# 运维手册

## 启动与停止

```bash
# 前台启动
php artisan epr:process-italy

# 停止（Ctrl+C）

# NSSM 服务方式（源流程，安装脚本见 install-service.bat → 服务 ItalyEPRFileProcess）
nssm start ItalyEPRFileProcess
nssm stop ItalyEPRFileProcess
nssm status ItalyEPRFileProcess
```

### API 接入模式服务

```bash
# 无安装脚本：手动 NSSM 注册两个服务（或在有安装脚本的版本上复用）
nssm install ItalyEprApiProcessor "path\to\php.exe"   # AppParameters: "artisan epr:process-api"
nssm install ItalyEprApiServer    "path\to\php.exe"   # AppParameters: "artisan serve --host=0.0.0.0 --port=8010"

# 前台运行（调试）
php artisan epr:process-api
php artisan serve --host=0.0.0.0 --port=8010

# NSSM 管理
nssm start ItalyEprApiProcessor    # API 兜底任务处理器（回收历史遗留 desc_status=0 行与陈旧 desc_status=1 行）
nssm start ItalyEprApiServer       # API 受理 HTTP 监听（POST /api/epr/italy，说明书在受理请求内同步生成）
nssm status ItalyEprApiProcessor
nssm status ItalyEprApiServer
```

**注意**：
- 说明书（使用说明）**在受理请求内同步生成**（2026-08-21 起），`epr:process-api` 不再负责新行；仍建议保留服务用于兜底（崩溃恢复 / 历史遗留行），`epr:process-api` 与 `epr:process-italy` 各只能有一个实例
- HTTP 监听使用 PHP 内置服务器（单线程），接入量增大时迁移 IIS / FrankenPHP
- PHP 内置服务器所在机器 php.ini `post_max_size` 需 ≥ 3M（2MB 请求体 + JSON 转义余量），否则 PHP 层 413 会绕过统一信封

## 日志

```powershell
# 实时查看
Get-Content storage\logs\epr-processor.log -Tail 50 -Wait

# 查看错误日志
Select-String -Path storage\logs\epr-processor.log -Pattern "ERROR"
```

- 日志通道：`epr`（专用）
- 文件：`storage/logs/epr-processor.log`
- 轮转：每日，保留 30 天

## 故障排查

| 现象 | 可能原因 | 排查方式 |
|------|----------|----------|
| 程序退出无日志 | 数据库连接失败 10 次 | 检查 source/target DB 连接 |
| 翻译一直失败 | DeepL API Key 过期 | 看日志中 `Translation` 相关错误 |
| COS 上传失败 | 网络或凭证过期 | 看日志中 `OSS` 相关错误 |
| 文档生成失败 | 模板文件不存在 | 检查 `EPR_WORD_TEMPLATE_PATH` 文件 |
| 记录卡在 status=5 | 某步骤抛异常 | 看日志中对应的 saas_id 和错误 |
| API 受理返回 401 | 鉴权开关开启（`EPR_API_TOKEN_ENABLED=true`）且 token 缺失或不匹配 | 检查请求头与 .env 的 `EPR_API_TOKEN` |
| API 受理返回 500 | 鉴权开启但 `EPR_API_TOKEN` 未配置 / target 库不可达 | 看日志中 `意大利 EPR API 受理异常` |
| API 受理返回信封 `code=500`（HTTP 恒 200），msg 形如「说明书生成失败（…）」 | 受理内同步生成说明书失败（翻译 / 文档生成 / OSS上传 / 数据处理） | 查行内 `desc_error_msg` 与日志 `API 受理同步生成 EPR 注册文件失败`；**未发回调**（调用方靠该 `code` 得知），可修正后重投 |
| API 行 desc_status=1（新行） | 受理请求内同步生成中（正常几秒内完成） | 查日志 `API 受理同步生成使用说明失败` 判断是否失败 |
| API 行 desc_status=1 长期滞留 | 同步生成进程崩溃（新行）/ 处理中崩溃（历史行） | 10 分钟后陈旧行自动被 `epr:process-api` 兜底认领；查日志与 `desc_count` |
| API 行 desc_status=0 | 历史遗留行（2026-08-21 前受理），`epr:process-api` 服务未运行 | `nssm status ItalyEprApiProcessor` |
| API 行 desc_status=3（受理内同步失败） | 受理请求内生成说明书失败，无重试 | 调用方已收到信封 `code=500`（HTTP 恒 200），**未发回调**；处置完毕可让调用方重投（重投会复用同一行并刷新 payload） |
| API 行 desc_status=3（兜底路径耗尽） | `epr:process-api` 重试达 `EPR_API_DESC_MAX_RETRIES` | 查 `desc_error_msg`，确认**已发送**失败回调；调用方可重新提交（终态允许） |
| 没有收到回调 | `EPR_RESULT_CALLBACK_URL` 为空或不可达 | 查行内 `callback_url` 与回调日志（`API 失败结果回调`）；`.env` 示例值 `http://192.168.1.211:8080/delivery/rpa/callback` |

