# 法国 LEKO 合并文件接口文档

> **对接契约来源**：本文档描述本项目（`fr_epr_reg`）实现侧的接口契约。
> 完整对接契约（字段总表、PushType 枚举、新系统侧流程）以新系统仓库文档为准：
> `E:\ou\meiou-app\app_withdrawn\docs\UNIFIED_API_DESIGN.md` §4.4.22。
>
> **同族接口**：生成文件（[`french-file-generation-api.md`](french-file-generation-api.md)）→ **合并文件（本文）**
> → 注册提交（[`french-registration-mail-api.md`](french-registration-mail-api.md)）→ 下号（RPA 流程，本服务不覆盖）。

## 接口地址

**POST** `/fr_epr_reg/api/epr/fr/merge-xlsx`

服务器地址：`http://automation.usaeu.com:8888/`

完整地址：`http://automation.usaeu.com:8888/fr_epr_reg/api/epr/fr/merge-xlsx`

## 功能说明

供新系统（SaaS）同步调用：把同一批次多家公司的 LEKO XLSX（生成接口返回的
`data.files[]` 中 `type=EPR业务申请表` 的项）合并为一份公司清单，写回 API 桶后返回相对路径。

- 合并语义与老接口（`POST /api/epr/merge-xlsx`，source 流程）**完全一致**：首个文件为底稿
  （保留第 1~13 行表头与格式），其余文件取第 14 行 A~AJ 依次追加到第 15、16… 行，
  数据区统一 `FORMAT_TEXT`（防长数字转科学计数法）。
- 与老接口的差异：入参为 **API 桶 OSS 相对路径**（而非 source 库 `Base_AnnexesFile` 的 `AttachmentIDs`），
  产物写 **API 桶**（而非 source 桶），返回 `data.files[]` 相对路径（而非 `data.oss_url` 完整 URL）。
- 本接口对源库（`sqlsrv_source`）与目标库**零读写**，不调用 INSEE。

## 访问控制

本接口**不受** `internal.network`（172.16.x.x）限制，与生成接口共用同一组守卫：

| 中间件 | 作用 | 失败响应 |
|---|---|---|
| `fr.api.transport` | 仅接受 POST + `Content-Type: application/json`；请求体上限 `FR_FILE_API_MAX_BODY_BYTES`（默认 1 MB） | 405 / 400 |
| `fr.api.bearer` | 可选 Bearer 鉴权，由 `FR_FILE_API_AUTH_ENABLED` 控制（默认 `false`） | 401 |
| `fr.api.rate` | 按客户端 IP 限流，`FR_FILE_API_RATE_LIMIT_PER_MINUTE`（默认 60） | 429 |

> **限流配额**：生成 / 合并 / 注册三个接口**共享同一每 IP 配额**（60/分钟）。

## 请求

### Content-Type

`application/json`

### 请求参数（统一信封）

| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| PushType | string | 是 | 固定 `FR_EPR_REGISTER_LEKO_FILE_MERGE`；白名单精确分派 |
| Country | string | 是 | 业务国，**必须恰为 `FR`** |
| Data.files | array | 是 | 待合并的 XLSX 文件数组，**2~50 条** |
| Data.files[].url | string | 是 | **API 桶 OSS 相对路径**（生成接口 `data.files[].url` 原样回传即可） |
| Data.files[].name | string | 否 | 文件名（不参与合并处理，当前未使用） |
| Data.files[].type | string | 否 | 若提供必须为 `EPR业务申请表`（防止把 POA PDF 混入合并） |
| bizParam | object | 是 | 透传参数；成功与失败响应均**原样回传**（合并跨多家公司，**不强制** `BusinessSerialNumber`） |

### OSS key 规则（安全校验）

所有 `url` 必须是 **API 桶内的相对路径**，服务端逐条校验：

