除认证类接口(登录 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 为符合新规范而重构已有代码。新旧模块风格并存是正常的过渡状态。
app/api/ 中新增业务接口@router.post(),SHALL NOT 使用 @router.get() / @router.put() / @router.delete()app/api/admin.py 等既有模块使用 @router.get() / @router.put() / @router.delete()POST 方法,查询条件通过请求体(JSON body)传入POST + multipart/form-data,请求体使用 FormData 而非 JSONPOST,返回 StreamingResponse 或 FileResponse,Content-Type 为对应文件类型所有 POST 业务接口的请求体 SHALL 使用 JSON 格式,并遵循以下分类规范:
{
"page": 1,
"pageSize": 10,
"<筛选字段1>": "<值>",
"<筛选字段2>": "<值>"
}
page:页码,默认 1,从 1 开始pageSize:每页条数,默认 10,最大 500create_time DESC),后续按需扩展当接口需要返回全量数据(如树形列表、下拉选项、字典数据、所有标签等)时,请求体 SHALL NOT 包含 page 和 pageSize 字段。后端 SHALL 返回全量数据,不进行分页。
{
"<筛选字段1>": "<值>"
}
关键约束:SHALL NOT 通过设置
pageSize: 999、pageSize: 9999等大数值来模拟全量查询。这会导致后端参数校验 422 错误(pageSize 上限为 500)。需要全量数据时,必须调用不分页接口。
{ "page": 1, "pageSize": 10, "productName": "关键词", "categoryId": "xxx", "status": "1" }page 和 pageSize 字段,后端返回全量树形数据page 和 pageSize 字段,后端返回全量选项列表pageSize: 999 / pageSize: 9999 / pageSize: 1000 等超过 500 的值/options、/tree)pageSize > 500{
"id": "<主键ID>"
}
{
"<业务字段1>": "<值>",
"<业务字段2>": "<值>"
}
id SHALL NOT 出现在新增请求体中(由后端自动生成 UUID)tenant_no SHALL NOT 出现在请求体中(由后端从当前用户上下文注入)create_by、create_time、del_flag 等)SHALL NOT 出现在请求体中{
"id": "<主键ID>",
"<业务字段1>": "<值>",
"<业务字段2>": "<值>"
}
id 为必填,标识要更新的记录{
"id": "<主键ID>"
}
id"ids": ["id1", "id2", ...]id(新增时)、tenant_no、create_by、create_time、update_by、update_time、del_flag 等系统字段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 同时满足:
model_config = ConfigDict(populate_by_name=True)Field(..., alias="camelCase名") 声明 JSON 别名body.model_dump(exclude_unset=True)(默认按字段名输出 snake_case),SHALL NOT 要求前端改为 snake_casealias 名作为 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"),与前端列表分页一致。
POST /api/transport/carrier 发送 { "carrierName": "测试" }CarrierCreate SHALL 含 carrier_name = Field(..., alias="carrierName") 且校验通过carrier_name Field requiredGET /api/transport/plan/?page=2&pageSize=50page_size: int = Query(..., alias="pageSize") 解析为 50pageSize 而始终使用默认 20POST /web/crm/product-categories/add,请求体为 { "categoryName": "有机原料", "categoryCode": "yjyl", "orderNum": 0 }model_dump() 得到 { "category_name": "有机原料", "category_code": "yjyl", "order_num": 0, ... } 供 CRUD 使用category_name 且无 alias / populate_by_namedata 内字典键 SHALL 为 camelCase(如 parentId、createTime),与前端 TypeScript 字段一致app/api/admin.py 等既有模块已使用 populate_by_name + aliasCustomerListBody 定义 create_time_range: Optional[List[str]] = Field(..., alias="createTimeRange")if body.createTimeRange: 或 view_mode=body.viewModeAttributeError(如 '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 取值POST /web/crm/customer/list 请求体为 { "page": 1, "pageSize": 10, "viewMode": "pool" }body.view_mode(值为 pool)传入 CRUD,SHALL NOT 使用 body.viewMode;page / pageSize 按 Schema 定义使用 body.page、body.pageSizeapp/api/** 中新增或修改对 Pydantic body 的字段访问app/schemas/** 中的属性名(非 alias 字符串);若出现 body.[a-z]+[A-Z] 形式且 Schema 为 snake_case + alias,SHALL 要求修正所有业务接口 SHALL 返回统一的 JSON 响应结构:
{
"code": 200,
"message": "成功",
"data": <任意类型或null>,
"timestamp": 1748572800000
}
{ "code": 200, "message": "成功", "data": <业务数据>, "timestamp": <毫秒时间戳> }{ "code": <错误码>, "message": "<可读错误描述>", "data": null, "timestamp": <毫秒时间戳> }data 字段 SHALL 为 { "list": [...], "total": 100, "page": 1, "pageSize": 10, "totalPages": 10 }data 字段 SHALL 为该记录的字典对象(通过 _row_to_dict 转换)data 字段 SHALL 为 null,message SHALL 为操作结果描述(如 "删除成功")所有业务接口 SHALL 使用以下返回码体系:
| 返回码 | 含义 | 使用场景 |
|---|---|---|
200 |
成功 | 所有成功响应 |
400 |
请求参数错误 | 必填字段缺失、格式不合法、业务校验失败 |
401 |
未认证 | Token 缺失、Token 过期、Token 无效 |
403 |
无权限 | 当前用户无权访问该资源或操作 |
404 |
资源不存在 | 根据 ID 查询的记录不存在(或已删除) |
409 |
数据冲突 | 唯一性校验失败(如编号重复) |
500 |
服务器内部错误 | 未捕获的异常、数据库错误 |
{ "code": 400, "message": "<具体校验失败原因>", "data": null }{ "code": 409, "message": "产品编号已存在", "data": null }{ "code": 404, "message": "记录不存在", "data": null }{ "code": 401, "message": "未登录或登录已过期", "data": null }POST /web/auth/login 校验失败(用户不存在、密码错误等){ "code": 400, "message": "用户名或密码错误", "data": null },SHALL NOT 使用 401(避免前端将业务码误判为 Token 失效并触发登出)user_no 解析 sys_user 且未传租户DEFAULT_TENANT_NO(shanxia2.0);未命中时可按 user_no 回退查询以兼容未完成租户迁移的库,并建议尽快执行 docs/sql/20260530_migrate_tenant_no_to_shanxia2.0.sql{ "code": 403, "message": "无操作权限", "data": null }{ "code": 500, "message": "服务器内部错误", "data": null },SHALL NOT 将堆栈信息暴露给前端统一 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 |
@router.post("/products/list"), @router.post("/products/add") 等,SHALL NOT 使用 RESTful 风格的 @router.get("/products") 或 @router.put("/products/{id}"){ "id": "xxx" } 传入,SHALL NOT 通过 URL 路径参数 /products/{id} 传入所有面向业务数据的查询接口 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。
tenant_notenant_no SHALL 等于当前登录用户租户,且不得信任客户端单独提交的租户覆盖服务端策略(除非经安全评审的多租户管理接口)除超级管理员等需求明确豁免的场景外,所有业务查询接口 SHALL 叠加当前登录用户全部角色合并后的数据权限范围进行过滤(与需求 §2.1.5「多角色取权限之和」一致)。
在无单独产品排序要求的前提下,列表类接口返回的数据集合 SHALL 按 create_time 降序排列(与需求 §3 一致)。
create_time DESC对外 HTTP API SHALL 使用统一前缀(当前仓库为 /api 及版本/子路径,见 AGENTS.md);新增路由 SHALL 注册到既有路由聚合机制,避免绕过中间件(鉴权、加解密、审计等)。
app/api/ 增加新的路由模块app/api/__init__.py 与 app/main.py 中完成注册,并符合现有认证与租户上下文注入模式miniprogram 拍照凭证(仅图片,见 ui-conventions「小程序拍照凭证上传(基线)」)相关接口 SHALL 遵循下列约定;权威行为对齐叁强鲜配 POST /api/upload/image + 业务接口携带 imageUrls。
POST + multipart/form-data(文件上传例外,见上文「文件上传下载例外」)。file;业务目录字段 SHALL 为 folder(Form 字符串,如 delivery-evidence、avatars)。data SHALL 至少包含 url(对象存储可访问地址);MAY 含 filename、contentType、size。前端以 data.url 为唯一回填依据。image/jpeg、image/png、image/webp;建议兼容 image/heic/image/heif);单文件大小上限建议 10MB(与叁强一致,业务另有约定时在接口文档写明)。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 包装。
imageUrls: string[])。file_url 存完整 URL;多 URL 落单字段时 SHALL 使用英文逗号分隔(见 architecture-conventions「附件与对象存储」)。file + foldercode SHALL 为成功码且 data.url 非空,对象存储中可按该 URL 访问图片imageUrls 数组,服务端 SHALL 据此落库/关联,SHALL NOT 要求客户端再次上传二进制API_ENCRYPT_EXCLUDE_PATHS 且全局 API 加密开启细胞存储场景下,将协议样本行(biz_protocol_sample)与下游业务(样本接收 biz_sample_reception、质检工单 biz_qc_work_order 等)进行业务匹配时,关联键 SHALL 仅为 tenant_no + sample_code(租户内样本编码唯一且不变;接收/工单表须持久化 sample_code 快照)。不得使用 collection_protocol_id 或 protocol_sample_id 作为查询、列表排除、详情回显的关联依据(协议单可换号/转科研、编辑保存会重建样本行 id,但样本编码不变)。
sample_code 已登记接收sampleReception(及同规则的 qcInfo),与当前协议单 ID、样本行 id 是否变更无关sample_code 已存在有效接收记录(del_flag=0)tenant_no + sample_code 校验是否已接收,已存在则拒绝重复登记CRM 模块(及后续从山峡 CRM 迁移的业务模块)列表 Excel 导出 SHALL 在服务端将字典 code(dict_value) 转为 label(dict_label) 后再写入单元格,行为对齐 Java RuoYi ExcelDictConvert + @ExcelDictFormat(dictType=...)。
实现 SHALL 遵循以下约定:
backend/app/utils/crm_export_columns.py,使用 ExportColumn(title, field, dict_type=...);dict_type 须与原 CRM 对应 *Vo.java 中 @ExcelDictFormat 一致。backend/app/utils/excel_export.py 的 export_rows_to_xlsx_response,SHALL NOT 在 app/api/crm.py 等路由内重复 openpyxl 表头/行循环。sys_dict_data 构建 value→label 映射(load_dict_label_maps),SHALL NOT 按行 N+1 查字典。tenant_no(与列表接口一致)。application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 二进制流;失败时 MAY 返回 JSON { code, message }(前端 blob 拦截器须能识别,见 coding-conventions)。openpyxl(backend/requirements.txt)。POST /web/crm/clue/export 且线索 source 存为 websitesys_dict_data 中 dict_type=crm_clue_source 且 dict_value=website 的 dict_label,SHALL NOT 直接输出 website/export 路由crm_export_columns.py 增加 XXX_EXPORT_COLUMNS 并对照 Java CrmXxxVo,路由 SHALL 仅调用 export_rows_to_xlsx_response@ExcelDictFormat(如姓名、手机号)或为布尔语义(如是否主联系人)dict_type 的 ExportColumn,或使用 formatter(参考联系人导出 _yes_no)以下案例供迁移与 Code Review 对照;编号 EC-API-* 与 ui-conventions 中 EC-UI-* 并列,不替代上文 Requirement 的 SHALL。
| 项 | 说明 |
|---|---|
| 现象 | 接口 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) |
POST /web/crm/customer/list 且 {"page":1,"pageSize":10,"viewMode":"pool"} SHALL 返回 code: 200 且 data.list 为数组,SHALL NOT 再出现 createTimeRange 相关 AttributeError