## 数据库连接重试策略

- **source/target DB 连接失败**：每 5 秒重试，最多 10 次（共 50 秒），全部失败则退出（源流程与 API 流程相同）
- **DeepL API 调用失败**：重试 3 次（间隔 2s, 5s, 10s）；源流程全部失败则退出进程；API 流程仅该行失败（按重试次数回写行状态）
- **COS 上传失败**：失败即抛异常；源流程标记记录为 -1 继续，API 受理内同步生成失败即终态（desc_status=3）+ 信封 `code=500`（HTTP 恒 200，**不发回调**）
- **API 说明书阶段**：受理内同步生成、失败直接终态（无重试）并同步返回信封 `code=500`；`EPR_API_DESC_MAX_RETRIES`（默认 3）仅作用于 `epr:process-api` 兜底路径；RPA 授权书阶段 auth_count 上限 30 次后终态 + 失败回调
- **异步结果回调**：10s 超时、2xx 即送达；发送失败重试 `EPR_RESULT_CALLBACK_MAX_RETRIES` 次（默认 3，间隔 `EPR_RESULT_CALLBACK_RETRY_DELAY` 秒）；
  **只作用于 `epr:process-api` 兜底路径与 RPA 侧** —— 受理请求内不发任何回调（失败走同步信封 `code=500`，HTTP 恒 200）；
  重试会产生重复 POST（接收端幂等：成功回调按 `bizParam` + `data.task_id`，失败回调 `data=null` 按 `bizParam`），
  失败不影响任务状态更新，投递明细落 `notify_*` 列

退出后 NSSM 服务会自动重启（5 秒延迟）。

## 环境变量速查

```ini
# Source DB（待处理记录来源）
SOURCE_DB_HOST=
SOURCE_DB_PORT=1433
SOURCE_DB_DATABASE=
SOURCE_DB_USERNAME=
SOURCE_DB_PASSWORD=

# Target DB（处理结果写入）
TARGET_DB_HOST=
TARGET_DB_PORT=1433
TARGET_DB_DATABASE=
TARGET_DB_USERNAME=
TARGET_DB_PASSWORD=

# DeepL（国家名翻译）
DEEPL_API_KEY=
DEEPL_API_URL=https://api-free.deepl.com/v2/translate

# 腾讯云 COS
TENCENT_COS_SECRET_ID=
TENCENT_COS_SECRET_KEY=
TENCENT_COS_REGION=ap-guangzhou
TENCENT_COS_BUCKET=

# 处理器配置
EPR_WORD_TEMPLATE_PATH=Certificate_of_Use_Template.docx
EPR_TEMP_DIR=storage/app/temp
EPR_TRANSLATION_CACHE_PATH=storage/app/translations/country_names_it.json
EPR_POLL_INTERVAL=8

# API 接入配置
EPR_API_TOKEN_ENABLED=false       # 鉴权开关（false=跳过 token 验证；true=开启验证）
EPR_API_TOKEN=                    # Bearer token（仅开关开启时校验；开启且留空时接口 500）
EPR_API_MAX_BODY_BYTES=2097152
EPR_RESULT_CALLBACK_URL=http://192.168.1.211:8080/delivery/rpa/callback   # 异步结果回调地址（delivery 平台统一结果通知接口，留空不回调）
EPR_RESULT_CALLBACK_TIMEOUT=10
EPR_RESULT_CALLBACK_MAX_RETRIES=3  # 回调发送失败重试次数（总尝试 = +1；重复 POST 需接收端幂等）
EPR_RESULT_CALLBACK_RETRY_DELAY=5  # 回调重试间隔秒数
EPR_RESULT_CALLBACK_VERIFY_SSL=false  # 回调 HTTPS 证书校验（默认关闭：证书链含内网自签 CA）
EPR_API_COS_BUCKET=usaeu-1259285998  # API 行 COS 桶（留空=与 TENCENT_COS_BUCKET 同桶；须与 RPA 常量 API_FLOW_BUCKET_DEFAULT 一致）
EPR_API_OSS_PREFIX=common-test/generatefile/  # API 行 COS 相对路径前缀（须与 RPA 常量 API_FLOW_OSS_PREFIX 一致）
EPR_API_POLL_INTERVAL=8
EPR_API_DESC_MAX_RETRIES=3        # 仅作用于 epr:process-api 兜底路径

# 企业微信 Webhook（可选；用于轮换代码中已提交 git 的默认密钥）
WECHAT_WEBHOOK_URL=
```

