Files
shanxia2.0/openspec/changes/zkxw-engineering-standards/specs/api-conventions/spec.md
T
2026-08-21 16:50:47 +08:00

493 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## ADDED Requirements
### Requirement: 统一 POST 接口
除认证类接口(登录 RSA 公钥获取等系统级 GET 接口)和文件上传/下载接口外,**新增的业务 API 端点** SHALL 统一使用 HTTP `POST` 方法。SHALL NOT 使用 `GET`、`PUT`、`DELETE` 方法定义新的业务路由。
> **存量代码不追溯**:本规范仅约束**新增模块**(如 CRM、报销、地磅等迁移模块)。`app/api/admin.py`、`app/api/public.py`、`app/api/menu.py` 等既有模块保持现有 GET/POST/PUT/DELETE 混用风格,SHALL NOT 为符合新规范而重构已有代码。新旧模块风格并存是正常的过渡状态。
#### Scenario: 新增业务路由
- **WHEN** 开发者在 `app/api/` 中新增业务接口
- **THEN** 路由装饰器 SHALL 使用 `@router.post()`,SHALL NOT 使用 `@router.get()` / `@router.put()` / `@router.delete()`
#### Scenario: 存量模块不修改
- **WHEN** `app/api/admin.py` 等既有模块使用 `@router.get()` / `@router.put()` / `@router.delete()`
- **THEN** SHALL NOT 为符合新规范而修改这些文件,保持原样
#### Scenario: 查询类接口
- **WHEN** 实现列表查询、详情查询、下拉选项等只读接口
- **THEN** 依然使用 `POST` 方法,查询条件通过请求体(JSON body)传入
#### Scenario: 文件上传下载例外
- **WHEN** 接口涉及文件上传(如附件上传、Excel 导入)
- **THEN** 可使用 `POST` + `multipart/form-data`,请求体使用 `FormData` 而非 JSON
- **WHEN** 接口涉及文件下载(如 Excel 导出、模板下载)
- **THEN** 可使用 `POST`,返回 `StreamingResponse` 或 `FileResponse`,`Content-Type` 为对应文件类型
---
### Requirement: 统一请求体格式
所有 `POST` 业务接口的请求体 SHALL 使用 JSON 格式,并遵循以下分类规范:
#### 列表查询请求体
```json
{
"page": 1,
"pageSize": 10,
"<筛选字段1>": "<值>",
"<筛选字段2>": "<值>"
}
```
- `page`:页码,默认 `1`,从 1 开始
- `pageSize`:每页条数,默认 `10`,最大 `500`
- 筛选字段为可选,按业务需要定义
- 排序字段暂不在请求体中定义,使用后端默认排序(`create_time DESC`),后续按需扩展
#### 全量查询请求体(不分页)
当接口需要返回全量数据(如树形列表、下拉选项、字典数据、所有标签等)时,请求体 SHALL NOT 包含 `page` 和 `pageSize` 字段。后端 SHALL 返回全量数据,不进行分页。
```json
{
"<筛选字段1>": "<值>"
}
```
> **关键约束**:SHALL NOT 通过设置 `pageSize: 999`、`pageSize: 9999` 等大数值来模拟全量查询。这会导致后端参数校验 422 错误(pageSize 上限为 500)。需要全量数据时,必须调用不分页接口。
#### Scenario: 产品列表分页查询
- **WHEN** 前端调用产品列表接口
- **THEN** 请求体 SHALL 为 `{ "page": 1, "pageSize": 10, "productName": "关键词", "categoryId": "xxx", "status": "1" }`
#### Scenario: 产品分类树形全量查询
- **WHEN** 前端调用产品分类树形接口(不分页)
- **THEN** 请求体 SHALL NOT 包含 `page` 和 `pageSize` 字段,后端返回全量树形数据
#### Scenario: 下拉选项全量查询
- **WHEN** 前端调用下拉选项接口(如产品选择器、分类选择器)
- **THEN** 请求体 SHALL NOT 包含 `page` 和 `pageSize` 字段,后端返回全量选项列表
#### Scenario: 禁止超大 pageSize 模拟全量
- **WHEN** 开发者需要获取全量数据
- **THEN** SHALL NOT 设置 `pageSize: 999` / `pageSize: 9999` / `pageSize: 1000` 等超过 500 的值
- **THEN** SHALL 使用对应的不分页接口(如 `/options`、`/tree`)
#### Scenario: 后端 pageSize 校验
- **WHEN** 后端分页接口接收到 `pageSize > 500`
- **THEN** SHALL 返回 422 参数校验错误,SHALL NOT 静默截断或放行
#### 详情查询请求体
```json
{
"id": "<主键ID>"
}
```
#### 新增请求体
```json
{
"<业务字段1>": "<值>",
"<业务字段2>": "<值>"
}
```
- `id` SHALL NOT 出现在新增请求体中(由后端自动生成 UUID)
- `tenant_no` SHALL NOT 出现在请求体中(由后端从当前用户上下文注入)
- 公共字段(`create_by`、`create_time`、`del_flag` 等)SHALL NOT 出现在请求体中
#### 更新请求体
```json
{
"id": "<主键ID>",
"<业务字段1>": "<值>",
"<业务字段2>": "<值>"
}
```
- `id` 为必填,标识要更新的记录
- 其余字段为可选(只传需要更新的字段)
#### 删除请求体
```json
{
"id": "<主键ID>"
}
```
- 单条删除使用单个 `id`
- 批量删除使用 `"ids": ["id1", "id2", ...]`
#### Scenario: 禁止客户端传递系统字段
- **WHEN** 前端调用新增或更新接口
- **THEN** 请求体 SHALL NOT 包含 `id`(新增时)、`tenant_no`、`create_by`、`create_time`、`update_by`、`update_time`、`del_flag` 等系统字段
---
### Requirement: JSON 字段命名(前后端 camelCase 对齐)
Web 管理端(`web-admin`)与对外 JSON 接口中,**请求体与响应 `data` 内的业务字段** SHALL 使用 **camelCase**(如 `categoryName`、`pageSize`、`createTime`)。Python 服务层(Pydantic 模型属性名、CRUD 函数参数、`model_dump()` 传给 ORM 的键)SHALL 继续使用 **snake_case**(如 `category_name`)。
新增或迁移模块的 Pydantic 请求体(`app/schemas/**` 或路由内联 `BaseModel`)SHALL 同时满足:
1. `model_config = ConfigDict(populate_by_name=True)`
2. 多词字段定义 Python 名为 snake_case,并通过 `Field(..., alias="camelCase名")` 声明 JSON 别名
3. 路由内调用 CRUD 时优先使用 `body.model_dump(exclude_unset=True)`(默认按字段名输出 snake_case),SHALL NOT 要求前端改为 snake_case
4. **路由层读取字段**时 SHALL 使用 Schema 上的 **Python 属性名**(见下表),SHALL NOT 使用 JSON `alias` 名作为 `body.xxx` 访问(`alias` 仅参与反序列化,**不会**在模型上生成 camelCase 属性)
| 前端 JSON(camelCase) | Python 属性(路由内访问) | 说明 |
|------------------------|---------------------------|------|
| `createTimeRange` | `body.create_time_range` | 常见列表筛选 |
| `viewMode` | `body.view_mode` | 如 `pool` / `mine` / `all` |
| `customerName` | `body.customer_name` | |
| `customerIds` | `body.customer_ids` | 批量公海操作 |
| `pageSize` | `body.pageSize` | **例外**:部分 Schema 将字段名直接定义为 `pageSize`(无 snake_case、无 alias),须以源码为准 |
列表/详情等读接口的 `_row_to_dict`(或等价转换)SHALL 将 ORM 列名转为 camelCase 键名再返回前端。
参考实现:`app/api/admin.py` 中 `TenantCreateBody`;CRM:`app/schemas/crm/customer.py`、`app/schemas/crm/product.py`;路由:`app/api/crm.py`(客户列表/公海池);**运输计划**:`app/schemas/pl/**`(`openspec/changes/transport-plan-management/specs/pl-api-json/spec.md`);地磅:`app/schemas/db/weighing.py`。
FastAPI 路由 Query 参数若 Python 参数名为 `page_size`,SHALL 声明 `Query(..., alias="pageSize")`,与前端列表分页一致。
#### Scenario: 运输计划承运商新增 422 回归
- **WHEN** 前端 `POST /api/transport/carrier` 发送 `{ "carrierName": "测试" }`
- **THEN** `CarrierCreate` SHALL 含 `carrier_name = Field(..., alias="carrierName")` 且校验通过
- **AND** SHALL NOT 因缺少 alias 返回 422 `carrier_name Field required`
#### Scenario: 运输计划列表 pageSize Query
- **WHEN** 前端 `GET /api/transport/plan/?page=2&pageSize=50`
- **THEN** 路由 SHALL 使用 `page_size: int = Query(..., alias="pageSize")` 解析为 50
- **AND** SHALL NOT 忽略 `pageSize` 而始终使用默认 20
#### Scenario: 前端提交新增分类
- **WHEN** 前端调用 `POST /web/crm/product-categories/add`,请求体为 `{ "categoryName": "有机原料", "categoryCode": "yjyl", "orderNum": 0 }`
- **THEN** 后端 Pydantic 模型 SHALL 校验通过,且 `model_dump()` 得到 `{ "category_name": "有机原料", "category_code": "yjyl", "order_num": 0, ... }` 供 CRUD 使用
#### Scenario: 仅定义 snake_case 字段名导致 422
- **WHEN** 请求体 JSON 使用 camelCase,但 Schema 仅声明 `category_name` 且无 `alias` / `populate_by_name`
- **THEN** 属于规范违反;修复方式为补全 alias,SHALL NOT 将 web-admin 改为发送 snake_case 作为长期方案
#### Scenario: 响应列表字段
- **WHEN** 接口返回单条或列表业务记录
- **THEN** `data` 内字典键 SHALL 为 camelCase(如 `parentId`、`createTime`),与前端 TypeScript 字段一致
#### Scenario: 存量 admin 内联 Schema
- **WHEN** `app/api/admin.py` 等既有模块已使用 `populate_by_name` + `alias`
- **THEN** 新增模块 SHALL 复用同一模式;SHALL NOT 为对齐规范而批量重构存量文件,除非该文件正在因其他需求修改
#### Scenario: 路由层按 alias 名访问请求体导致 500
- **GIVEN** `CustomerListBody` 定义 `create_time_range: Optional[List[str]] = Field(..., alias="createTimeRange")`
- **WHEN** 路由中编写 `if body.createTimeRange:` 或 `view_mode=body.viewMode`
- **THEN** 运行时将抛出 `AttributeError`(如 `'CustomerListBody' object has no attribute 'createTimeRange'`),HTTP 常表现为 `code: 500`;修复 SHALL 改为 `body.create_time_range`、`body.view_mode` 等 Python 属性名,或改为 `dump = body.model_dump(exclude_unset=True)` 后从 `dump` 取值
#### Scenario: CRM 公海池列表 viewMode
- **WHEN** 前端 `POST /web/crm/customer/list` 请求体为 `{ "page": 1, "pageSize": 10, "viewMode": "pool" }`
- **THEN** 路由 SHALL 使用 `body.view_mode`(值为 `pool`)传入 CRUD,SHALL NOT 使用 `body.viewMode`;`page` / `pageSize` 按 Schema 定义使用 `body.page`、`body.pageSize`
#### Scenario: 代码评审 — 路由读 body 字段
- **WHEN** PR 在 `app/api/**` 中新增或修改对 Pydantic `body` 的字段访问
- **THEN** 审查者 SHALL 对照对应 `app/schemas/**` 中的**属性名**(非 `alias` 字符串);若出现 `body.[a-z]+[A-Z]` 形式且 Schema 为 snake_case + alias,SHALL 要求修正
---
### Requirement: 统一响应格式
所有业务接口 SHALL 返回统一的 JSON 响应结构:
```json
{
"code": 200,
"message": "成功",
"data": <任意类型或null>,
"timestamp": 1748572800000
}
```
#### Scenario: 成功响应
- **WHEN** 接口执行成功
- **THEN** 响应体 SHALL 为 `{ "code": 200, "message": "成功", "data": <业务数据>, "timestamp": <毫秒时间戳> }`
#### Scenario: 失败响应
- **WHEN** 接口执行失败(校验不通过、业务异常等)
- **THEN** 响应体 SHALL 为 `{ "code": <错误码>, "message": "<可读错误描述>", "data": null, "timestamp": <毫秒时间戳> }`
#### Scenario: 分页列表响应
- **WHEN** 接口返回分页数据
- **THEN** `data` 字段 SHALL 为 `{ "list": [...], "total": 100, "page": 1, "pageSize": 10, "totalPages": 10 }`
#### Scenario: 单条记录响应
- **WHEN** 接口返回详情或新增/更新后的记录
- **THEN** `data` 字段 SHALL 为该记录的字典对象(通过 `_row_to_dict` 转换)
#### Scenario: 无数据响应
- **WHEN** 接口执行删除等无需返回数据的操作
- **THEN** `data` 字段 SHALL 为 `null`,`message` SHALL 为操作结果描述(如 "删除成功")
---
### Requirement: 统一返回码定义
所有业务接口 SHALL 使用以下返回码体系:
| 返回码 | 含义 | 使用场景 |
|--------|------|---------|
| `200` | 成功 | 所有成功响应 |
| `400` | 请求参数错误 | 必填字段缺失、格式不合法、业务校验失败 |
| `401` | 未认证 | Token 缺失、Token 过期、Token 无效 |
| `403` | 无权限 | 当前用户无权访问该资源或操作 |
| `404` | 资源不存在 | 根据 ID 查询的记录不存在(或已删除) |
| `409` | 数据冲突 | 唯一性校验失败(如编号重复) |
| `500` | 服务器内部错误 | 未捕获的异常、数据库错误 |
#### Scenario: 参数校验失败
- **WHEN** 请求体缺少必填字段或字段格式不合法
- **THEN** SHALL 返回 `{ "code": 400, "message": "<具体校验失败原因>", "data": null }`
#### Scenario: 唯一性冲突
- **WHEN** 新增或更新时违反唯一性约束(如产品编号重复)
- **THEN** SHALL 返回 `{ "code": 409, "message": "产品编号已存在", "data": null }`
#### Scenario: 记录不存在
- **WHEN** 按 ID 查询或操作的记录不存在或已删除
- **THEN** SHALL 返回 `{ "code": 404, "message": "记录不存在", "data": null }`
#### Scenario: 未登录
- **WHEN** 请求未携带有效 Token
- **THEN** SHALL 返回 `{ "code": 401, "message": "未登录或登录已过期", "data": null }`
#### Scenario: Web 登录账号或密码错误
- **WHEN** `POST /web/auth/login` 校验失败(用户不存在、密码错误等)
- **THEN** SHALL 返回 `{ "code": 400, "message": "用户名或密码错误", "data": null }`,SHALL NOT 使用 `401`(避免前端将业务码误判为 Token 失效并触发登出)
#### Scenario: Web 登录查用户
- **WHEN** Web 登录按 `user_no` 解析 `sys_user` 且未传租户
- **THEN** SHALL 优先使用 `DEFAULT_TENANT_NO`(`shanxia2.0`);未命中时可按 `user_no` 回退查询以兼容未完成租户迁移的库,并建议尽快执行 `docs/sql/20260530_migrate_tenant_no_to_shanxia2.0.sql`
#### Scenario: 无权限
- **WHEN** 当前用户角色无权执行该操作
- **THEN** SHALL 返回 `{ "code": 403, "message": "无操作权限", "data": null }`
#### Scenario: 服务器异常
- **WHEN** 发生未预期的异常(数据库错误、代码 bug 等)
- **THEN** SHALL 返回 `{ "code": 500, "message": "服务器内部错误", "data": null }`,SHALL NOT 将堆栈信息暴露给前端
---
### Requirement: URL 路径设计规范
统一 POST 后,URL 路径 SHALL 通过路径语义区分操作类型:
| 操作 | URL 模式 | 示例 |
|------|---------|------|
| 列表查询 | `/web/<module>/<resource>/list` | `/web/crm/products/list` |
| 详情查询 | `/web/<module>/<resource>/detail` | `/web/crm/products/detail` |
| 新增 | `/web/<module>/<resource>/add` | `/web/crm/products/add` |
| 更新 | `/web/<module>/<resource>/update` | `/web/crm/products/update` |
| 删除 | `/web/<module>/<resource>/delete` | `/web/crm/products/delete` |
| 下拉选项 | `/web/<module>/<resource>/options` | `/web/crm/products/options` |
| 导入 | `/web/<module>/<resource>/import` | `/web/crm/products/import` |
| 导出 | `/web/<module>/<resource>/export` | `/web/crm/products/export` |
| 下载模板 | `/web/<module>/<resource>/import-template` | `/web/crm/products/import-template` |
#### Scenario: 新增资源路由
- **WHEN** 为 CRM 产品模块定义路由
- **THEN** 路由 SHALL 为 `@router.post("/products/list")`, `@router.post("/products/add")` 等,SHALL NOT 使用 RESTful 风格的 `@router.get("/products")` 或 `@router.put("/products/{id}")`
#### Scenario: 避免路径参数
- **WHEN** 操作需要指定某条记录
- **THEN** 记录标识 SHALL 通过请求体 `{ "id": "xxx" }` 传入,SHALL NOT 通过 URL 路径参数 `/products/{id}` 传入
---
### Requirement: 租户隔离
所有面向业务数据的查询接口 SHALL 仅返回 `tenant_no` 等于当前登录用户租户编号的数据;所有写入或更新类接口 SHALL 将目标业务行的 `tenant_no` 设置为当前登录用户的 `tenant_no`(与《shanxia2.0需求 V0.3》§3 一致)。
本仓库门户默认租户编号 SHALL 为 **`shanxia2.0`**(配置项 `DEFAULT_TENANT_NO`、DDL/ORM 默认值见 `database-conventions` 与 `coding-conventions`)。无 JWT 的公共入口(如 Web 登录按 `user_no` 查 `sys_user`)SHALL 使用该默认租户,SHALL NOT 使用历史编码 `zkxw`。
#### Scenario: 列表查询
- **WHEN** 调用任意业务数据列表或详情接口
- **THEN** 后端 SHALL 在查询条件中强制包含当前用户的 `tenant_no`
#### Scenario: 新建业务主表
- **WHEN** 创建新的业务主表记录
- **THEN** 持久化的 `tenant_no` SHALL 等于当前登录用户租户,且不得信任客户端单独提交的租户覆盖服务端策略(除非经安全评审的多租户管理接口)
### Requirement: 数据权限过滤
除超级管理员等需求明确豁免的场景外,所有业务查询接口 SHALL 叠加当前登录用户**全部角色**合并后的数据权限范围进行过滤(与需求 §2.1.5「多角色取权限之和」一致)。
#### Scenario: 用户绑定多角色
- **WHEN** 用户拥有角色 A 与角色 B
- **THEN** 可访问数据范围 SHALL 为两角色数据权限的并集(菜单与数据权限均适用并集语义)
### Requirement: 列表默认排序
在无单独产品排序要求的前提下,列表类接口返回的数据集合 SHALL 按 `create_time` 降序排列(与需求 §3 一致)。
#### Scenario: 默认分页列表
- **WHEN** 接口未声明自定义排序参数且业务无例外说明
- **THEN** 默认排序 SHALL 为 `create_time DESC`
### Requirement: API 前缀与工程约定
对外 HTTP API SHALL 使用统一前缀(当前仓库为 `/api` 及版本/子路径,见 `AGENTS.md`);新增路由 SHALL 注册到既有路由聚合机制,避免绕过中间件(鉴权、加解密、审计等)。
#### Scenario: 新增 admin 路由
- **WHEN** 在 `app/api/` 增加新的路由模块
- **THEN** 该模块 SHALL 在 `app/api/__init__.py` 与 `app/main.py` 中完成注册,并符合现有认证与租户上下文注入模式
### Requirement: 小程序拍照凭证上传接口
`miniprogram` 拍照凭证(仅图片,见 `ui-conventions`「小程序拍照凭证上传(基线)」)相关接口 SHALL 遵循下列约定;权威行为对齐叁强鲜配 `POST /api/upload/image` + 业务接口携带 `imageUrls`。
#### 上传接口
1. **方法与编码**:`POST` + `multipart/form-data`(文件上传例外,见上文「文件上传下载例外」)。
2. **表单字段**:文件字段名 SHALL 为 `file`;业务目录字段 SHALL 为 `folder`(`Form` 字符串,如 `delivery-evidence`、`avatars`)。
3. **鉴权**:SHALL 要求登录态(Bearer JWT 或与小程序一致的现有鉴权);未登录 SHALL 返回 401。
4. **成功响应**:统一信封下 `data` SHALL 至少包含 `url`(对象存储可访问地址);MAY 含 `filename`、`contentType`、`size`。前端以 `data.url` 为唯一回填依据。
5. **类型与大小**:SHALL 仅接受图片 MIME(至少覆盖 `image/jpeg`、`image/png`、`image/webp`;建议兼容 `image/heic`/`image/heif`);单文件大小上限建议 **10MB**(与叁强一致,业务另有约定时在接口文档写明)。
6. **路径落点**:山峡落地时 MAY 复用/对齐 `POST /api/upload/image`,或提供 `POST /api/mini/.../upload/image`;无论路径如何,小程序 `uni.uploadFile` 的 `url` 与后端注册路径 SHALL 一致,并完成路由注册(`app/api/__init__.py`、`app/main.py`)。
#### 加解密排除(强制)
`multipart` 上传路径 SHALL 列入服务端 `API_ENCRYPT_EXCLUDE_PATHS`(及前端若启用 API 加密时的排除清单)。微信小程序端若因运行时不保证 WebCrypto 而关闭客户端加密(叁强 `sqxp-wx/utils/request.js` 在 `MP-WEIXIN` 下 `API_ENCRYPT_ENABLED = false`),上传仍 SHALL 依赖 HTTPS + Bearer,SHALL NOT 对文件字节做 JSON AES 包装。
#### 业务关联
1. 业务确认类接口(如取货、送达、备货完成)请求体 SHALL 使用 JSON,图片以 **URL 数组** 传递(建议字段名 `imageUrls: string[]`)。
2. 服务端收到 URL 列表后 SHALL 校验归属与数量上限,再更新业务状态;需要附件台账时 SHALL 写入附件表(或等价持久化),`file_url` 存完整 URL;多 URL 落单字段时 SHALL 使用英文逗号分隔(见 `architecture-conventions`「附件与对象存储」)。
3. 业务接口 SHALL NOT 再接收同一确认动作中的 multipart 文件流。
#### Scenario: 小程序上传一张凭证图
- **WHEN** 已登录小程序用户对图片上传接口提交 `file` + `folder`
- **THEN** 响应 `code` SHALL 为成功码且 `data.url` 非空,对象存储中可按该 URL 访问图片
#### Scenario: 业务提交携带 imageUrls
- **WHEN** 用户确认取货/送达等动作且本地已有已上传 URL
- **THEN** 业务 POST 体 SHALL 含 `imageUrls` 数组,服务端 SHALL 据此落库/关联,SHALL NOT 要求客户端再次上传二进制
#### Scenario: 上传路径未排除加密
- **WHEN** 上传路径未加入 `API_ENCRYPT_EXCLUDE_PATHS` 且全局 API 加密开启
- **THEN** 视为规范违规;实现与评审 SHALL 在合入前补齐排除项
### Requirement: 细胞存储样本跨表关联键
细胞存储场景下,将协议样本行(`biz_protocol_sample`)与下游业务(样本接收 `biz_sample_reception`、质检工单 `biz_qc_work_order` 等)进行**业务匹配**时,关联键 SHALL 仅为 `tenant_no` + `sample_code`(租户内样本编码唯一且不变;接收/工单表须持久化 `sample_code` 快照)。**不得**使用 `collection_protocol_id` 或 `protocol_sample_id` 作为查询、列表排除、详情回显的关联依据(协议单可换号/转科研、编辑保存会重建样本行 `id`,但样本编码不变)。
#### Scenario: 协议单查看弹窗展示样本接收
- **WHEN** 用户打开采集协议单详情,且租户内某 `sample_code` 已登记接收
- **THEN** 详情接口 SHALL 仅按样本编码返回对应 `sampleReception`(及同规则的 `qcInfo`),与当前协议单 ID、样本行 `id` 是否变更无关
#### Scenario: 待接收列表排除已接收样本
- **WHEN** 租户内某 `sample_code` 已存在有效接收记录(`del_flag=0`)
- **THEN** 该编码的协议样本 SHALL NOT 出现在待接收列表
#### Scenario: 登记接收防重复
- **WHEN** 用户对某协议样本提交接收登记
- **THEN** 服务端 SHALL 按 `tenant_no` + `sample_code` 校验是否已接收,已存在则拒绝重复登记
---
### Requirement: CRM Excel 导出字典 label 转换
CRM 模块(及后续从山峡 CRM 迁移的业务模块)列表 Excel 导出 SHALL 在服务端将字典 **code(dict_value)** 转为 **label(dict_label)** 后再写入单元格,行为对齐 Java RuoYi `ExcelDictConvert` + `@ExcelDictFormat(dictType=...)`。
实现 SHALL 遵循以下约定:
1. **列定义集中管理**:各模块导出列 SHALL 声明于 `backend/app/utils/crm_export_columns.py`,使用 `ExportColumn(title, field, dict_type=...)`;`dict_type` 须与原 CRM 对应 `*Vo.java` 中 `@ExcelDictFormat` 一致。
2. **统一导出入口**:路由 SHALL 调用 `backend/app/utils/excel_export.py` 的 `export_rows_to_xlsx_response`,SHALL NOT 在 `app/api/crm.py` 等路由内重复 openpyxl 表头/行循环。
3. **字典预加载**:导出前 SHALL 一次查询 `sys_dict_data` 构建 value→label 映射(`load_dict_label_maps`),SHALL NOT 按行 N+1 查字典。
4. **租户隔离**:字典查询 SHALL 过滤当前用户 `tenant_no`(与列表接口一致)。
5. **响应类型**:成功时 SHALL 返回 `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` 二进制流;失败时 MAY 返回 JSON `{ code, message }`(前端 blob 拦截器须能识别,见 `coding-conventions`)。
6. **依赖**:运行环境 SHALL 安装 `openpyxl`(`backend/requirements.txt`)。
#### Scenario: 线索列表导出
- **WHEN** 用户调用 `POST /web/crm/clue/export` 且线索 `source` 存为 `website`
- **THEN** Excel「线索来源」列 SHALL 展示 `sys_dict_data` 中 `dict_type=crm_clue_source` 且 `dict_value=website` 的 `dict_label`,SHALL NOT 直接输出 `website`
#### Scenario: 新增 CRM 模块导出
- **WHEN** 移植新 CRM 模块并增加 `/export` 路由
- **THEN** 开发者 SHALL 先在 `crm_export_columns.py` 增加 `XXX_EXPORT_COLUMNS` 并对照 Java `CrmXxxVo`,路由 SHALL 仅调用 `export_rows_to_xlsx_response`
#### Scenario: 非字典列与格式化列
- **WHEN** 导出列无 `@ExcelDictFormat`(如姓名、手机号)或为布尔语义(如是否主联系人)
- **THEN** MAY 使用无 `dict_type` 的 `ExportColumn`,或使用 `formatter`(参考联系人导出 `_yes_no`)
---
## 常见错误案例(API / Pydantic)
以下案例供迁移与 Code Review 对照;编号 **EC-API-*** 与 `ui-conventions` 中 **EC-UI-*** 并列,不替代上文 Requirement 的 SHALL。
### 错误案例 EC-API-001:路由层用 JSON alias 访问 Pydantic 请求体
| 项 | 说明 |
|----|------|
| **现象** | 接口 HTTP 200 但 `data` 为 null,`message` 如 `'CustomerListBody' object has no attribute 'createTimeRange'`;前端表现为 `HttpError` / 公海池或客户列表白屏 |
| **根因** | Schema 使用 `create_time_range = Field(..., alias="createTimeRange")`,路由误写 `body.createTimeRange`。Pydantic v2 的 `alias` 只用于解析入参,**Python 属性名仍为 `create_time_range`** |
| **错误写法** | `body.createTimeRange`、`body.viewMode`、`body.customerName`、`body.customerIds`、`body.poolReason` 等(与 Schema 属性名不一致时) |
| **正确写法** | `body.create_time_range`、`body.view_mode`、`body.customer_name`、`body.customer_ids`、`body.pool_reason`;或 `kwargs = body.model_dump(exclude_unset=True)` 再传给 CRUD |
| **注意** | 少数字段在 Schema 中直接命名为 `pageSize`(无 alias),此时 SHALL 使用 `body.pageSize`,不可臆测为 `body.page_size` |
| **参考** | `app/schemas/crm/customer.py`(`CustomerListBody`);修复记录:`app/api/crm.py` 客户列表/导出/公海池相关路由(2026-05) |
#### Scenario: EC-API-001 复现与验证
- **WHEN** 修改 CRM 客户列表路由后做冒烟
- **THEN** `POST /web/crm/customer/list` 且 `{"page":1,"pageSize":10,"viewMode":"pool"}` SHALL 返回 `code: 200` 且 `data.list` 为数组,SHALL NOT 再出现 `createTimeRange` 相关 `AttributeError`