## 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///list` | `/web/crm/products/list` | | 详情查询 | `/web///detail` | `/web/crm/products/detail` | | 新增 | `/web///add` | `/web/crm/products/add` | | 更新 | `/web///update` | `/web/crm/products/update` | | 删除 | `/web///delete` | `/web/crm/products/delete` | | 下拉选项 | `/web///options` | `/web/crm/products/options` | | 导入 | `/web///import` | `/web/crm/products/import` | | 导出 | `/web///export` | `/web/crm/products/export` | | 下载模板 | `/web///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`