## 上线检查清单（API 接入模式）

1. **先执行库变更**：在 target 库执行 `database/italia_epr_file_api_columns.sql`（幂等，可重复执行），核对 `data_source`、`notify_status` 等列已存在
2. **对齐三份常量**：`.env` 的 `EPR_API_COS_BUCKET` / `EPR_API_OSS_PREFIX` / `EPR_RESULT_CALLBACK_URL` 必须分别与 `python/rpa_save.py` 顶部的 `API_FLOW_BUCKET_DEFAULT` / `API_FLOW_OSS_PREFIX` / `DEFAULT_CALLBACK_URL` 一致（RPA 进程不读 `.env`）；否则 PHP 与 RPA 各自传到不同桶/前缀，两份文件不会落在同一目录
3. 部署代码后重启 `ItalyEPRFileProcess`（源流程，install-service.bat）并确保 `ItalyEprApiProcessor` / `ItalyEprApiServer` 两个服务在运行
4. 验收：`curl -X POST http://127.0.0.1:8010/api/epr/italy -H "Authorization: Bearer $EPR_API_TOKEN" -H "Content-Type: application/json" -d @docs/api/italy_epr_request.json`（期望 200 信封；**受理后行应为 desc_status=2 + desc_file_url**——说明书在请求内同步生成，无需等待 worker；`desc_file_url` 落在 `{EPR_API_OSS_PREFIX}{年}/epr_italia/{流水号}/`）
5. 回调验收：RPA 回写后核对回调 `data.files` 为两项（`EPR注册文件` + `授权书`）且 `url` 为 OSS 相对路径；失败态核对 `data=null`、原因在 `msg`、`notify_status` 有值（样例见 `docs/api/italy_epr_callback_*.json`）
6. **与 RPA 团队确认**：
   - RPA 程序需把 `rpa_get.py` 返回行的 `data_source` / `callback_url` 透传给 `rpa_save.py`，否则 API 行会按 source 处理并错误访问 vat_db
   - RPA 程序调用 `rpa_get.py` 时需传入腾讯云凭证（`secret_id` / `secret_key`，可选 `api_bucket_name` / `api_region`）——API 桶是**私有桶**，签名文件下载必须带签名，不传凭证会 403（source 流程走的 file.usaeu.com 不需要凭证）

## 关键路径

| 路径 | 用途 |
|------|------|
| `storage/app/temp/` | Word 文档临时目录（上传后自动清理） |
| `storage/app/translations/country_names_it.json` | 翻译缓存 |
| `storage/logs/epr-processor.log` | 处理器专用日志 |
| `storage/Certificate_of_Use_Template.docx` | Word 模板（模板路径经 `storage_path()` 解析，放 storage/ 目录） |

## 数据一致性验证

```sql
-- 检查是否有记录卡住（status=5 但长时间未处理）
SELECT COUNT(*) FROM EPRBusinessRecord
WHERE Country = 'IT' AND PushTaxBureauStatus = 5
  AND UpdateTime < DATEADD(MINUTE, -30, GETDATE());

-- 检查处理失败记录
SELECT Id, PushTaxBureauErrorMsg FROM EPRBusinessRecord
WHERE Country = 'IT' AND PushTaxBureauStatus = -1
ORDER BY Id DESC;
```