1. 非空、≤1024 字符、非绝对 URL（`scheme://`）、非前导 `/`、无 `\`、无控制字符；
2. 无 `.` / `..` 路径段（含 `%2e%2e` 等百分号编码绕过）；
3. 必须匹配 `{OSS_API_PREFIX}{4位年}/{OSS_API_MODULE_DIR}/`（如 `common-test/generatefile/2026/fr_epr_reg/…`）；
4. 对象必须存在（`Storage::exists`）。

违反 1~3 → 聚合校验错误（400，`msg` 形如 `Validation failed: Data.files.0.url is invalid: …`）；
`url` 指向 `merged/` 目录（合并产物回灌再合并）→ 同以聚合校验错误拒绝；不存在 → `OSS_KEY_NOT_FOUND`；
单文件超 `FR_FILE_API_OSS_MAX_FILE_BYTES`（默认 20 MB）或合计超 `FR_FILE_API_OSS_MAX_TOTAL_BYTES`
（默认 200 MB）→ `OSS_FILE_TOO_LARGE`。

### 请求示例

```json
{
    "PushType": "FR_EPR_REGISTER_LEKO_FILE_MERGE",
    "Country": "FR",
    "bizParam": {
        "BusinessSerialNumber": "FREPR2026000001"
    },
    "Data": {
        "files": [
            {
                "url": "common-test/generatefile/2026/fr_epr_reg/leko/FREPR2026000001/Leko_Template list of companies_v3.11 2026.xlsx",
                "name": "Leko_Template list of companies_v3.11 2026.xlsx",
                "type": "EPR业务申请表"
            },
            {
                "url": "common-test/generatefile/2026/fr_epr_reg/leko/FREPR2026000002/Leko_Template list of companies_v3.11 2026.xlsx",
                "name": "Leko_Template list of companies_v3.11 2026.xlsx",
                "type": "EPR业务申请表"
            }
        ]
    }
}
```

**文件顺序即输出行序**：`files[0]` 的公司留在第 14 行，`files[1]` 追加到第 15 行，依此类推。

## 响应

### 成功响应（200）

```json
{
    "code": 200,
    "msg": "success",
    "ProcessMode": "sync",
    "data": {
        "files": [
            {
                "url": "common-test/generatefile/2026/fr_epr_reg/merged/merged_20260912_101500_a1b2c3d4.xlsx",
                "name": "merged_20260912_101500_a1b2c3d4.xlsx",
                "type": "EPR业务申请表"
            }
        ]
    },
    "bizParam": {
        "BusinessSerialNumber": "FREPR2026000001"
    }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| data.files | array | 固定单元素数组（合并产物） |
| data.files[].url | string | 合并文件 OSS 相对路径，目录 `{OSS_API_PREFIX}{年}/{OSS_API_MODULE_DIR}/merged/` |
| data.files[].name | string | `merged_{Ymd_His}_{8位随机字符}.xlsx` |
| data.files[].type | string | 固定 `EPR业务申请表` |
| bizParam | object | 请求 `bizParam` 原样回传 |

### 校验失败（400）

```json
{
    "code": 400,
    "msg": "Validation failed: Data.files must contain at least 2 entries",
    "data": null
}
```

所有字段错误聚合在**一次** 400 响应中（`msg` 为分号分隔的错误列表）。

### 业务失败（400）

`msg` 为稳定错误类别（不回显路径、堆栈、凭证或内部端点）：

```json
{
    "code": 400,
    "msg": "OSS_KEY_NOT_FOUND",
    "data": null
}
```

| 类别 | 触发场景 |
|------|----------|
| key 违规（聚合校验错误） | key 为空 / 绝对 URL / 目录穿越 / 不在本流程 API 目录内（含 `%2e%2e` 编码绕过）/ 指向 `merged/` 目录——随其余字段错误聚合返回，`msg` 为 `Validation failed: …`，非独立分类码 |
| `OSS_KEY_NOT_FOUND` | OSS 对象不存在 |
| `OSS_FILE_TOO_LARGE` | 单文件或合计字节数超上限 |
| `OSS_DOWNLOAD_FAILED` | 从 API 桶读取失败（含空对象） |
| `XLSX_MERGE_FAILED` | PhpSpreadsheet 合并失败（文件损坏/非 XLSX 结构） |
| `OSS_UPLOAD_FAILED` | 合并结果上传 API 桶失败 |

### 传输 / 鉴权 / 限流 / 未分类失败

| HTTP | 场景 |
|------|------|
| 405 | 非 POST 方法 |
| 400 | Content-Type 非 `application/json`，或请求体超限 |
| 401 | `FR_FILE_API_AUTH_ENABLED=true` 且 Bearer 缺失/错误 |
| 429 | 超出每 IP 每分钟配额（三接口共享） |
| 500 `Internal error` | 未分类异常；不回显内部消息，仅记录异常类名 |

## 实现细节

- 编排：`FrenchMergeService`（校验 → 逐 key 校验/下载 → `XlsxRowMerger` 合并 → 上传 → 响应信封）。
- 共享核心：`XlsxRowMerger`（与 source 流程 `XlsxMergeService` 同一实现，行语义完全一致）。
- OSS key 校验与流式下载：`FrenchApiOssKeyValidator`（`readStream()` 落临时文件，**不用 `get()`**，
  避免多文件占内存；临时文件在 `finally` 中统一清理）。
- 目录构造：`FrenchApiOssPath::directory('merged')` → `{OSS_API_PREFIX}{年}/{OSS_API_MODULE_DIR}/merged`。
- 日志：`api_french` 通道（仅记录 push_type、文件数、合并 key；不含业务字段）。

## cURL 调用示例

```bash
# 先用生成接口拿到各公司 XLSX 的 data.files[].url（相对路径），再按顺序填入 Data.files
curl -X POST \
  http://automation.usaeu.com:8888/fr_epr_reg/api/epr/fr/merge-xlsx \
  -H 'Content-Type: application/json' \
  -d '{
    "PushType": "FR_EPR_REGISTER_LEKO_FILE_MERGE",
    "Country": "FR",
    "bizParam": {"BusinessSerialNumber": "FREPR2026000001"},
    "Data": {"files": [
      {"url": "common-test/generatefile/2026/fr_epr_reg/leko/FREPR2026000001/Leko_Template list of companies_v3.11 2026.xlsx", "type": "EPR业务申请表"},
      {"url": "common-test/generatefile/2026/fr_epr_reg/leko/FREPR2026000002/Leko_Template list of companies_v3.11 2026.xlsx", "type": "EPR业务申请表"}
    ]}
  }'
```

启用 Bearer 时追加请求头 `-H 'Authorization: Bearer <FR_FILE_API_AUTH_TOKEN>'`。
