493 lines
26 KiB
Markdown
493 lines
26 KiB
Markdown
## 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`
|