比较提交

...
作者 SHA1 备注 提交日期
lc 60a28339e0 Merge pull request 'docs: 纠正供应商合同独立登记交付状态 (#6544)' (#80) from changelog/6544-supplier-contract-boundary into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-29 09:40:44 +08:00
lc d3b5c0e8c9 docs: 纠正供应商合同独立登记交付状态 (#6544)
changelog-filename-gate / validate (pull_request) Successful in 1s
2026-08-29 09:40:18 +08:00
API Changelog Bot 0474e6ff78 docs: 交付供应商合同独立登记契约(#6544)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-29 00:55:23 +08:00
Mimingguang 9aace47441 chore(changelog): 回写 #6474 供应商变更/审批分离 verified(199147de/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 17:01:30 +08:00
lc 605859b12f docs: 交付供应商历史分离契约(#6474)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:44:13 +08:00
Mimingguang 734a9b1112 chore(changelog): 回写 #6518 供应商草稿即生成编号 verified(d546912c/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:38:45 +08:00
lc 20272bb706 docs: 下发供应商草稿编号契约(#6518)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:28:01 +08:00
Mimingguang 59090c889b chore(changelog): 回写 #6476 供应商详情地址备注 verified(6635a2ae/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:03:03 +08:00
lc 07387e506f docs(changelog): 记录 #6476 供应商表单契约
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 15:41:38 +08:00
Mimingguang e4f7c5cb0a chore(changelog): 回写 #6436 供应商敏感字段完整回显 verified(5957c58c/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 12:31:26 +08:00
lc cf3fe30a0f docs: 交付 #6436 供应商完整字段契约
changelog-filename-gate / validate (push) Successful in 3s
2026-08-27 11:42:09 +08:00
Mimingguang e91101c037 docs(changelog): 回写 #6397 amount String 化前端 verified
changelog-filename-gate / validate (push) Successful in 2s
前端 002475b2 已按字符串重新集成 PR #6450 修正(响应 contracts[].amount Number→String),fillForm toContractAmount 归一 number、详情列透传、提交侧 Number 不变,补 String 回填测试。
2026-08-27 00:25:29 +08:00
API Changelog Bot ebc2fecbfd docs(changelog): #6397 全面对齐模板——逐接口自包含详情 + 五、数据库行为 + 九、相关历史PR
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 00:04:27 +08:00
API Changelog Bot e362441e9c docs(changelog): #6397 接口详情子节编号化 + 补六.6/六.7 章节 2026-08-26 23:58:34 +08:00
API Changelog Bot 81aab7abb0 docs(changelog): #6397 对齐 CHANGELOG_TEMPLATE 必备章节(二/三/四/六/七/八/十) 2026-08-26 23:56:51 +08:00
API Changelog Bot 4f81ef1cf9 fix(changelog): #6397 响应 contracts[].amount Number→String 同日修正 (PR #6450)
后端每日审查红线修复: 合同金额 BigDecimal 补 ToStringSerializer,输出字符串化与全站金额对齐;
frontend_status 回退 pending 待 mmg 按字符串重新集成,请求侧 Number 不变。
2026-08-26 23:53:04 +08:00
lc ca11783f72 Merge pull request 'fix(changelog): 按 TEST 代码修正行政区划契约 (#6438)' (#79) from docs/6438-region-changelog-code-sync into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 18:01:45 +08:00
lc 3a2c1ae38d fix(changelog): 按 TEST 代码修正行政区划契约 (#6438)
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-26 17:59:58 +08:00
Mimingguang 7b37f31df6 chore(changelog): #6350 前端接入完成(行政区划三级级联,ref=7acd1cb0)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 17:20:35 +08:00
lc 49f8b6d0a0 Merge pull request 'fix(changelog): 补齐行政区划三级联动接口文档与模板门禁(#6422)' (#78) from fix/6422-region-changelog-contract into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 16:24:50 +08:00
lc 3d6f6245a0 补齐行政区划三级联动接口文档与模板门禁(#6422)
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-26 16:23:19 +08:00
Mimingguang ff16cfd94c chore(changelog): #6392 管理端 verified (ref=49b071b0)
changelog-filename-gate / validate (push) Failing after 2s
2026-08-26 16:11:05 +08:00
lc 2e18de27e1 Merge pull request 'docs(changelog): 交付行政区划主数据接口(#6350)' (#77) from docs/6350-region-api-changelog into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 15:53:04 +08:00
lc 6461e1ce39 docs(changelog): 交付行政区划主数据接口(#6350)
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-26 15:52:27 +08:00
lc 48b1d069c8 Merge pull request 'docs(changelog): 交付供应商暂停与拉黑接口(#6392)' (#76) from chore/6392-api-changelog into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 15:45:47 +08:00
lc 36a85b8669 docs(changelog): 交付供应商暂停与拉黑接口(#6392)
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-26 15:45:16 +08:00
lc 43b9c16a36 Merge pull request 'docs(changelog): 交付行政区划 ID 字符串契约(#6409)' (#75) from chore/6409-api-changelog into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 15:30:01 +08:00
lc fd76eea701 docs(changelog): 交付行政区划 ID 字符串契约(#6409)
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-26 15:29:26 +08:00
Mimingguang 49f67134fd chore(changelog): #6406 管理端 verified (ref=080ea8df)
changelog-filename-gate / validate (push) Failing after 2s
2026-08-26 15:24:50 +08:00
API Changelog Bot 314c228654 feat: 开票申请按税号自动回填企业工商信息(#6406 / PR #6415)
changelog-filename-gate / validate (push) Successful in 2s
新增 GET /v3/admin/invoice/company-info?taxNo=,按税号返回元典工商照面 +
本系统历史开票抬头两段数据,后端不合并由前端取舍;已合并 dev-v3 并部署测试服,
网关 API 实测 companyInfo / lastInvoiceTitle 往返一致,非法税号返回 581526。
2026-08-26 15:08:20 +08:00
Mimingguang 3e8ea93caf chore(changelog): #6391 管理端 verified (ref=74b3d8bf)
changelog-filename-gate / validate (push) Failing after 2s
2026-08-26 14:53:22 +08:00
Mimingguang 33c0aa2c08 chore(changelog): #6343 管理端 verified (ref=d84c6c25)
changelog-filename-gate / validate (push) Failing after 1s
2026-08-26 14:16:40 +08:00
lc 6831ec3128 docs: 交付供应商法人证件接口(#6391)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 11:57:09 +08:00
Mimingguang 42a16b78c6 chore(changelog): #6397 管理端 verified (ref=f2a4200d)
changelog-filename-gate / validate (push) Failing after 1s
2026-08-26 11:49:48 +08:00
lc 257ecb1233 fix(changelog): 对齐 #6343 前端状态元数据
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 11:32:39 +08:00
lc e9b52319c9 docs(changelog): #6343 供应商类型可为空
changelog-filename-gate / validate (push) Failing after 2s
2026-08-26 11:30:55 +08:00
lc 4060c65261 docs: 交接供应商注册合同接口(#6397)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 11:13:31 +08:00
Mimingguang 0fcf0801cf chore(changelog): #6304 管理端 verified (ref=25fb4b27)
changelog-filename-gate / validate (push) Failing after 2s
2026-08-25 17:04:58 +08:00
共修改 19 个文件,包含 5034 行新增和 19 行删除
+3 -3
查看文件
@@ -2,7 +2,7 @@
# changelog 发布门禁(推送前强制校验)
# 启用(每台机一次): git config core.hooksPath .githooks
# 拦截目标: 接口类 changelog 未部署测试服(backend_status != deployed)就推送给前端,
# 以及文件名/frontmatter 结构违规。规则实现见 scripts/validate-changelog-*.mjs。
# 以及文件名/frontmatter/CHANGELOG_TEMPLATE.md 结构违规。规则实现见 scripts/validate-changelog-*.mjs。
zero=0000000000000000000000000000000000000000
status=0
while read local_ref local_sha remote_ref remote_sha; do
@@ -19,7 +19,7 @@ while read local_ref local_sha remote_ref remote_sha; do
done
if [ "$status" -ne 0 ]; then
echo "" >&2
echo "推送被 changelog 发布门禁拦截:接口类条目必须测试服已部署+实测(backend_status=deployed)后才能推送给前端。" >&2
echo "修正文件后重试;规则详见 BACKEND_CHANGELOG_DELIVERY_GUIDE.md §2.1。" >&2
echo "推送被 changelog 发布门禁拦截:接口类条目必须已部署实测,并完整仿照 CHANGELOG_TEMPLATE.md。" >&2
echo "每个接口需自含入参、出参、请求/响应、错误和业务边界;修正规则详见 BACKEND_CHANGELOG_DELIVERY_GUIDE.md。" >&2
fi
exit $status
+7 -1
查看文件
@@ -25,7 +25,8 @@ changelogs-v2/2026-07/24_5205_车务首页汇总状态补全-修改接口-管理
## 2. 写什么
可以复制仓库根目录的 `CHANGELOG_TEMPLATE.md`,至少写清:
必须复制并按仓库根目录的 `CHANGELOG_TEMPLATE.md` 组织接口文档。接口类 Changelog 不能只写
路径和变更摘要,至少写清:
- 关联的 Issue 和后端 PR;
- 接口路径和 HTTP 方法;
@@ -34,6 +35,11 @@ changelogs-v2/2026-07/24_5205_车务首页汇总状态补全-修改接口-管理
- 前端需要做什么;
- 后端测试、部署和网关验证结果。
多接口条目必须让每个接口小节独立包含入参、出参、请求示例、响应示例、错误响应和业务边界;
“变更接口清单”的 `METHOD /path` 必须与逐接口详情一一对应。该结构由
`check:frontmatter` 机器校验,缺失时分别报 `E_API_TEMPLATE`、`E_API_ENDPOINTS` 或
`E_API_DETAIL`。存量接口文档一旦修改,也必须升级到当前模板标准。
元数据中:
```yaml
+12 -2
查看文件
@@ -3,7 +3,8 @@ schema: "hl-changelog/v2"
ticket: "{issue-no}"
title: "{一句话概括变化}"
consumer: "{admin|mp|internal|multiple}"
author: "{推送者登录名}(GIT)" # 如 wx(GIT)/yst(GIT),谁 push 到 main 就写谁
# author 示例: wx(GIT) / yst(GIT);谁 push 到 main 就写谁
author: "{推送者登录名}(GIT)"
change_type: "{新增接口|修改接口|删除接口}"
backend_status: "pending"
gateway_status: "pending"
@@ -67,6 +68,10 @@ base: "{dev|dev-v3}"
**VO**: `{VO 类名}`
#### 使用场景
说明前端在什么页面、什么时机调用;多接口条目中每个接口都必须自包含,不依赖其他章节补参数或错误语义。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
@@ -113,9 +118,14 @@ base: "{dev|dev-v3}"
}
```
#### 业务边界
- 写清本接口自己的鉴权、空值、状态、幂等/并发、失败零写入和兼容规则。
- 不要只在全局章节写一次;消费方应能单独阅读本接口小节完成联调。
---
## 四、契约约束与正确调用方式(选填,字段互斥/联动/切换场景必写)
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
+18
查看文件
@@ -75,6 +75,24 @@ pending → claimed → implemented → released → verified
- `FRONTEND_CONSUMPTION_STATUS_GUIDE.md`
- `BACKEND_CHANGELOG_DELIVERY_GUIDE.md`
## 接口文档模板门禁
新增、修改或删除接口的 Changelog 必须以仓库根目录
[`CHANGELOG_TEMPLATE.md`](CHANGELOG_TEMPLATE.md) 为结构基线,不能只写接口路径和一段变更摘要。
机器校验要求:
- 使用“二、变更接口清单”的标准六列表格;
- 清单中的每个 `METHOD /path` 都必须在“三、接口详情”中有且只有一个对应小节;
- 每个接口小节必须自含入参字段表、出参字段表、请求示例、响应示例、错误响应和业务边界;
- 含写接口时必须说明外部可观察的数据库行为,但不得泄露物理表结构;
- 修改或删除接口必须补“修改前后对比”和“影响评估”;
- 必须保留契约约束、边界行为、不影响范围、TEST 验证、相关文档及联系人章节。
缺少上述内容时 `check:frontmatter` 返回 `E_API_TEMPLATE`、`E_API_ENDPOINTS` 或
`E_API_DETAIL`,PR/push 检测失败。存量文件不全量追责,但任何接口类文件一旦新增或修改,
就必须补到当前模板标准。
已下发路径是消费契约的一部分,不通过重命名表达状态。历史路径已发生迁移时:
- `changelog-path-aliases.json` 是机器可识别的唯一映射源;
@@ -7,11 +7,11 @@ author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "25fb4b27"
target_release: ""
verified_at: ""
verified_at: "2026-08-25"
status_note: "PR #6342 与补充修复 PR #6349 均已合并 dev-v3,TEST 最终部署提交为 af3a7bae5。真实 SUPER_ADMIN 经 Gateway 已验证显式永久有效、395042 冲突零写入、历史省略兼容和数据清理;未认证请求仍返回标准 401 业务响应。管理端尚待适配,frontend_status 保持 pending。"
updated_at: "2026-08-25"
base: "dev-v3"
@@ -0,0 +1,74 @@
---
schema: "hl-changelog/v2"
ticket: "6343"
title: "供应商新建修改允许供应商类型为空"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "d84c6c25"
target_release: ""
verified_at: "2026-08-26"
status_note: "PR #6385 已合并 dev-v3,合并提交 b8d99f58 已部署到 TEST。供应商创建草稿的 types 可省略、传 null 或空数组;更新省略 types 保持原关系,显式空数组清空无资源占用的类型;注册提交仍至少需要一个类型。"
updated_at: "2026-08-26"
base: "dev-v3"
---
# 供应商新建修改允许供应商类型为空
供应商草稿阶段不再强制选择供应商类型。创建草稿可不传类型,编辑草稿也可显式清空全部未被资源占用的类型;注册提交的完整性门禁保持不变。
## 变更接口
| 方法 | 路径 | 行为变化 |
|---|---|---|
| POST | `/admin/supplier/items/add` | `types` 省略、为 `null` 或空数组时均可创建无类型草稿;非空时继续校验最多 15 项、生效字典值和唯一性 |
| PUT | `/admin/supplier/items/{supplierId}/update` | 省略 `types` 时保持原类型关系;显式传空数组时清空全部无资源占用的类型;非空快照继续执行原校验 |
| POST | `/admin/supplier/items/{supplierId}/submit` | 行为不变:没有供应商类型时仍拒绝提交注册 |
| GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 无类型供应商的 `types` 返回空数组 |
字段名和 JSON 类型没有变化。`types` 非空时仍提交对象数组,类型值来自供应商类型生效字典。
## 校验与错误语义
- 创建无类型草稿仅放宽草稿保存,不放宽注册提交、审批或状态机。
- 更新显式清空前仍检查资源关系占用;存在有效资源关系的类型不能被移除,失败时主体、类型关系和审计保持零变化。
- 无类型草稿提交继续返回既有业务错误码 `395008`,草稿状态和并发版本不变。
- 非空类型继续校验最多 15 项、生效字典值、重复值和主类型约束。
- 业务失败可能仍使用 HTTP 200,客户端必须同时检查统一响应的 `code`、`success`、`message` 和 `data`。
## 兼容性与前端事项
- 管理端请求字段和响应结构不变,现有非空类型流程无需调整,因此无前端源码变更。
- 无类型供应商仍可被列表和详情读取;客户端应兼容 `types: []`。
- 不新增数据库 migration,不修改配置、Gateway、Redis、MQ、Nacos、Feign 或跨服务写入。
- 本次不自动修改历史数据,也不绕过既有权限、数据范围、软删除、资源占用和审计规则。
## 验证证据
- 合并后独立审计:#6343 相关跨层定向 153 项零失败;Resource 最新目标分支全量 2111 项零失败、38 项仓库既有条件跳过;`git diff --check` 通过。
- TEST 运行态:服务器后端仓库为 `dev-v3` 的 `1a16a5aec`,包含合并提交 `b8d99f58c`;`hl-resource-service` 的 8082、8182 双实例均监听。
- 真实 Gateway 正向:`types` 省略、`null`、空数组分别成功创建三个 `DRAFT`,详情均返回空类型数组;带一个生效类型的草稿创建成功。
- 更新语义:省略 `types` 后原一项类型保持不变,显式 `types: []` 后详情返回空数组。
- 状态门禁:无类型草稿提交返回 `395008`,状态仍为 `DRAFT`、版本不变,确认零写入。
- 权限门禁:未登录请求返回业务码 401,真实 `CUSTOMIZER` 返回 `395002`,两者均通过列表回读确认零写入;正向使用真实 `SUPER_ADMIN` 身份。
- 清理:四条成功创建的 TEST 草稿全部删除;逐条详情返回 `395001`,按随机全名查询均为零条,没有留下测试业务数据。
## 撤回
1. 从最新 `dev-v3` 创建回退分支,执行 `git revert -m 1 --no-edit b8d99f58c84daa12ef50d68bcc3090f38f57a22d`,经独立 PR 合入。
2. 通过 Deploy Panel API 重新构建并滚动部署 `hl-resource-service`,复核 8082、8182 双实例和 Nacos 健康状态。
3. 无数据库、配置、Redis 或 MQ 变更,不执行 DDL、DML、缓存清理或消息补偿;不要回退后续无关工单的提交或 migration。
4. 回退前让调用方恢复创建时至少提交一个生效类型,并停止更新时发送 `types: []`;更新不改类型时继续省略该字段。
5. 回退后经 Gateway 复测非空类型创建、更新省略保持、无类型创建拒绝、无类型提交拒绝、资源占用移除保护和越权零写入。
## 关联 / 联系人
- **Issue**: [#6343](https://git.1814.love:8443/wx/HL/issues/6343)
- **PR**: [#6385](https://git.1814.love:8443/wx/HL/pulls/6385)
- **功能提交**: [d625f0448](https://git.1814.love:8443/wx/HL/commit/d625f04489655e11982b9baeeb45d74ba4f7c0e1)
- **合并提交**: [b8d99f58c](https://git.1814.love:8443/wx/HL/commit/b8d99f58c84daa12ef50d68bcc3090f38f57a22d)
- **后端负责人**: @lc
@@ -0,0 +1,877 @@
---
schema: "hl-changelog/v2"
ticket: "6350"
title: "行政区划主数据与三级联动"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "7acd1cb0"
target_release: "v2.1"
verified_at: "2026-08-26"
status_note: "主工单 #6350 及补充工单 #6384、#6389、#6395、#6399、#6409 的六个 PR 均已合并 dev-v3。精确提交 b62054035a139e3c689179ef75e2d052d08dde52 已部署 TEST;真实管理员经 Gateway 完成未认证、读取、失败零写入、创建、CAS 更新、数据库和 Redis 一致性验收。#6422 已按 CHANGELOG_TEMPLATE.md 补齐四个接口的前端联调契约,三级联动由 mmg 以 ref 7acd1cb0 完成验证;#6438 再按 TEST 代码修正种子父链、聚合节点、可扩展编码命名空间、异步缓存刷新和 extension 更新限制。"
updated_at: "2026-08-26"
base: "dev-v3"
---
# User: 行政区划主数据与三级联动
> **服务**: `hl-user-service`(统一经 Gateway 访问)
>
> **PR**: #6359、#6387、#6393、#6398、#6402、#6412
>
> **Issue**: #6350、#6384、#6389、#6395、#6399、#6409、#6422、#6438
>
> **日期**: 2026-08-26
>
> **影响范围**: 管理后台行政区划维护、省/市/县三级选择器和历史值父链回显
---
## ⚠️ 关键变化
- 本次补文档不改变 TEST 上已经部署的接口行为;它把原 Changelog 中分散的说明整理为可直接联调的完整契约。
- 前端不得再把行政区划 ID 当 JavaScript number:响应中的所有行政区划 ID 均为 JSON string,缺失层级保持真正的 `null`。
- 三级联动不允许通过行政区编码截位推断父子关系;必须逐级调用 `children`,编辑回显先调用 `path`。
- `PREFECTURE` 是第二层导航层,不保证一定是可选择的法定地级行政区;直辖市等链路会返回 `AGGREGATION` 聚合节点,必须继续下钻且不得作为最终业务值。
- 当前没有单节点详情接口,读响应也不返回 `extension`;更新接口省略或传 `null` 都会清空该字段,前端不能把它理解成“保持原值”。
- 业务失败可能仍使用 HTTP 200,调用方必须同时判断响应体 `code`、`success`、`message` 和 `data`。
---
## 一、背景
管理后台需要一套统一的行政区划主数据能力,同时支持:
- 省、市、县三级选择器逐级加载;
- 编辑历史业务数据时,从任意节点恢复完整父链;
- 具有专用权限的管理员创建自定义行政区、带来源证据的新法定版本或导航聚合节点;
- 使用 `rowVersion` 防止两位管理员并发更新时互相覆盖。
接口当前只开放省、市、县三级写入,层级代码依次为 `PROVINCE`、`PREFECTURE`、`COUNTY`。第二层可能是 `AGGREGATION` 导航分组,不等同于可提交的法定地级行政区。下文读取示例使用 TEST migration 中的北京链路 `1 → 35 → 376`,仅用于说明响应结构,业务代码仍不得硬编码。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 单级联动列表 | GET | `/admin/region/children` | 新增接口 | 加载根层省级或指定父节点的当前有效直接子节点 |
| 2 | 父链回显 | GET | `/admin/region/{id}/path` | 新增接口 | 从任意省/市/县节点恢复省级根到目标节点的完整路径 |
| 3 | 创建行政区划节点 | POST | `/admin/region` | 新增接口 | 创建自定义行政区、新法定版本或不可选择的导航聚合节点 |
| 4 | 更新行政区划节点 | PUT | `/admin/region/{id}` | 新增接口 | 使用完整可变字段和 `rowVersion` 执行 CAS 更新 |
---
## 三、接口详情
### 1. 单级联动列表 `GET /admin/region/children`
**VO**: `AdministrativeRegionChildrenVO / AdministrativeRegionItemVO`
#### 使用场景
- 新建页面首次加载省级列表:省略 `parentId`。
- 选择省后加载市、选择市后加载县:把当前选中节点 ID 作为下一次请求的 `parentId`。
- 编辑页面逐层恢复默认值:把本层历史 ID 作为 `selectedId`;它仍在当前 `items` 中时会成为 `defaultId`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `Authorization` | Header | String | ✅ | `Bearer <token>` | 真实管理端登录令牌 |
| `parentId` | Query | String | ❌ | 十进制正整数;根层省级列表必须省略 | 指定要查询直接子节点的父节点 ID |
| `selectedId` | Query | String | ❌ | 十进制正整数 | 编辑回显时希望优先选中的本级节点 ID |
无请求体。
#### 出参 `Result<AdministrativeRegionChildrenVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | `200` 表示成功;业务失败读取具体业务码 |
| `message` | String | 响应说明 |
| `success` | Boolean | 仅当 `code=200` 时为 `true` |
| `traceId` | String / null | 链路追踪 ID;报错排查时提供给后端 |
| `data.parentId` | String / null | 本次查询父节点;根层查询为 `null` |
| `data.defaultId` | String / null | 合法 `selectedId`,否则为排序后首项 ID;空列表为 `null` |
| `data.items` | Array | 当前有效且状态为 `ACTIVE` 的直接子节点,稳定排序 |
| `data.items[].id` | String | 节点 ID,始终为字符串 |
| `data.items[].parentId` | String / null | 父节点 ID;省级根节点为 `null` |
| `data.items[].codeStandard` | String | 编码标准,例如 `GB/T 2260`、`HL_CUSTOM`、`HL_GROUP` |
| `data.items[].regionCode` | String | 编码标准内的地区代码 |
| `data.items[].levelCode` | String | `PROVINCE`、`PREFECTURE` 或 `COUNTY` |
| `data.items[].depth` | Integer | 树深度,省/市/县分别为 1/2/3 |
| `data.items[].name` | String | 行政区划完整名称 |
| `data.items[].shortName` | String / null | 简称 |
| `data.items[].nodeKind` | String | `ADMINISTRATIVE` 或 `AGGREGATION` |
| `data.items[].navigable` | Boolean | 是否允许继续查询下一层 |
| `data.items[].selectable` | Boolean | 是否允许作为最终业务行政区值 |
| `data.items[].hasChildren` | Boolean | 是否存在当前有效、启用的直接子节点 |
| `data.items[].currentlyEffective` | Boolean | 中国业务当日是否位于有效期且状态启用 |
| `data.items[].status` | String | `ACTIVE` 或 `INACTIVE` |
| `data.items[].validFrom` | String | 生效日期,格式 `yyyy-MM-dd` |
| `data.items[].validTo` | String / null | 失效日期;`null` 表示持续有效 |
| `data.items[].sortOrder` | Integer | 同级排序号 |
| `data.items[].rowVersion` | Integer | 当前 CAS 版本,更新时原样提交 |
#### 请求示例
```http
GET /admin/region/children?parentId=1&selectedId=35 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员令牌>
Accept: application/json
```
根层省级列表:
```http
GET /admin/region/children HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员令牌>
Accept: application/json
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"parentId": "1",
"items": [
{
"id": "35",
"parentId": "1",
"codeStandard": "HL_GROUP",
"regionCode": "HL-GROUP-110000",
"levelCode": "PREFECTURE",
"depth": 2,
"name": "北京市辖区",
"shortName": null,
"nodeKind": "AGGREGATION",
"navigable": true,
"selectable": false,
"hasChildren": true,
"currentlyEffective": true,
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 2000000001,
"rowVersion": 0
}
],
"defaultId": "35"
},
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
#### 空数据 / 降级响应
父节点存在但没有当前有效直接子节点时,返回成功空数组。Redis 读取失败时服务会降级查数据库,成功结构不变:
```json
{
"code": 200,
"message": "成功",
"data": {
"parentId": "376",
"items": [],
"defaultId": null
},
"success": true
}
```
#### 错误响应
父节点不存在或已删除:
```json
{
"code": 210801,
"message": "行政区划节点不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 只返回 `status=ACTIVE` 且当前有效的直接子节点,停用或历史节点不会混入当前选择项。
- `selectedId` 不在本次 `items` 中时不会报错,而是稳定回退到排序后的第一项;空列表回退为 `null`。
- 排序固定为 `sortOrder`、`regionCode`、`id`,前端不得另用行政区编码推断顺序或父子关系。
- `navigable` 控制能否继续向下加载,`selectable` 控制能否作为最终值;两者不能相互替代。
- `defaultId` 只表示本级默认导航项,不保证 `selectable=true`;例如北京第二层默认项 `"35"` 是不可提交的聚合节点。
- 未登录返回业务码 `401`;`parentId=0` 等非正整数参数返回业务码 `400`。
---
### 2. 父链回显 `GET /admin/region/{id}/path`
**VO**: `AdministrativeRegionPathVO / AdministrativeRegionItemVO`
#### 使用场景
编辑已有业务数据时,后端只保存了一个最终行政区 ID。前端先调用本接口得到完整三级父链,再按父链逐层调用 `children` 加载每一级导航项。父链的第二层可能是不可选择的 `AGGREGATION`,不能仅凭 `prefectureId` 字段名判断它一定是法定地级行政区。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `Authorization` | Header | String | ✅ | `Bearer <token>` | 真实管理端登录令牌 |
| `id` | Path | String | ✅ | 十进制正整数 | 要回显的省、市或县节点 ID |
无请求体。
#### 出参 `Result<AdministrativeRegionPathVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | `200` 表示成功;业务失败读取具体业务码 |
| `message` | String | 响应说明 |
| `success` | Boolean | 仅当 `code=200` 时为 `true` |
| `traceId` | String / null | 链路追踪 ID;报错排查时提供给后端 |
| `data.selectedId` | String | 调用方传入并成功解析的目标节点 ID |
| `data.provinceId` | String | 父链中的省级 ID |
| `data.prefectureId` | String / null | 父链第二层 ID,可能是地级行政区或聚合导航节点;目标为省级时为 `null` |
| `data.countyId` | String / null | 父链中的县级 ID;目标高于县级时为 `null` |
| `data.path` | Array | 从省级根到目标节点的完整有序父链 |
| `data.path[].id` | String | 节点 ID,始终为字符串 |
| `data.path[].parentId` | String / null | 父节点 ID;首个省级节点为 `null` |
| `data.path[].codeStandard` | String | 编码标准 |
| `data.path[].regionCode` | String | 地区代码 |
| `data.path[].levelCode` | String | 省/市/县层级代码 |
| `data.path[].depth` | Integer | 从 1 开始的连续深度 |
| `data.path[].name` | String | 完整名称 |
| `data.path[].shortName` | String / null | 简称 |
| `data.path[].nodeKind` | String | 节点类型 |
| `data.path[].navigable` | Boolean | 是否允许逐级导航 |
| `data.path[].selectable` | Boolean | 是否允许作为最终值 |
| `data.path[].hasChildren` | Boolean | 是否存在当前有效直接子节点 |
| `data.path[].currentlyEffective` | Boolean | 当前是否有效;历史节点可能为 `false` |
| `data.path[].status` | String | `ACTIVE` 或 `INACTIVE` |
| `data.path[].validFrom` | String | 生效日期 |
| `data.path[].validTo` | String / null | 失效日期 |
| `data.path[].sortOrder` | Integer | 同级排序号 |
| `data.path[].rowVersion` | Integer | 当前 CAS 版本 |
#### 请求示例
```http
GET /admin/region/376/path HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员令牌>
Accept: application/json
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"selectedId": "376",
"provinceId": "1",
"prefectureId": "35",
"countyId": "376",
"path": [
{
"id": "1",
"parentId": null,
"codeStandard": "GB/T 2260",
"regionCode": "110000",
"levelCode": "PROVINCE",
"depth": 1,
"name": "北京市",
"shortName": null,
"nodeKind": "ADMINISTRATIVE",
"navigable": true,
"selectable": true,
"hasChildren": true,
"currentlyEffective": true,
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 10,
"rowVersion": 0
},
{
"id": "35",
"parentId": "1",
"codeStandard": "HL_GROUP",
"regionCode": "HL-GROUP-110000",
"levelCode": "PREFECTURE",
"depth": 2,
"name": "北京市辖区",
"shortName": null,
"nodeKind": "AGGREGATION",
"navigable": true,
"selectable": false,
"hasChildren": true,
"currentlyEffective": true,
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 2000000001,
"rowVersion": 0
},
{
"id": "376",
"parentId": "35",
"codeStandard": "GB/T 2260",
"regionCode": "110101",
"levelCode": "COUNTY",
"depth": 3,
"name": "东城区",
"shortName": null,
"nodeKind": "ADMINISTRATIVE",
"navigable": true,
"selectable": true,
"hasChildren": false,
"currentlyEffective": true,
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 20,
"rowVersion": 0
}
]
},
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
#### 空数据 / 降级响应
本接口不返回“成功但空父链”。目标不存在、父链不完整或超过当前三级安全边界时使用错误响应,不返回部分路径。Redis 不可用时整条父链降级为同一数据库事务快照读取,不拼接缓存与数据库的半条路径。
```json
{
"code": 210801,
"message": "行政区划节点不存在",
"data": null,
"success": false
}
```
#### 错误响应
父链存在孤儿、越级、循环或异常终止:
```json
{
"code": 210811,
"message": "行政区划父链数据不完整",
"data": null,
"success": false
}
```
#### 业务边界
- `path` 必须从省级根开始,`parentId`、`depth` 和 `levelCode` 逐级连续,最后一个节点必须等于 `selectedId`。
- 目标为省级时 `prefectureId`、`countyId` 均为 `null`;目标为地级时只有 `countyId=null`,绝不会返回字符串 `"null"`。
- `prefectureId` 只是路径第二层快捷 ID;直辖市等链路可指向 `nodeKind=AGGREGATION && selectable=false` 的聚合节点。
- 历史停用或过期节点允许回显,节点自身 `currentlyEffective=false`,且后端会把该节点的 `navigable`、`selectable` 强制返回 `false`;前端只能展示历史值,不能把它重新加入当前可选列表。
- 父链异常时整体失败,不返回可被误用的部分路径。
- 未登录返回业务码 `401`;不存在节点返回 `210801`。
---
### 3. 创建行政区划节点 `POST /admin/region`
**VO**: `AdministrativeRegionCreateReqVO / String`
#### 使用场景
具有 `system:region:create` 权限的管理员可以创建自定义业务行政区、带完整来源证据且不与旧版本重叠的 `GB/T 2260` 新版本,或只用于层级导航、不可作为最终业务值的聚合节点。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `Authorization` | Header | String | ✅ | `Bearer <token>` 且具有 `system:region:create` | 管理端登录令牌 |
| `codeStandard` | Body | String | ✅ | 1~32 字符;服务端去空白并转大写;聚合节点必须为 `HL_GROUP` | 可扩展编码命名空间,自定义行政区推荐 `HL_CUSTOM` |
| `regionCode` | Body | String | ✅ | 1~64 字符;首字符为字母或数字;仅含字母、数字、`.`、`_`、`:`、`-` | 编码标准内的唯一地区代码;服务端转大写 |
| `parentId` | Body | String / null | ❌ | 十进制正整数;创建省级根节点时为 `null` | 父节点 ID |
| `levelCode` | Body | String | ✅ | 1~32 字符;省/市/县必须匹配派生深度 | `PROVINCE`、`PREFECTURE`、`COUNTY` |
| `nodeKind` | Body | String | ✅ | `ADMINISTRATIVE` 或 `AGGREGATION` | 节点业务类型 |
| `name` | Body | String | ✅ | 非空,最多 128 字符 | 完整名称 |
| `shortName` | Body | String / null | ❌ | 最多 64 字符;空白归一为 `null` | 简称 |
| `navigable` | Body | Boolean | ✅ | 聚合节点固定为 `true` | 是否允许继续向下导航 |
| `selectable` | Body | Boolean | ✅ | 聚合节点固定为 `false` | 是否允许作为最终业务值 |
| `validFrom` | Body | String | ✅ | `yyyy-MM-dd` | 生效日期 |
| `validTo` | Body | String / null | ❌ | `yyyy-MM-dd`,必须严格晚于 `validFrom` | 失效日期;`null` 表示持续有效 |
| `status` | Body | String | ✅ | `ACTIVE` 或 `INACTIVE` | 节点状态 |
| `sortOrder` | Body | Integer | ✅ | 0~2,000,000,000 | 同级排序号 |
| `sourceVersion` | Body | String | ✅ | 非空,最多 64 字符 | 可审计的数据来源版本 |
| `sourceRef` | Body | String | ✅ | 非空,最多 512 字符 | 来源 URL、文件定位或管理批次标识 |
| `extension` | Body | Object / null | ❌ | 必须是 JSON object,序列化后最多 4000 字符 | 结构化扩展;旧入参别名 `extJson` 仍兼容 |
`depth`、操作人和来源校验值由服务端派生,禁止由前端提交。
#### 出参 `Result<String>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | `200` 表示创建成功 |
| `message` | String | 响应说明 |
| `success` | Boolean | 创建成功为 `true` |
| `traceId` | String / null | 链路追踪 ID;报错排查时提供给后端 |
| `data` | String | 新节点 ID,始终为 JSON string |
#### 请求示例
```http
POST /admin/region HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <具有 system:region:create 的管理员令牌>
Content-Type: application/json
{
"codeStandard": "HL_CUSTOM",
"regionCode": "DEMO-COUNTY-001",
"parentId": "35",
"levelCode": "COUNTY",
"nodeKind": "ADMINISTRATIVE",
"name": "示例业务区",
"shortName": "示例区",
"navigable": true,
"selectable": true,
"validFrom": "2026-08-26",
"validTo": null,
"status": "ACTIVE",
"sortOrder": 10000,
"sourceVersion": "ADMIN-20260826",
"sourceRef": "工单或数据批次定位",
"extension": {
"businessNote": "示例扩展信息"
}
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": "2090000000000001001",
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
#### 空数据 / 降级响应
本接口成功时一定返回字符串 ID,不存在 `data=null` 的成功语义。失败时事务回滚且不创建节点;本接口没有“Redis 不可用仍受理写入”的公开降级承诺。
#### 错误响应
层级代码与父链派生深度不一致:
```json
{
"code": 210813,
"message": "行政区划层级代码与父链深度不一致",
"data": null,
"success": false
}
```
#### 业务边界
- 当前最大写入深度为 3;省、市、县层级必须分别使用 `PROVINCE`、`PREFECTURE`、`COUNTY`。
- 父节点必须存在、可导航、有效期完整覆盖子节点,并且不会让写入深度超过三级。
- `AGGREGATION` 必须同时满足 `codeStandard=HL_GROUP`、`navigable=true`、`selectable=false`;它只能导航,不能作为最终业务值。
- `ADMINISTRATIVE` 不得占用 `HL_GROUP`;除 `HL_GROUP`、`HL_INTERNAL` 的限制外,`codeStandard` 不是封闭枚举,自定义命名空间可扩展,推荐使用 `HL_CUSTOM`。
- 旧命名空间 `HL_INTERNAL` 不允许产生新写入;`GB/T 2260` 只允许六位数字 `regionCode`,并要求完整的 `sourceVersion`、`sourceRef`。
- 同一 `codeStandard + regionCode` 的版本有效期不得重叠;有效期采用左闭右开 `[validFrom, validTo)`。
- 缺少权限返回 `210802`;空请求或格式错误返回 `400`;全部失败路径零业务写入。
---
### 4. 更新行政区划节点 `PUT /admin/region/{id}`
**VO**: `AdministrativeRegionUpdateReqVO / Void`
#### 使用场景
具有 `system:region:update` 权限的管理员更新节点名称、父级、层级、可导航/可选状态、有效期、排序和扩展信息。请求必须携带完整可变字段快照和最近读取到的 `rowVersion`;这不是局部 PATCH。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `Authorization` | Header | String | ✅ | `Bearer <token>` 且具有 `system:region:update` | 管理端登录令牌 |
| `id` | Path | String | ✅ | 十进制正整数 | 要更新的节点 ID |
| `parentId` | Body | String / null | ❌ | 十进制正整数;省级根节点为 `null` | 更新后的父节点 ID |
| `levelCode` | Body | String | ✅ | 1~32 字符;必须匹配更新后父链深度 | 省/市/县层级代码 |
| `name` | Body | String | ✅ | 非空,最多 128 字符 | 完整名称 |
| `shortName` | Body | String / null | ❌ | 最多 64 字符;空白归一为 `null` | 简称 |
| `navigable` | Body | Boolean | ✅ | 聚合节点必须保持 `true` | 是否允许继续向下导航 |
| `selectable` | Body | Boolean | ✅ | 聚合节点必须保持 `false` | 是否允许作为最终业务值 |
| `validFrom` | Body | String | ✅ | `yyyy-MM-dd` | 生效日期 |
| `validTo` | Body | String / null | ❌ | 必须严格晚于 `validFrom` | 失效日期 |
| `status` | Body | String | ✅ | `ACTIVE` 或 `INACTIVE` | 节点状态 |
| `sortOrder` | Body | Integer | ✅ | 0~2,000,000,000;部分初始化聚合节点的只读返回值大于此上限 | 同级排序号;不能无脑回填超限旧值 |
| `extension` | Body | Object / null | ❌ | 必须是 JSON object,最多 4000 字符;省略与显式 `null` 都会清空 | 结构化扩展;旧别名 `extJson` 仍兼容 |
| `rowVersion` | Body | Integer | ✅ | 大于等于 0,必须等于当前版本 | CAS 并发版本,成功后自动加 1 |
`codeStandard`、`regionCode`、`nodeKind`、`sourceVersion` 和 `sourceRef` 不属于更新接口,不能通过请求改写。
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | `200` 表示更新成功 |
| `message` | String | 成功时为“行政区划更新成功” |
| `success` | Boolean | 更新成功为 `true` |
| `traceId` | String / null | 链路追踪 ID;报错排查时提供给后端 |
| `data` | null | 更新接口不返回业务数据 |
#### 请求示例
```http
PUT /admin/region/2090000000000001001 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <具有 system:region:update 的管理员令牌>
Content-Type: application/json
{
"parentId": "35",
"levelCode": "COUNTY",
"name": "示例业务区(更新)",
"shortName": "示例区",
"navigable": true,
"selectable": true,
"validFrom": "2026-08-26",
"validTo": null,
"status": "ACTIVE",
"sortOrder": 10001,
"extension": {
"businessNote": "名称已更新"
},
"rowVersion": 0
}
```
#### 响应示例
```json
{
"code": 200,
"message": "行政区划更新成功",
"data": null,
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
#### 空数据 / 降级响应
本接口成功时 `data=null` 是固定契约,调用方以 `code=200 && success=true` 判断成功。本接口没有“Redis 不可用仍受理写入”的公开降级承诺,也不会返回更新后的节点快照。
#### 错误响应
提交了已经过期的 `rowVersion`:
```json
{
"code": 210807,
"message": "行政区划已被其他操作更新,请刷新后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 更新采用完整可变字段快照,不是局部 PATCH;省略 `extension` 不表示保持原值,而是写成 `null`。
- 当前 `children` / `path` 响应不包含 `extension`,也没有单节点详情接口。前端无法仅靠这四个公开接口保留未知的非空扩展值;在补充详情契约前,不应开放此类节点的通用编辑入口。
- TEST 初始化聚合节点可返回大于 `2,000,000,000` 的 `sortOrder`(例如北京第二层为 `2,000,000,001`),而更新请求上限是 `2,000,000,000`;直接回填会返回 `400`,这类节点也不能按无损通用编辑处理。
- `rowVersion` 冲突返回 `210807` 且零写入;前端应重新加载,而不是自动覆盖重试。
- 有子节点的分支禁止移动父级或改变层级,返回 `210810`。
- 父节点提前停用、取消导航或缩短有效期,导致现有子节点失去合法父级时返回 `210814`。
- 已到生效日的 `GB/T 2260` 法定节点不能原位改写历史属性;代码只允许在其他历史字段(包含 `extension`)完全不变时关闭状态/结束时间。由于当前读接口不返回 `extension`,前端不能把“关闭已有法定版本”当成已具备的安全通用操作。
- 成功后数据库中的 `rowVersion` 增加 1,但响应不返回新版本;再次编辑前需重新读取可见节点,且要考虑提交后缓存异步刷新的短暂窗口。
---
## 四、契约约束与正确调用方式
### 三级联动新建流程
```text
GET /admin/region/children
→ 用户选择 provinceId
GET /admin/region/children?parentId={provinceId}
→ 用户选择第二层导航项 prefectureId(可能是不可提交的 AGGREGATION)
GET /admin/region/children?parentId={prefectureId}
→ 用户选择 countyId
→ 业务表单最终保存 countyId(或业务允许的上级 selectable 节点)
```
### 编辑历史值回显流程
```text
GET /admin/region/{savedRegionId}/path
→ 读取 provinceId / prefectureId / countyId
→ 按 path 顺序逐层调用 children
→ 每层把对应历史 ID 作为 selectedId
→ 历史节点 currentlyEffective=false 时只展示历史值,不重新作为当前选项提交
```
### ✅ 正确 / ❌ 错误调用对照
| 场景 | 调用或 payload |
|---|---|
| ✅ 加载省级 | `GET /admin/region/children`,省略 `parentId` |
| ✅ 加载市级 | `GET /admin/region/children?parentId={provinceId}` |
| ✅ 编辑回显 | 先 `GET /admin/region/{savedId}/path`,再逐层加载 `children` |
| ✅ 比较 ID | 全程以 string 比较,例如 `item.id === selectedId` |
| ✅ 判断最终值 | 只提交 `selectable=true` 的节点;`defaultId`、`prefectureId` 本身不等于可提交 |
| ❌ 根层传 0 | `GET /admin/region/children?parentId=0` → 400 |
| ❌ 截行政区编码推父级 | 根据 `110101` 截取 `110100`;父子关系只能以接口返回的 ID 为准 |
| ❌ 把聚合节点当最终值 | `nodeKind=AGGREGATION && selectable=false` 时仍提交为业务行政区 |
| ❌ 强转 number | `Number("2090000000000000376")` 会丢失精度 |
### 统一响应判断
```javascript
if (response.code !== 200 || response.success !== true) {
throw new Error(response.message || '行政区划接口调用失败');
}
```
禁止只判断 HTTP status;业务错误可能使用 HTTP 200 返回。
---
## 五、数据库行为
| 前端操作 | 外部可观察的持久化行为 | 失败行为 |
|---|---|---|
| 查询 `children` | 只读,不改变节点、版本或审计 | 不写入 |
| 查询 `path` | 只读,不改变节点、版本或审计 | 不写入,不返回部分父链 |
| 创建节点 | 成功新增一个节点,初始 `rowVersion=0`;数据库事务提交后异步刷新节点和父级列表缓存 | 参数、权限、父链、有效期或唯一性失败时不新增节点 |
| 更新节点 | 成功更新一个节点并令 `rowVersion+1`;数据库事务提交后异步刷新节点、旧/新父级及上层列表缓存 | 版本冲突或业务 guard 失败时原节点保持不变 |
写接口成功只证明数据库事务已经提交,不承诺紧随其后的第一个缓存读取已经反映新值。前端不得自行操作缓存;需要立即回显时应短暂重读,并始终以新的 `rowVersion` 为准。读取阶段 Redis 故障会降级数据库;写入口还受公共幂等保护,不能据此推导“Redis 故障时写入一定成功”。
---
## 六、边界行为
- 未登录:业务码 `401`。
- 已登录但缺少创建/更新专用权限:`210802`。
- Query/Path ID 为 0、负数或非十进制正整数:`400`。
- 父节点或目标节点不存在:`210801`。
- 父级不可导航、有效期不能覆盖子级:`210803`。
- 写入超过当前三级边界:`210804`。
- 自引用或父链成环:`210805`。
- 当前编码或版本冲突:`210806` / `210815`。
- `rowVersion` 过期:`210807`,零写入。
- `validTo` 不晚于 `validFrom`:`210808`。
- 父链异常:`210811`,不返回部分路径。
- `extension` 不是 JSON object 或过长:`210812`。
- 层级代码与父链深度不一致:`210813`。
- 写接口命中五秒参数幂等键:`100502`;这是拒绝重复提交,不会回放第一次成功响应。
- 老数据兼容:停用历史节点可由 `path` 回显;旧缓存中的数字 ID 可由后端读取,但当前 API 响应始终输出 string ID。
### 业务错误码
| code | message | 典型接口与场景 |
|---:|---|---|
| `400` | 参数校验失败文案 | 任一接口的 ID 非正整数、Body 缺少必填字段或格式不合法 |
| `401` | 未认证文案 | 任一接口未携带有效管理端身份 |
| `100502` | 请勿重复提交 | `create` / `update` 在五秒内命中相同参数幂等键;失败不会返回第一次结果 |
| `210801` | 行政区划节点不存在 | `children` 父节点或 `path`/`update` 目标不存在 |
| `210802` | 无行政区划维护权限 | `create` / `update` 缺少专用写权限 |
| `210803` | 行政区划父节点不合法 | 父级不可导航、已失效或有效期不能覆盖子级 |
| `210804` | 行政区划层级超过当前允许深度 | `create` / `update` 试图写入第四级 |
| `210805` | 行政区划父链存在循环 | `update` 自引用或移动后形成祖先环 |
| `210806` | 行政区划当前编码已存在 | `create` 唯一编码冲突 |
| `210807` | 行政区划已被其他操作更新,请刷新后重试 | `update.rowVersion` 已过期 |
| `210808` | 行政区划有效期不合法 | `validTo` 不晚于 `validFrom` |
| `210809` | 该行政区划编码标准为系统保留值 | 新写入继续使用旧 `HL_INTERNAL` |
| `210810` | 存在子节点的行政区划不能移动或变更层级 | `update` 移动分支或改变分支层级 |
| `210811` | 行政区划父链数据不完整 | `path` 遇到孤儿、越级、循环或异常终止 |
| `210812` | 行政区划扩展字段必须是合法 JSON | `extension` 不是 JSON object 或内容过长 |
| `210813` | 行政区划层级代码与父链深度不一致 | 省/市/县代码与派生深度不匹配 |
| `210814` | 行政区划更新与现有子节点状态或有效期冲突 | 父级提前停用、取消导航或缩短有效期 |
| `210815` | 行政区划编码的版本有效期与既有数据重叠 | 同编码的两个版本时间窗重叠 |
| `210816` | 行政区划节点类型或编码命名空间不合法 | `nodeKind`、`codeStandard`、可导航/可选组合错误 |
| `210817` | 已生效法定行政区划只能关闭旧版本后新增 | 原位改写生效中的 `GB/T 2260` 节点 |
| `210818` | 行政区划数据来源信息不完整 | `sourceVersion` 或 `sourceRef` 缺失/过长 |
| `210819` | 法定行政区划代码必须为六位数字 | `GB/T 2260` 的 `regionCode` 格式错误 |
---
## 六.5、枚举 / 数据字典
### `levelCode`(行政区划层级)
**所属字段**: `AdministrativeRegionCreateReqVO.levelCode`、`AdministrativeRegionUpdateReqVO.levelCode`、`AdministrativeRegionItemVO.levelCode` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `PROVINCE` | 省级 | 深度 1,父节点必须为 `null` |
| `PREFECTURE` | 地级 | 深度 2,父节点必须是省级 |
| `COUNTY` | 县级 | 深度 3,父节点必须是地级 |
### `nodeKind`(节点类型)
**所属字段**: `AdministrativeRegionCreateReqVO.nodeKind`、`AdministrativeRegionItemVO.nodeKind` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `ADMINISTRATIVE` | 行政区节点 | 可按 `selectable` 决定是否作为最终业务值;不得使用 `HL_GROUP` |
| `AGGREGATION` | 导航聚合节点 | 必须使用 `HL_GROUP`、`navigable=true`、`selectable=false` |
### `status`(节点状态)
**所属字段**: 创建/更新请求及节点响应的 `status` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `ACTIVE` | 启用 | 位于有效期时可进入当前联动列表 |
| `INACTIVE` | 停用 | 不进入当前联动列表,但历史父链仍可回显 |
### `codeStandard`(可扩展编码命名空间)
**所属字段**: `AdministrativeRegionCreateReqVO.codeStandard`、`AdministrativeRegionItemVO.codeStandard` | **类型**: `String`
`codeStandard` 不是封闭枚举。服务端会去除首尾空白并转成大写;下表仅列出内置/保留语义:
| 值或类别 | 中文 | 说明 |
|---|---|---|
| `GB/T 2260` | 法定行政区编码 | `regionCode` 必须为六位数字;生效历史受不可变保护 |
| `HL_CUSTOM` | 自定义行政区编码 | 管理端创建普通自定义行政区的推荐命名空间 |
| `HL_GROUP` | 导航聚合编码 | 只能与 `AGGREGATION` 搭配 |
| `HL_INTERNAL` | 旧内部命名空间 | 仅兼容识别旧缓存,新写入固定拒绝并返回 `210809` |
| 其他非空值 | 扩展命名空间 | 最多 32 字符;可供 `ADMINISTRATIVE` 使用,但不得冒充 `HL_GROUP` |
---
## 六.6、修改前后对比
本条为新增接口,没有需要兼容的旧 `/admin/region/**` 公开接口。补充工单 #6409 在正式前端接入前固定了 ID 输出类型:
### 字段级对比
| 字段 | 初始实现风险 | 当前已部署契约 |
|---|---|---|
| 所有行政区划 Long ID | 小数值可能被全局序列化规则输出为 JSON number | 一律输出 JSON string |
| 缺失父级/层级 | 可能被调用方误当字符串处理 | 保持真正的 JSON `null` |
| `extension` 历史入参 | 旧调用方可能使用 `extJson` | 请求继续兼容 `extJson`,当前文档统一使用 `extension` |
| TEST 北京第二层示例 | 曾被文档误写为可选择的 `GB/T 2260` 地级节点 | 实际为 `id="35"`、`HL_GROUP`、`AGGREGATION`、`selectable=false` 的导航分组 |
### 行为级对比
| 行为 | 接入前 | 当前已部署行为 |
|---|---|---|
| 三级联动 | 无统一公开接口,容易截行政区编码推断 | 逐级调用 `children`,以后端父子关系为准 |
| 编辑回显 | 调用方自行拼装父链 | 调用 `path` 返回完整有序父链和三级快捷 ID |
| 并发更新 | 无本接口旧语义 | 必须提交 `rowVersion`,冲突返回 `210807` 且零写入 |
| 写后读取 | 文档曾表述为提交后立即可见 | 代码为事务提交后异步刷新缓存,存在短暂最终一致窗口 |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否;这是新增接口,且正式前端接入前已经固定字符串 ID 契约。
- **前端接入状态**: `frontend_status=verified`;mmg 已完成三级联动接入验证(`frontend_ref=7acd1cb0`)。该验证不扩大为通用主数据编辑能力,后者仍受 `extension` 不可回读等限制,不能按“完整 CRUD 已具备”上线。
- **前端 workaround 清理点**: 不得保留行政区编码截位、Long ID 转 number、只判断 HTTP status、把 `hasChildren` / `defaultId` / `prefectureId` 等同于 `selectable`,或把省略 `extension` 理解成保持原值的临时逻辑。
---
## 七、不影响范围
- **仅影响**: 管理后台行政区划维护、省/市/县三级选择器及历史行政区值回显。
- **当前未提供**: 单节点详情、历史版本列表、按编码查询、删除、批量导入/导出接口;四个已发布接口不能组成无损的完整 CRUD。
- **前端接入边界**: 三级选择与父链回显可直接接入;对已有非空 `extension`、超出更新上限的初始化 `sortOrder`、已生效法定节点关闭等需要无损回读/回填的场景暂不具备完整公开契约。
- **零影响**:
- 小程序端现有行政区接口;
- Product、Order、Resource 和 Fleet 现有业务接口;
- 已保存业务数据中的行政区 ID;
- 登录、权限菜单和非行政区划缓存;
- 生产环境(本 Changelog 只证明 TEST 已部署和验收)。
---
## 八、测试环境已验证
```text
GET /admin/region/children → 根级/子级列表、defaultId、string/null ID ✓
GET /admin/region/{id}/path → 三级完整父链、缺失层级 null、string ID ✓
POST /admin/region → 创建成功返回 string ID,数据库与缓存读取一致 ✓
PUT /admin/region/{id} → CAS 更新成功,rowVersion 0→1,提交后缓存刷新完成再读取一致 ✓
GET /admin/region/children(未认证) → 401 ✓
POST /admin/region(缺参/非法节点类型) → 400 且零业务写入 ✓
GET /admin/region/{missingId}/path → 210801 ✓
```
- TEST 精确提交:`b62054035a139e3c689179ef75e2d052d08dde52`,包含六个相关 PR 的合并提交。
- 本次代码优先复核确认:该提交的 `AdminRegionController` 只有本文四个公开端点;`AdministrativeRegionItemVO` 不含 `extension`;缓存刷新注册在事务 `afterCommit` 后交给独立执行器;migration 中北京链路为 `1 → 35 → 376`,其中 `35` 规范化后为不可选择的 `HL_GROUP/AGGREGATION`。
- 2026-08-26 本次复核还通过 TEST Gateway 匿名请求确认 `/admin/region/children` 当前可达且返回业务码 `401`、`success=false`;未使用伪造身份,也未产生业务写入。
- Deploy Panel API 任务:`52771074`(`hl-user-service`)和 `b1db24b2`(`hl-gateway`),终态均为 `success`,双实例、Nacos、滚动采样及部署窗口日志检查通过。
- 真实 TEST 管理员经 Gateway 完成未认证、读取、失败零写入、创建、CAS 更新、持久化结果和缓存一致性验收。
- 验收创建的临时节点已精确清理;数据库数量与测试命名空间恢复基线,四个缓存快照按原值和绝对过期时间恢复,登录会话已注销;操作审计按系统设计保留。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6359 | #6350 | 行政区划主数据、三级写入与读接口基础实现 | ✅ 有效 |
| #6387 | #6384 | 权限、来源和节点类型契约补强 | ✅ 有效 |
| #6393 | #6389 | 有效期、父链和历史版本保护补强 | ✅ 有效 |
| #6398 | #6395 | Gateway 路由与认证策略补齐 | ✅ 有效 |
| #6402 | #6399 | 缓存一致性与滚动兼容补强 | ✅ 有效 |
| #6412 | #6409 | 所有行政区划响应 ID 固定为 JSON string | ✅ 有效 |
---
## 十、相关文档
- 主 Issue: [wx/HL#6350](https://git.1814.love:8443/wx/HL/issues/6350)
- ID 字符串补充 Issue: [wx/HL#6409](https://git.1814.love:8443/wx/HL/issues/6409)
- 代码优先核对修正 Issue: [wx/HL#6438](https://git.1814.love:8443/wx/HL/issues/6438)
- 主 PR: [wx/HL#6359](https://git.1814.love:8443/wx/HL/pulls/6359)
- ID 字符串 PR: [wx/HL#6412](https://git.1814.love:8443/wx/HL/pulls/6412)
- 本次文档补充 Issue: [wx/HL#6422](https://git.1814.love:8443/wx/HL/issues/6422)
---
## 撤回
本次 #6438 代码优先核对只修改 Changelog,不改变后端制品。若本次文档修正需要撤回,从最新 `hl-api-changelog/main` 创建独立分支,revert 本次文档合并提交并重新运行仓库全部校验;TEST 行政区划 API、数据库、配置、Redis 和 MQ 均不需要回退。撤回后前端必须以 TEST 对应提交的 Controller、VO、Service 与 migration 为准,不能恢复使用被本次指出的错误聚合节点示例或同步缓存假设。
---
## 关联 / 联系人
### 链接
- **Issue**: [#6350](https://git.1814.love:8443/wx/HL/issues/6350)
- **补充文档 Issue**: [#6422](https://git.1814.love:8443/wx/HL/issues/6422)
- **代码核对修正 Issue**: [#6438](https://git.1814.love:8443/wx/HL/issues/6438)
- **PR**: [#6359](https://git.1814.love:8443/wx/HL/pulls/6359)
- **ID 契约 PR**: [#6412](https://git.1814.love:8443/wx/HL/pulls/6412)
- **最终后端提交**: [b62054035](https://git.1814.love:8443/wx/HL/commit/b62054035a139e3c689179ef75e2d052d08dde52)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,369 @@
---
schema: "hl-changelog/v2"
ticket: "6391"
title: "供应商法人证件号与身份证正反面"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "74b3d8bf"
target_release: ""
verified_at: "2026-08-26"
status_note: "PR #6401 已合并 dev-v3 并部署 TEST;供应商创建、更新、提交新增法人身份证号及正反面永久地址,详情仅返回脱敏证件号。旧客户端省略三字段时保持兼容,前端待接入输入与双面上传。"
updated_at: "2026-08-26"
base: "dev-v3"
---
# 🔧 供应商法人证件号与身份证正反面
供应商“基本信息-法定代表人”新增身份证号、人像面和国徽面三个字段。身份证图片继续使用既有文件上传能力,本次接口只接收上传完成后的永久 HTTPS 地址,不新增上传或 OCR 接口。
## 变更接口
| 方法 | 路径 | 权限 | 变化 |
|---|---|---|---|
| POST | `/admin/supplier/items/add` | `supplier:create` | 创建草稿可保存三个法人证件字段 |
| PUT | `/admin/supplier/items/{supplierId}/update` | `supplier:update` | 增量更新三个法人证件字段 |
| POST | `/admin/supplier/items/{supplierId}/submit` | `supplier:update`、`supplier:approval:submit` | 完整注册表单可提交三个法人证件字段 |
| GET | `/admin/supplier/items/{supplierId}/basic-info/view` | `supplier:view` | 返回脱敏证件号及身份证正反面地址 |
## 通用字段与校验
| 字段 | 类型 | 必填 | 规则 |
|---|---|---|---|
| `legalRepresentativeIdNo` | string | 条件必填 | 18 位中国大陆居民身份证号;校验长度、出生日期、顺序码及校验位;末位小写 `x` 会规范为大写 `X` |
| `legalRepresentativeIdCardFrontUrl` | string | 条件必填 | 身份证人像面永久地址,最长 1000 字符;必须为公网 HTTPS 地址,且不能含账号密码、查询串或片段 |
| `legalRepresentativeIdCardBackUrl` | string | 条件必填 | 身份证国徽面永久地址,规则同人像面 |
三个字段必须“全部省略”或“同时提供”:
- 全部省略:兼容旧客户端和没有法人证件数据的存量供应商。
- 任意一个有值:三个字段必须同时有值,不能只保存证件号或单面图片。
- 法定代表人可能对应多个供应商,证件号不作为供应商之间的唯一键。
- 写接口的成功响应不回传证件号;需要展示时调用详情接口。
- 统一响应可能以 HTTP 200 承载业务失败,必须同时判断 `code`、`success` 和 `data`。
## 1. 创建供应商草稿
### 使用场景
在新建供应商基本信息时,同时保存法定代表人身份证号及正反面扫描件地址。
### 请求
```http
POST /admin/supplier/items/add
Authorization: Bearer <管理员令牌>
Content-Type: application/json
```
本次新增的三个字段遵循上方通用规则;创建草稿的既有最低必填字段仍为 `fullName`、`taxNo`、`mainCooperation`。
典型成功请求:
```json
{
"fullName": "法人证件联调示例供应商",
"taxNo": "L6391EXAMPLE001",
"legalRepresentative": "示例法人",
"legalRepresentativeIdNo": "11010519491231002x",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/id/front.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/id/back.jpg",
"mainCooperation": "旅游资源供应"
}
```
典型成功响应:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "1900000000000000001",
"supplierNo": null,
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [],
"updateTime": "2026-08-26 11:40:00"
}
}
```
兼容边界:旧客户端可完全省略三个新字段,其余请求保持原样。
异常请求(仅传人像面):
```json
{
"fullName": "法人证件联调示例供应商",
"taxNo": "L6391EXAMPLE001",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/id/front.jpg",
"mainCooperation": "旅游资源供应"
}
```
```json
{
"code": 400,
"message": "法定代表人证件号、人像面和国徽面必须同时提供",
"success": false,
"data": null
}
```
失败不会创建供应商草稿。
## 2. 更新供应商
### 使用场景
在供应商基本信息编辑页补录或替换完整的法人证件三字段。
### 请求
```http
PUT /admin/supplier/items/1900000000000000001/update
Authorization: Bearer <管理员令牌>
Content-Type: application/json
```
`changeReason` 和 `expectedUpdateTime` 沿用原接口必填约束;三个法人证件字段作为一组增量字段处理。典型成功请求:
```json
{
"legalRepresentative": "示例法人",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/id/front-v2.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/id/back-v2.jpg",
"changeReason": "补录法人身份证扫描件",
"expectedUpdateTime": "2026-08-26 11:40:00"
}
```
典型成功响应:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "1900000000000000001",
"supplierNo": null,
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [],
"updateTime": "2026-08-26 11:42:00"
}
}
```
兼容边界:三个新字段全部省略时,不修改现有法人证件数据。若请求中出现任一新字段,服务端会与当前值合并后再次检查三字段是否完整。
异常请求(身份证校验位错误):
```json
{
"legalRepresentativeIdNo": "110105194912310021",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/id/front-v2.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/id/back-v2.jpg",
"changeReason": "补录法人身份证扫描件",
"expectedUpdateTime": "2026-08-26 11:40:00"
}
```
```json
{
"code": 400,
"message": "法定代表人证件号的日期或校验位不合法",
"success": false,
"data": null
}
```
失败时主体版本、法人证件数据和变更记录均保持原状。
## 3. 提交供应商注册审批
### 使用场景
提交草稿的完整注册表单时,将法人证件三字段一并纳入审批内容。
### 请求
```http
POST /admin/supplier/items/1900000000000000001/submit
Authorization: Bearer <管理员令牌>
Content-Type: application/json
```
本接口继续要求完整注册表单和当前 `expectedUpdateTime`。典型成功请求:
```json
{
"fullName": "法人证件联调示例供应商",
"taxNo": "L6391EXAMPLE001",
"legalRepresentative": "示例法人",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/id/front.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/id/back.jpg",
"mainCooperation": "旅游资源供应",
"expectedUpdateTime": "2026-08-26 11:42:00"
}
```
典型成功响应:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"approvalLogId": "1900000000000000101",
"requestNo": "SUP-REQ-20260826-0001",
"provider": "LOCAL_AUTO",
"approvalStatus": "APPROVED",
"spNo": null,
"spStatus": null,
"syncStatus": "APPLIED",
"submittedAt": "2026-08-26 11:43:00",
"finishedAt": "2026-08-26 11:43:00"
}
}
```
兼容边界:没有法人证件数据的旧草稿仍可按旧请求提交;若提交法人证件,则三字段必须完整。
异常请求(图片地址带查询串):
```json
{
"fullName": "法人证件联调示例供应商",
"taxNo": "L6391EXAMPLE001",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/id/front.jpg?token=temporary",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/id/back.jpg",
"mainCooperation": "旅游资源供应",
"expectedUpdateTime": "2026-08-26 11:42:00"
}
```
```json
{
"code": 400,
"message": "法人证件人像面地址不在允许范围",
"success": false,
"data": null
}
```
失败时不会创建审批或改变供应商状态。
## 4. 查询供应商基本信息
### 使用场景
编辑页回显法人证件信息。证件号只返回掩码,不能用于恢复原文或再次提交;图片地址可用于有权限页面的预览。
### 请求
```http
GET /admin/supplier/items/1900000000000000001/basic-info/view
Authorization: Bearer <管理员令牌>
```
无请求体。
典型成功响应:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "1900000000000000001",
"supplierNo": null,
"fullName": "法人证件联调示例供应商",
"shortName": null,
"tax_no": "L639****E001",
"legalRepresentative": "示例法人",
"legalRepresentativeIdNoMask": "110105********002X",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/id/front.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/id/back.jpg",
"contactPhoneMask": null,
"establishDate": null,
"registeredCapital": null,
"businessScope": null,
"staffScale": null,
"mainCooperation": "旅游资源供应",
"status": "DRAFT",
"creditLevel": "B",
"totalScore": null,
"types": [],
"contacts": [],
"qualifications": [],
"updateTime": "2026-08-26 11:42:00"
}
}
```
兼容边界:存量供应商没有法人证件数据时,三个响应字段均为 `null`。响应中不存在 `legalRepresentativeIdNo` 明文字段。
未认证示例:
```json
{
"code": 401,
"message": "未认证或登录已失效",
"success": false,
"data": null
}
```
## 错误与前端处理
| 响应码 | 触发条件 | 前端处理 |
|---:|---|---|
| `400` | 身份证不是 18 位、出生日期/顺序码/校验位错误、三字段不完整、图片地址不符合规则 | 保留表单并定位到法人证件区域;不要自动重试 |
| `401` | 未登录或 Gateway 认证失效 | 进入统一重新登录流程 |
| `403` | 角色、功能权限或数据范围不足 | 隐藏无权操作并展示统一无权限提示 |
| `395014` | `expectedUpdateTime` 已过期 | 重新读取详情,提示用户确认后再提交 |
本次不新增业务错误码;参数失败继续使用统一 `400`。
## 前端改造清单
- 在“新建/编辑供应商-基本信息-法定代表人”后增加身份证号输入框、人像面上传和国徽面上传。
- 上传完成后提交永久 HTTPS 地址;不要提交临时签名 URL、查询参数 URL、Base64 或文件二进制。
- 前端可做 18 位长度和末位 `X/x` 预校验,但最终以服务端日期及校验位结果为准。
- 三字段联动必填;旧数据三个字段均为空时允许继续按原流程操作。
- 详情只展示 `legalRepresentativeIdNoMask`;不得寻找或缓存身份证号明文。
- 写成功后如需回显,重新调用详情接口,不要从写响应读取新字段。
## 验证证据
- 自动化:最新 `dev-v3` 的 Resource 全量测试 2111 项通过、0 失败、0 错误,38 项既有条件跳过;法人证件聚焦测试 89 项通过。
- TEST:Deploy Panel 任务 `118fcff1` 将目标提交 `de615b49e3ecef4be13bd6bc78b3100d08ef0bd2` 部署到双实例;该提交包含 #6391 合并提交 `b909c8f7dfd73712886a33b574c72030dde89d4f`,服务与 Nacos 健康检查通过。
- 真实 Gateway:7 组场景通过,覆盖合法创建、错误校验位零写入、详情脱敏与图片回显、未认证拒绝、非法更新零写入、旧客户端省略字段兼容和迁移状态。
- 清理:本轮验收草稿已通过业务删除接口软删除并保留正常删除审计,不遗留可用测试供应商。
## 撤回
1. 从最新 `dev-v3` 创建回退分支,revert #6401 合并提交并经独立 PR 合入。
2. 重新部署 `hl-resource-service`;新增的可空数据结构保留,不执行破坏性删除。
3. 旧客户端、存量供应商和已保存的安全数据保持兼容;无需恢复配置、Redis 或 MQ。
4. 经 Gateway 重跑旧请求、合法/非法证件号、三字段完整性、详情脱敏和失败零写入检查。
## 关联 / 联系人
- **Issue**: [#6391](https://git.1814.love:8443/wx/HL/issues/6391)
- **PR**: [#6401](https://git.1814.love:8443/wx/HL/pulls/6401)
- **合并提交**: [b909c8f7d](https://git.1814.love:8443/wx/HL/commit/b909c8f7dfd73712886a33b574c72030dde89d4f)
- **后端负责人**: @lc
@@ -0,0 +1,114 @@
---
schema: "hl-changelog/v2"
ticket: "6392"
title: "供应商暂停合作与拉黑原因必填接口"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "49b071b0"
target_release: ""
verified_at: "2026-08-26"
status_note: "PR #6403 已合并 dev-v3;两个状态命令已部署 TEST,ACTIVE→SUSPENDED、ACTIVE/SUSPENDED→BLACKLIST、原因审计、权限、并发、幂等和失败零写入均经真实 Gateway 验证。前端待接入原因弹窗与并发版本。"
updated_at: "2026-08-26"
base: "dev-v3"
---
# 供应商暂停合作与拉黑原因必填接口
供应商管理新增两个窄状态命令:暂停合作与列入黑名单。服务端强制要求业务原因和调用方读取到的并发版本,并沿用可信管理员身份、专用平台权限、聚合锁、短窗幂等、行锁、状态机及同事务审计。
## 变更接口
| 方法 | 路径 | 允许来源状态 | 目标状态 |
|---|---|---|---|
| POST | `/admin/supplier/items/{supplierId}/suspend` | `ACTIVE` | `SUSPENDED` |
| POST | `/admin/supplier/items/{supplierId}/blacklist` | `ACTIVE`、`SUSPENDED` | `BLACKLIST` |
两个接口都要求真实 `SUPER_ADMIN` 身份且拥有 `supplier:status:manage` 平台权限。前端按钮可按角色和状态控制展示,但不能替代服务端门禁。
## 公共请求
```json
{
"reason": "供应商连续违约,暂停合作复核",
"expectedUpdateTime": "2026-08-26 15:40:00"
}
```
| 字段 | 类型 | 必填 | 规则 |
|---|---|---|---|
| `reason` | string | 是 | 去除首尾空白后必须非空,最长 500 字符;规范化后的原因为状态审计内容 |
| `expectedUpdateTime` | string | 是 | 调用方最近一次读取到的供应商 `updateTime`,格式 `yyyy-MM-dd HH:mm:ss` |
路径参数 `supplierId` 必须为正 Long。请求体不接受角色、操作者或目标状态,身份仅来自 Gateway 验证后的可信属性。
## 成功响应
暂停合作成功:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "1900000000000000001",
"status": "SUSPENDED",
"updateTime": "2026-08-26 15:40:01"
}
}
```
拉黑成功时结构相同,`status` 为 `BLACKLIST`。`supplierId` 固定为 JSON string;后续状态命令必须使用响应或详情中的新 `updateTime`,不能继续提交旧版本。
每次成功迁移与一条 `supplier_change_log` 状态审计在同一本地事务内提交。审计记录包含 `fromStatus`、`toStatus`、操作者、角色、Trace ID 和规范化后的 `reason`;审计写入失败时状态整体回滚。
## 失败语义
| 场景 | 业务码 | 结果 |
|---|---:|---|
| 未登录或 Token 无效 | `401` | Gateway/服务认证拒绝,零业务写入 |
| `reason` 缺失、空白、纯空格、超过 500 字符,或缺少版本 | `400` | 参数/服务双层拒绝,零业务写入 |
| 供应商不存在或已软删除 | `395001` | 零业务写入 |
| 非 `SUPER_ADMIN` 或缺少 `supplier:status:manage` | `395004` | 专用状态权限失败关闭,零业务写入 |
| suspend 来源不是 `ACTIVE`;blacklist 来源不是 `ACTIVE/SUSPENDED` | `395005` | 状态机拒绝,零业务写入 |
| `expectedUpdateTime` 与数据库秒级版本不一致 | `395014` | 状态和审计均不写入,调用方应刷新后重试 |
| 5 秒内同操作者、供应商和相同请求重复提交 | `100502` | 重复请求被拒绝,不产生第二条状态审计 |
统一响应可能以 HTTP 200 承载业务失败,客户端必须同时检查 `code`、`success`、`message` 和 `data`。
## 前端联调事项
- 在合作中供应商上提供“暂停合作”和“列入黑名单”;暂停合作供应商仅提供“列入黑名单”。其他状态不展示这两个动作。
- 点击动作后弹出必填原因输入框,前端限制 500 字符;提交详情或列表中最近一次读取到的 `updateTime`。
- `395014` 应提示数据已变化并刷新详情;`395005` 应刷新当前状态;`100502` 应提示不要重复提交。
- 成功后使用响应中的 `status` 和 `updateTime` 更新页面或重新拉取详情/列表。
- 前端显示控制不构成授权;不得从请求体传入操作者、角色或目标状态。
## 验证证据
- 代码:PR #6403 合并提交为 `3f3a0bf734737ca0e84ed1cfcad1801835cd46d5`;供应商目标测试 62 项通过,Resource 全量 2100 项通过、0 失败、0 错误(38 项既有条件跳过),Gateway 路由/认证 9 项通过,`hl-verify` 全部通过。
- 部署:原规范任务 `d08c52f6` 成功,目标/实际提交均为 `4b31612aeba5c0d3ac690cc6af15b66290bd8b48`;只读恢复绑定原任务期样本,5 个可验证样本均健康、3 个本地不可验证样本、0 个故障样本,没有重复部署。
- 当前制品:后续同服务规范任务 `da90f211` 将 TEST 更新为 `dev-v3@692547a5989ec1f8fa1b1495fc8dd501cddc0fe0`,该提交包含本工单合并提交;6/6 个任务期样本均为双进程、Nacos 双 healthy/enabled 且端口匹配,0 不可验证、0 故障样本。本工单最终业务验收在该当前制品上完成。
- 真实 Gateway:验证 `ACTIVE→SUSPENDED→BLACKLIST` 和 `ACTIVE→BLACKLIST`,成功响应、详情状态、并发版本与同事务审计一致;原因首尾空白按规范化值入审计。
- 失败分支:未认证、真实 `CUSTOMIZER` 越权、原因缺失/空白/超长、缺版本、旧版本、DRAFT 来源、不存在对象、重复状态与短窗重复请求全部返回预期错误,主体版本、状态和审计计数均保持不变。
- 清理:仅创建 3 个随机 TEST 草稿,并以 ID、随机全名、当前状态三重条件设置 2 个 ACTIVE 夹具;未触碰既有供应商。验收后精确恢复 2 行为 DRAFT,再通过业务删除接口软删除全部 3 个夹具;有效供应商总量恢复为 4,`HL6392-` 活跃命名空间为 0。9 条脱敏审计按系统约定保留;两枚验收会话已注销,幂等键等待 6 秒自然过期,未手工修改 Redis。
## 撤回
1. 从最新 `dev-v3` 创建独立回退分支,执行 `git revert -m 1 --no-edit 3f3a0bf734737ca0e84ed1cfcad1801835cd46d5`,经评审 PR 合入;不要回退后续无关提交。
2. 通过 Deploy Panel API 对精确回退目标执行新鲜预检和显式部署 `hl-resource-service`,复核双实例、Nacos、日志以及既有供应商查询、建档、资料更新和审批链路。
3. 本次无 DDL、配置、Redis、MQ 或跨服务写入变更,不执行 schema、缓存或消息回退。
4. 已实际发生的 `SUSPENDED`、`BLACKLIST` 状态和审计不会随代码回退自动反转;禁止直接改库,只能由后续合法恢复合作/解除黑名单业务流程处理。
5. 回退后两个新增 POST 接口恢复不可用;前端在回退部署前隐藏/停用对应动作,并继续兼容既有供应商接口。
## 关联 / 联系人
- **Issue**: [#6392](https://git.1814.love:8443/wx/HL/issues/6392)
- **PR**: [#6403](https://git.1814.love:8443/wx/HL/pulls/6403)
- **合并提交**: [3f3a0bf73](https://git.1814.love:8443/wx/HL/commit/3f3a0bf734737ca0e84ed1cfcad1801835cd46d5)
- **后端负责人**: @lc
@@ -0,0 +1,555 @@
---
schema: "hl-changelog/v2"
ticket: "6397"
title: "供应商注册合同聚合信息(已废弃)"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: "pending"
target_release: "v2.1"
verified_at: "2026-08-29"
status_note: "本条记录的 #6405 合同随供应商草稿/注册聚合写入语义已被业务纠正并废弃,不再是当前可联调契约。当前后端以 #6544 为准:add/update/submit 对旧 contracts 输入兼容忽略,合同只在已有 supplierId 后通过三个独立接口维护,不进入建档审批;详情继续只读返回 contracts。最终 Resource 已通过任务 ca2fe4d7 部署提交 c579c87014f56452fea2fce5075b31ae6fd12a35。旧前端 002475b2 仅作为历史记录,当前消费状态恢复 pending。"
updated_at: "2026-08-29"
base: "dev-v3"
---
# ⚠️ 供应商注册合同聚合信息(已废弃)
> **2026-08-29 纠正:本文以下聚合写入说明仅保留为历史,不得继续用于联调或实现。** 当前契约见 [#6544 供应商合同独立登记](../2026-08/29_6544_供应商合同独立登记-新增接口-管理后台.md):供应商草稿、资料更新和注册提交对旧 `contracts` 输入兼容接收但完全忽略;合同必须在取得 `supplierId` 后,通过独立 add/update/del 接口和独立事务维护,且不进入建档审批。`basic-info/view` 仍只读返回未软删合同,`initialAccounts` 字段保持不变。
原前端提交 `002475b2` 消费的是已撤销的聚合写语义,只作为历史事实保留,当前状态为待按独立接口重新接入。
展示名调整不改变接口字段:原请求字段仍为 `initialAccounts`,不得改成 `settlementInfo` 或其他名称。
## 二、变更接口清单(历史,禁止用于当前联调)
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 请求体新增可选字段 | 可选增加 `contracts` 完整集合 |
| 2 | 提交供应商注册 | POST | `/admin/supplier/items/{supplierId}/submit` | 请求体新增可选字段 | 可选增加 `contracts` 完整快照 |
| 3 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应新增字段 | 响应增加 `contracts` 列表 |
统一响应均为 `Result<T>`。业务失败可能仍为 HTTP 200,调用方必须同时判断 `code`、`success`、`message` 和 `data`。
## ⚠️ 关键变化(2026-08-26 晚,PR #6450 同日修正)
- **响应 `contracts[].amount` 由 Number 改为 String**:此前(f2a4200d 验收时)响应示例为 `"amount": 1200.50`,现实际输出 `"amount": "1200.50"`,与全站金额字段(`contractId` 之外的金额一律字符串)对齐,防 JavaScript 浮点精度丢失。
- **请求侧不变**:`contracts[].amount` 请求仍按 Number 传(字符串同值也可被兼容解析),无需改表单提交。
- 前端处理:详情/列表展示处把 amount 当字符串渲染即可,参与运算前 `Number(...)` 转换。
## 三、接口详情
三个接口的合同字段完全一致,先在「公共合同字段」统一约定;各接口再分节给出自包含的使用场景、入参、出参、示例与错误。
### 公共合同字段
#### 请求字段 `contracts[]`
| 字段 | 类型 | 创建必填 | 提交既有项必填 | 约束与说明 |
|---|---|---:|---:|---|
| `contractId` | String | 否,且禁止传入 | 是 | 正整数 ID 字符串;必须属于当前供应商,同一快照不得重复 |
| `contractName` | String | 是 | 是 | 非空白,最长 500 字符 |
| `contractNo` | String | 否 | 否 | 最长 100 字符 |
| `contractType` | String | 是 | 是 | `FRAME`、`SINGLE_TRIP`、`PURCHASE` |
| `signDate` | String | 否 | 否 | `yyyy-MM-dd` |
| `startDate` | String | 是 | 是 | `yyyy-MM-dd` |
| `endDate` | String | 是 | 是 | `yyyy-MM-dd`,不得早于 `startDate` |
| `amount` | Number | 否 | 否 | 大于等于 0,最多 10 位整数和 2 位小数 |
| `pricingMode` | String | 否 | 否 | 计价方式说明,最长 100 字符 |
| `settleCycle` | String | 否 | 否 | 结算周期说明,最长 32 字符 |
| `status` | String | 是 | 是 | 写接口允许 `DRAFT`、`ACTIVE`、`EXPIRED` |
| `scanFileUrl` | String | 否 | 否 | 合同扫描件永久地址,最长 1000 字符 |
| `remark` | String | 否 | 否 | 最长 500 字符 |
#### 响应字段 `contracts[]`
详情返回上述全部业务字段,并额外返回:
| 字段 | 类型 | 说明 |
|---|---|---|
| `contractId` | String | 合同 ID,始终按字符串处理,不得转 JavaScript Number |
| `updateTime` | String | 合同当前版本,格式 `yyyy-MM-dd HH:mm:ss` |
响应中的 `amount` 为 String(如 `"1200.50"`,PR #6450 起),请求中的 `amount` 仍按 Number 传。
历史数据可能返回只读状态 `TERMINATED`;创建和提交请求不得发送该状态。
### 1. 创建供应商注册草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO`(请求;响应 data 字段见下表)
#### 使用场景
供应商注册第一步:创建草稿。`contracts` 为本次新增的可选完整集合,随草稿与供应商主体、资质、`initialAccounts` 一起成功或一起失败;旧客户端省略该字段时行为完全不变。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `fullName` | Body | String | 是 | 非空白 | 供应商全称(本次未变更,列此定位) |
| `taxNo` | Body | String | 是 | 统一社会信用代码 | 本次未变更,列此定位 |
| `qualifications` | Body | Array | 否 | - | 资质证照集合(本次未变更) |
| `contracts` | Body | Array | 否 | 非空时最多 100 项 | 本次新增:合同完整集合,单项字段见「公共合同字段」;创建时每项禁止携带 `contractId` |
| `initialAccounts` | Body | Array | 否 | - | 结算账户集合,字段名不得改(本次未变更) |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID,字符串 |
| `supplierNo` | String/null | 供应商编号,草稿期可为 null |
| `status` | String | 固定 `DRAFT` |
| `onboardingStage` | String | 入驻阶段,草稿为 `PROFILE_DRAFT` |
| `initialAccounts` | Array | 结算账户回显 |
| `updateTime` | String | 聚合版本时间,`yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
POST /admin/supplier/items/add
Authorization: Bearer <admin-token>
Content-Type: application/json
```
```json
{
"fullName": "示例供应商有限公司",
"shortName": "示例供应商",
"taxNo": "91350211M000100Y46",
"types": [
{
"typeCode": "SCENIC"
}
],
"mainCooperation": "景区资源合作",
"licenseImageUrl": "https://files.example.com/license.png",
"qualifications": [
{
"qualType": "BUSINESS_LICENSE",
"certNo": "LIC-2026-001",
"imageUrl": "https://files.example.com/license.png",
"permanentValid": true
}
],
"contracts": [
{
"contractName": "2026 年度框架合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-26",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": 1200.50,
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "DRAFT",
"scanFileUrl": "https://files.example.com/contracts/HT-2026-001.pdf",
"remark": "年度合作"
}
],
"initialAccounts": []
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"supplierId": "2090300000000063970",
"supplierNo": null,
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [],
"updateTime": "2026-08-26 11:10:00"
},
"success": true
}
```
#### 空数据 / 降级响应
`contracts` 省略、为 `null` 或空数组均可正常创建,旧客户端行为不变;创建响应不返回合同明细(合同通过详情接口回显)。草稿无结算账户时 `initialAccounts` 返回空数组 `[]`,不返回 `null`。
#### 错误响应
创建请求携带合同 ID(创建时每个合同都是新项,禁止 `contractId`):
```json
{
"code": 400,
"message": "创建草稿不能携带合同ID",
"data": null,
"success": false
}
```
失败时不会留下供应商主体或部分合同。
#### 业务边界
- 非空时最多 100 项,合同与供应商主体、资质和 `initialAccounts` 一起成功或一起失败。
- 创建请求中的每个合同都是新合同,禁止携带 `contractId`。
- 写入仍要求现有供应商创建权限;仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有对应平台权限的身份可执行。
- `endDate` 早于 `startDate` 直接校验失败,零写入。
### 2. 提交供应商注册 `POST /admin/supplier/items/{supplierId}/submit`
**VO**: `SupplierDraftSaveReqVO`(请求,含 `expectedUpdateTime` 版本;响应 data 字段见下表)
#### 使用场景
注册第二步:把草稿完整表单(含合同快照)提交审批。`contracts` 按完整快照语义处理:省略/`null` = 本次不处理合同;`[]` = 明确清空;非空数组 = 全量覆盖(带 ID 覆盖、无 ID 新增、遗漏删除)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 必须存在且未删除 | 目标供应商 |
| `fullName` | Body | String | 是 | 非空白 | 完整表单字段(本次未变更,列此定位) |
| `contracts` | Body | Array | 否 | 非空时最多 100 项 | 本次新增:合同完整快照,单项字段见「公共合同字段」;既有项必须带回字符串 `contractId` |
| `initialAccounts` | Body | Array | 否 | - | 结算账户集合(本次未变更) |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 聚合并发版本,须取详情最新值 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `approvalLogId` | String | 审批日志 ID,字符串 |
| `requestNo` | String | 审批请求号 |
| `provider` | String | 审批通道,如 `LOCAL_AUTO` |
| `approvalStatus` | String | 审批结果,如 `APPROVED` |
| `spNo` / `spStatus` | String/null | 外部审批单号/状态,本地通道为 null |
| `syncStatus` | String | 结果应用状态,如 `APPLIED` |
| `submittedAt` / `finishedAt` | String | 提交/完成时间 |
#### 请求示例
```http
POST /admin/supplier/items/2090300000000063970/submit
Authorization: Bearer <admin-token>
Content-Type: application/json
```
```json
{
"fullName": "示例供应商有限公司",
"shortName": "示例供应商",
"taxNo": "91350211M000100Y46",
"types": [
{
"typeCode": "SCENIC"
}
],
"mainCooperation": "景区资源合作",
"licenseImageUrl": "https://files.example.com/license.png",
"qualifications": [
{
"qualType": "BUSINESS_LICENSE",
"certNo": "LIC-2026-001",
"imageUrl": "https://files.example.com/license.png",
"permanentValid": true
}
],
"contracts": [
{
"contractId": "2090300000000063971",
"contractName": "2026 年度框架合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-26",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/HT-2026-001.pdf",
"remark": "提交审批"
}
],
"initialAccounts": [],
"expectedUpdateTime": "2026-08-26 11:10:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"approvalLogId": "2090300000000063972",
"requestNo": "SUP-REQ-7b8c9d00112233445566778899aabbccddeeff00112233445566778899aabbcc",
"provider": "LOCAL_AUTO",
"approvalStatus": "APPROVED",
"spNo": null,
"spStatus": null,
"syncStatus": "APPLIED",
"submittedAt": "2026-08-26 11:11:00",
"finishedAt": "2026-08-26 11:11:00"
},
"success": true
}
```
#### 空数据 / 降级响应
`contracts` 省略或为 `null` 时保留草稿当前合同,走既有审批流程,无降级差异;`contracts: []` 为明确清空(不是省略),会删除当前全部合同。草稿本身无合同时按无合同提交,不报错。
#### 错误响应
合同不属于当前供应商(或重复 ID、非法枚举、负金额、日期逆序等同族校验失败):
```json
{
"code": 400,
"message": "合同不属于当前供应商",
"data": null,
"success": false
}
```
该失败会回滚本次提交表单中的主体、资质、合同和审计变化,供应商仍保持原状态和原版本。
#### 业务边界
- 带 ID 的合同必须属于路径中的供应商;不属于当前供应商、重复 ID、非法枚举、负金额或日期逆序均失败。
- `expectedUpdateTime` 仍是供应商聚合并发版本;发生并发修改时调用方应刷新详情后重新组装完整表单。
- 合同快照会进入本次审批资料,但提交注册不会自动改写合同自身的 `status`。
- 快照语义易错点:用户明确删除全部合同时发送 `contracts: []`;未加载合同或不处理合同时省略字段,不要误发空数组。
### 3. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
供应商详情首屏:返回主体信息、资质、合同列表(本次新增 `contracts`)与聚合版本。管理端据此渲染「资质证照 → 合同信息 → 结算信息」区块。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 必须存在且未删除 | 目标供应商 |
无请求体、无查询参数。读取继续要求可信读角色和 `supplier:view` 平台权限。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID,字符串 |
| `fullName` / `shortName` | String | 供应商名称 |
| `tax_no` | String | 脱敏税号 |
| `types` | Array | 供应商类型,`typeCode`/`typeName` |
| `qualifications` | Array | 资质证照列表(本次未变更) |
| `contracts` | Array | 本次新增:合同列表,字段见「公共合同字段」;`amount` 为 String |
| `status` | String | 供应商状态 |
| `updateTime` | String | 聚合版本时间,提交表单时回传 `expectedUpdateTime` |
#### 请求示例
```http
GET /admin/supplier/items/2090300000000063970/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"supplierId": "2090300000000063970",
"supplierNo": null,
"fullName": "示例供应商有限公司",
"shortName": "示例供应商",
"tax_no": "9135**********0Y46",
"legalRepresentative": null,
"legalRepresentativeIdNoMask": null,
"legalRepresentativeIdCardFrontUrl": null,
"legalRepresentativeIdCardBackUrl": null,
"contactPhoneMask": null,
"establishDate": null,
"registeredCapital": null,
"businessScope": null,
"staffScale": null,
"mainCooperation": "景区资源合作",
"status": "DRAFT",
"creditLevel": "B",
"totalScore": null,
"types": [
{
"typeCode": "SCENIC",
"typeName": "景区"
}
],
"contacts": [],
"qualifications": [
{
"qualificationId": "2090300000000063973",
"qualType": "BUSINESS_LICENSE",
"qualTypeName": "营业执照",
"certNoMask": "LI*********01",
"imageUrl": "https://files.example.com/license.png",
"expiryDate": null,
"permanentValid": true,
"daysUntilExpiry": null,
"validityStatus": "VALID",
"validityStatusName": "有效",
"isRequired": false,
"expired": false,
"updateTime": "2026-08-26 11:10:00"
}
],
"contracts": [
{
"contractId": "2090300000000063971",
"contractName": "2026 年度框架合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-26",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "DRAFT",
"scanFileUrl": "https://files.example.com/contracts/HT-2026-001.pdf",
"remark": "年度合作",
"updateTime": "2026-08-26 11:10:00"
}
],
"updateTime": "2026-08-26 11:10:00"
},
"success": true
}
```
#### 空数据 / 降级响应
没有合同时返回空数组 `"contracts": []`,不返回 `null`;合同按 `contractId` 升序返回,只包含当前有效合同。
#### 错误响应
常见失败(统一 `Result` 包装,HTTP 200):
```json
{
"code": 395001,
"message": "供应商不存在",
"data": null,
"success": false
}
```
| 场景 | `code` | 前端处理 |
|---|---:|---|
| 未登录或 Token 失效 | `401` | 跳转登录,不展示空详情 |
| 可信角色或 `supplier:view` 平台权限不足 | `403` | 展示无权限状态 |
| 供应商不存在或已删除 | `395001` | 返回列表并刷新 |
#### 业务边界
- 读取要求可信读角色(`ADMIN`/`FINANCE`/`SUPER_ADMIN`)和 `supplier:view` 平台权限。
- 敏感字段(税号、证件号、手机号)一律脱敏返回,前端不得期待明文。
- `updateTime` 是后续提交表单的并发版本,必须原样缓存回传。
## 四、契约约束与正确调用方式
1. 在“资质证照”区域之后新增“合同信息”表格,在合同之后显示原账户表格。
2. 原账户表格标题改为“结算信息”;所有请求和响应继续使用 `initialAccounts`,不要改字段名。
3. 创建草稿时合同为完整新项,不发送 `contractId`;编辑后提交时,既有合同必须原样带回字符串 `contractId`。
4. 提交表单是完整快照。用户明确删除全部合同时发送 `contracts: []`;未加载合同或不处理合同时省略字段,不要误发空数组。
5. 所有 ID 均作为字符串保存、比较和回传,不经过 Number 转换。
6. 本次不新增接口路径、权限点或业务错误码;旧客户端省略 `contracts` 时继续可用。
7. 管理端源码不在本后端工单中修改,前端状态保持 `pending`,直至完成页签、标题和表格接入并提供前端引用。
## 五、数据库行为
- 创建草稿与提交注册均为聚合级单事务写入:供应商主体、资质、合同快照、结算账户与审计记录一起成功或一起失败,任一校验失败零写入。
- 合同快照随供应商聚合版本(`expectedUpdateTime` 乐观并发控制)持久化;并发修改时提交失败,调用方须刷新详情后重试。
- 审计留痕:创建/删除等不可逆操作保留审计记录;测试环境临时数据已通过业务删除接口软删除。
- 查询供应商基本信息为只读,无写库行为。
- 本次无 DDL、无 Flyway 迁移、无 Redis/MQ 行为变化。
## 六、边界行为
- `contracts` 省略、`null` 与空数组语义不同:省略/传 `null` = 不处理合同(旧客户端兼容);`[]` = 明确清空全部合同。
- 合同非空时单请求最多 100 项;合同与供应商主体、资质、`initialAccounts` 同事务,一起成功或一起失败。
- 创建草稿禁止携带 `contractId`(每个合同都是新项);提交既有合同必须原样带回字符串 `contractId`,外部/他人合同 ID 触发整体回滚零写入。
- `endDate` 早于 `startDate` 直接校验失败,零写入。
- `amount` 边界:大于等于 0,最多 10 位整数和 2 位小数;响应按字符串输出(PR #6450 起)。
- 历史只读状态 `TERMINATED` 仅可返回,创建/提交发送该状态会被拒绝。
- 越权:仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有对应平台权限可写;普通 ADMIN 调用写接口返回越权错误。
## 六.6、修改前后对比
| 场景 | 修改前 | 修改后 |
|---|---|---|
| 创建草稿 | 请求不能携带合同 | 可选携带完整 `contracts`,与草稿一起成功或失败 |
| 提交注册 | 提交表单不能维护合同 | 可省略保留、空数组清空或提交完整合同快照 |
| 基础信息 | 不返回合同列表 | 返回完整 `contracts[]` 及字符串 ID、版本 |
| 账户区域标题 | 页面显示“初始账户” | 页面应显示“结算信息”,接口字段仍为 `initialAccounts` |
| 页面区块顺序 | 资质后直接进入账户区域 | 资质证照 → 合同信息 → 结算信息 |
| 响应 `contracts[].amount`(PR #6450) | Number(如 `1200.50`) | String(如 `"1200.50"`) |
## 六.7、影响评估
- **管理端(admin)**:供应商注册/编辑页需新增「合同信息」表格并调整区块顺序;既有合同必须缓存并原样回传字符串 `contractId`,否则提交会整单回滚。
- **同日修正(PR #6450)**:响应 `contracts[].amount` 由 Number 改为 String,前端 f2a4200d 按 Number 集成的解析处需改为字符串处理(展示直接渲染、运算前 `Number(...)`),影响面限于合同金额展示/计算处,请求提交不受影响。
- **C 端(mp)**:不涉及,无影响。
- **QA 排查面**:问题定位优先看「契约约束与正确调用方式」第 3、4 条(快照回传与空数组语义)与「关键变化」(amount 字符串化)。
## 七、不影响范围
- 不新增接口路径、权限点或业务错误码;旧客户端省略 `contracts` 时行为完全不变。
- `initialAccounts` 字段名、类型与语义不变(仅页面展示标题由「初始账户」改「结算信息」,属前端文案)。
- 请求侧 `contracts[].amount` 仍按 Number 传,PR #6450 只改响应输出,不改请求解析。
- 供应商其余模块(资源信息、审批记录、账户证明)的接口与字段不受影响。
- 数据库结构无变更(复用既有快照列),无 Redis/MQ 行为变化。
## 八、测试环境已验证
- 自动化:供应商定向测试 115 项通过;`hl-resource-service` 全量 2,111 项,0 失败、0 错误,38 项条件跳过;`hl-verify` 与差异检查通过。
- 部署:Deploy Panel API 任务 `af3f205b` 终态 `success`、退出码 0、`has_build_error=false`,未发现 Maven、编译或滚动发布错误。
- 健康:`hl-resource-service` 的 8082、8182 双实例运行;Nacos `test` 命名空间两实例均 `healthy=true`、`enabled=true`。
- 真实 Gateway:23 项断言通过,覆盖合同随草稿创建、详情完整回显、字符串 ID、创建携带 ID 失败、普通 ADMIN 越权、提交外部合同 ID 完整回滚、日期逆序零写入和旧客户端省略 `contracts`。
- 同日修正复验(PR #6450):`hl-resource-service` 于 2026-08-26 23:44 滚动发布双实例 UP,jar 构建时间戳与合并提交一致;供应商定向测试 330 项通过;空原因/超长原因的状态变更请求仍由请求校验层返回 400(对外契约不变)。
- 清理:两个临时 DRAFT 均通过业务删除接口软删除并回读为不存在;仅保留不可逆的 CREATE/DELETE 操作审计。
- 环境限制:部署前锁定 `origin/dev-v3=1a16a5aec7d0b95ec87e6fb222581060a3135984`,但面板 Git API 回读到服务器本地短提交 `6f7d3ca78`,Gitea 无法解析该对象。接口行为已真实验证,后端工单仍等待测试环境恢复精确远端提交后复验,不能据此宣称最终交付完成。
## 九、相关历史 PR
- [#6405](https://git.1814.love:8443/wx/HL/pulls/6405):本功能原始落地(合同聚合信息)。
- [#6450](https://git.1814.love:8443/wx/HL/pulls/6450):同日每日审查修正——响应 `contracts[].amount` Number→String(金额序列化红线)、状态变更原因错误码段位化(395043/395044,仅内部防御路径,对外契约不变)。
## 撤回
1. 从最新 `dev-v3` 创建回退分支,执行 `git revert -m 1 --no-edit 1a16a5aec7d0b95ec87e6fb222581060a3135984`,经独立 PR 合入。
2. 重新滚动部署 `hl-resource-service`;无需执行数据库结构、配置、Redis 或 MQ 恢复。
3. 管理端停止发送和读取 `contracts`,恢复原页面结构;`initialAccounts` 契约始终不变。
4. 已保存的合同资料保留,不做破坏性批量清理;回退后旧客户端继续按省略 `contracts` 的路径工作。
5. 经 Gateway 复测创建、提交、基础信息、未认证、越权、非法合同 ID、失败零写入和旧客户端兼容,并确认双实例与 Nacos 健康。
## 十、相关文档
- 设计/API 说明:仓库 `docs/supplier/API-CHANGE-6397.html`
- 同日修正 PR:[#6450](https://git.1814.love:8443/wx/HL/pulls/6450)(响应 `contracts[].amount` Number→String,每日审查红线修复)
- 供应商暂停/拉黑原因必填(同属供应商状态域):`changelogs-v2/2026-08/26_6392_供应商暂停合作与拉黑原因必填接口-新增接口-管理后台.md`
## 关联 / 联系人
- **Issue**: [#6397](https://git.1814.love:8443/wx/HL/issues/6397)
- **PR**: [#6405](https://git.1814.love:8443/wx/HL/pulls/6405)
- **合并提交**: [1a16a5aec](https://git.1814.love:8443/wx/HL/commit/1a16a5aec7d0b95ec87e6fb222581060a3135984)
- **后端负责人**: @lc
@@ -0,0 +1,188 @@
---
schema: "hl-changelog/v2"
ticket: "6406"
title: "开票申请按税号自动回填企业工商信息"
consumer: "admin"
author: "wx(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "080ea8df"
target_release: ""
verified_at: "2026-08-26"
status_note: "PR #6415 已合并 dev-v3 并部署测试服,经网关 API 实测 companyInfo 与 lastInvoiceTitle 两段往返一致,非法税号返回 581526"
updated_at: "2026-08-26"
base: "dev-v3"
---
# 🔍 开票申请:按税号自动回填企业工商信息
开票申请填写页面现支持按税号自动回填企业工商信息。后端对接第三方企业信息查询服务(元典开放平台),并同时返回本系统同税号最近一条历史开票抬头,两段数据并列返回、由前端自行决定展示与回填优先级,后端不做合并取舍。
> **PR**: [#6415](https://git.1814.love:8443/wx/HL/pulls/6415) | **服务**: hl-order-service-v3 | **作者**: wx | **更新时间**: 2026-08-26
---
## 一、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 开票企业信息自动回填查询 | GET | `/v3/admin/invoice/company-info` | 新增 | 按税号查两段回填数据 |
---
## 二、接口详情
### 1. 开票企业信息自动回填查询 `GET /v3/admin/invoice/company-info`
**入参**: `taxNo`(Query 参数)
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| taxNo | Query | String | ✅ | 非空;去空格后长度 15 或 18 | 统一社会信用代码(或旧 15 位注册号) |
**出参 `Result<CompanyInfoRespVO>`**:
| 字段 | 类型 | 说明 |
|------|------|------|
| companyInfo | Object 或 null | 元典第三方工商照面;查不到/第三方异常/全 key 耗尽时为 null |
| lastInvoiceTitle | Object 或 null | 本系统同税号最近一条非作废发票的历史抬头;无历史时为 null |
**companyInfo 字段**(元典工商照面):
| 字段 | 类型 | 说明 |
|------|------|------|
| titleName | String | 企业名称 |
| taxNo | String | 统一社会信用代码 |
| legalPersonName | String | 法定代表人 |
| registAddress | String | 注册地址 |
| regStatus | String | 经营状态(存续/注销/吊销等,由第三方返回) |
**lastInvoiceTitle 字段**(本系统历史抬头):
| 字段 | 类型 | 说明 |
|------|------|------|
| titleName | String | 开票抬头 |
| bankName | String | 开户行 |
| bankAccount | String | 银行账号 |
| registAddress | String | 注册地址 |
| registPhone | String | 注册电话 |
| email | String | 邮箱 |
#### 请求示例
```
GET /v3/admin/invoice/company-info?taxNo=91110000802100433B
Authorization: Bearer <admin-token>
```
#### 成功响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"companyInfo": {
"titleName": "北京百度网讯科技有限公司",
"taxNo": "91110000802100433B",
"registAddress": "北京市海淀区上地十街10号百度大厦2层",
"legalPersonName": "梁志祥",
"regStatus": "存续"
},
"lastInvoiceTitle": {
"titleName": "北京百度网讯科技有限公司",
"bankName": "招商银行北京分行",
"bankAccount": "110900100011110",
"registAddress": "北京市海淀区上地十街10号百度大厦2层",
"registPhone": "010-59928888",
"email": "invoice@baidu.com"
}
},
"success": true
}
```
#### 降级响应示例(任一段查不到为 null,互不阻塞)
```json
{
"code": 200,
"message": "成功",
"data": {
"companyInfo": null,
"lastInvoiceTitle": null
},
"success": true
}
```
#### 非法税号错误响应
```json
{
"code": 581526,
"message": "税号格式不正确(统一社会信用代码须为 15 或 18 位)",
"data": null,
"success": false
}
```
---
## 三、边界行为
- 未登录 → 401(网关拦截)
- 税号为空 / 去空格后非 15 或 18 位 → `581526` 参数错误
- 元典查无该企业 → `companyInfo = null`,`lastInvoiceTitle` 照常返回
- 元典接口超时 / 异常 / 全部 key 不可用 → 降级 `companyInfo = null`,不影响 `lastInvoiceTitle`
- 本系统无同税号历史发票 → `lastInvoiceTitle = null`
- 税号含空格 → 后端自动去空格后再查询,不报错
---
## 四、不影响范围(显式声明)
- **仅新增**:本查询接口为纯只读旁路查询
- **零影响**:
- 开票申请提交接口(`/v3/admin/order/{orderId}/invoice/apply`)行为不变
- 开票流程、开票记录写入逻辑
- 既有发票列表/详情查询
- 订单、订单核心、房务、车务模块
---
## 五、测试环境已验证
网关实测(`https://api.test.1814.love:9443`):
```
GET /v3/admin/invoice/company-info?taxNo=91110000802100433B
→ 200 + companyInfo(北京百度网讯科技有限公司/梁志祥/存续) ✓
→ 200 + lastInvoiceTitle(招商银行北京分行/110900100011110/invoice@baidu.com) ✓
GET /v3/admin/invoice/company-info?taxNo=123
→ 581526 税号格式不正确 ✓
未带 Authorization 头
→ 401 缺少有效的 Authorization 头 ✓
```
---
## 十、相关文档
- 关联 Issue: [wx/HL#6406](https://git.1814.love:8443/wx/HL/issues/6406)
- 关联 PR: [wx/HL#6415](https://git.1814.love:8443/wx/HL/pulls/6415)
## 关联 / 联系人
### 链接
- **Issue**: [#6406](https://git.1814.love:8443/wx/HL/issues/6406)
- **PR**: [#6415](https://git.1814.love:8443/wx/HL/pulls/6415)
- **Merge commit**: [f2c6e9fef53315a458b79f71747ee3494c420694](https://git.1814.love:8443/wx/HL/commit/f2c6e9fef53315a458b79f71747ee3494c420694)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,64 @@
---
schema: "hl-changelog/v2"
ticket: "6409"
title: "行政区划响应 ID 固定为字符串"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "not_required"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "PR #6412 已合并 dev-v3;hl-user-service 已以提交 4b31612a 部署 TEST,create、children、path 经真实 Gateway 验证全部 Long ID 固定为 JSON string,空层级保持 null。"
updated_at: "2026-08-26"
base: "dev-v3"
---
# 行政区划响应 ID 固定为字符串
行政区划创建、单级联动和父链回显接口中的 Long ID 现在始终按 JSON 字符串返回,避免小整数因全局安全整数规则被输出为 JSON number。请求参数、权限、业务校验和错误码不变。
## 变更接口
| 方法 | 路径 | 响应变化 |
|---|---|---|
| POST | `/admin/region` | 成功响应 `data` 从 JSON number 固定为 string |
| GET | `/admin/region/children` | `parentId`、`defaultId`、`items[].id`、`items[].parentId` 固定为 string;空值保持 `null` |
| GET | `/admin/region/{id}/path` | `selectedId`、`provinceId`、`prefectureId`、`countyId` 及 `path` 节点 ID 固定为 string;缺失层级保持 `null` |
更新接口 `PUT /admin/region/{id}` 的请求和响应不变。所有请求路径、Query/Path 参数仍按原 Long 语义解析。
## 兼容性与错误语义
- 这是已发布接口 JSON 类型的契约修正,不新增字段,也不改变字段名称。
- 行政区划 Redis 缓存可同时反序列化旧 JSON number 与新 JSON string,滚动部署期间兼容。
- 根层 `parentId`、无默认节点的 `defaultId` 和父链中不存在的层级继续返回 `null`,不会变成字符串 `"null"`。
- 未认证请求继续返回业务码 `401`;非法正整数参数、缺失节点和非法节点类型继续使用既有错误语义,失败路径零业务写入。
- 无数据库 migration、配置、Gateway、MQ、缓存键或跨服务契约变更;无需修改前端源码,调用方按既定字符串 ID 契约消费即可。
- 统一响应可能以 HTTP 200 承载业务失败,客户端必须同时检查 `code`、`success`、`message` 和 `data`。
## TEST 验证证据
- PR #6412 合并提交为 `8494028aa2dfbb4e39147c09695898d247045d4c`;TEST 目标提交 `4b31612aeba5c0d3ac690cc6af15b66290bd8b48` 包含该合并提交。
- Deploy Panel API 任务 `b10dd2ed` 成功执行 `/opt/hulalv/scripts/deploy-backend.sh`;User 的 8081、8181 双实例和 Nacos 两个 healthy/enabled 实例通过,任务期 9 个可验证采样均有可用实例,0 个故障采样。
- 真实 TEST 管理员经 Gateway 验证 create、children、path:创建 ID、联动列表 ID、父链 ID 和对应 Redis 缓存 ID 均为字符串,根节点和缺失层级空值保持 `null`。
- 未认证请求返回 `401`;非法父节点、缺失节点、空创建和旧非法节点类型均按既有错误返回,并确认失败路径零写入。
- 成功创建和 CAS 更新后,精确删除本次创建的 1 行;数据库总量恢复且测试命名空间为 0。4 个相关 Redis key 按基线值及绝对过期时间恢复,验收会话已注销;操作审计按系统约定保留。
- 本地验证:Controller/Cache 定向 22 项通过;User 全量 3644 项通过、0 失败、0 错误,8 项条件跳过;`hl-verify` 与 `git diff --check` 通过。
## 撤回
1. 从最新 `dev-v3` 创建独立回退分支,执行 `git revert -m 1 --no-edit 8494028aa2dfbb4e39147c09695898d247045d4c`,经评审 PR 合入;不要回退后续无关提交。
2. 通过 Deploy Panel API 对回退目标执行新鲜预检和显式部署 `hl-user-service`,复核双实例、Nacos、日志和精确提交。
3. 本修复无数据库、配置或 MQ 变更,不执行 DDL、DML 或消息补偿。缓存新旧表示均可读取,通常无需清理;确需强制恢复旧表示时,仅在停止行政区划刷新后精确处理 `hl:user:region:v1:` 命名空间并由回退版本预热。
4. 撤回前确认调用方可重新接受 JSON number ID;撤回后经 Gateway 复测 create、children、path、空层级、未认证和失败零写入。
## 关联 / 联系人
- **Issue**: [#6409](https://git.1814.love:8443/wx/HL/issues/6409)
- **PR**: [#6412](https://git.1814.love:8443/wx/HL/pulls/6412)
- **合并提交**: [8494028aa](https://git.1814.love:8443/wx/HL/commit/8494028aa2dfbb4e39147c09695898d247045d4c)
- **后端负责人**: @lc
@@ -0,0 +1,777 @@
---
schema: "hl-changelog/v2"
ticket: "6436"
title: "供应商敏感字段完整回显与主类型"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "5957c58c"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6478、补充 PR #6483/#6486 已合并 dev-v3,最终提交 aa735fb8 已由 Deploy Panel 任务 7a48028c 发布 TEST;真实 TEST 身份已验证完整值、权限投影、失败零写入及测试数据清理。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 🔧 供应商敏感字段完整回显与主类型
供应商管理接口不再用掩码替代已授权管理员需要处理的业务原值,并补齐唯一主类型和注册账户摘要。证明附件仍受独立权限保护,不能因为本次完整值调整而绕过授权或同步读取审计。
## 一、背景
此前详情、账户和审批记录混用了原值、掩码与历史密文回退,草稿类型也缺少稳定的主类型回显,导致管理端无法可靠编辑、审核或回填表单。本次统一以下消费口径:
- 通过既有供应商读取权限后,税号、法人证件、电话、证照号和银行账号返回完整业务值。
- `proofFileUrls` 继续要求 `supplier:account:proof:read`,且只有同步读取审计成功后才返回;无权限时字段不序列化。
- `types[].isPrimary` 与根对象 `primaryTypeCode`、`primaryTypeName` 成为主类型权威回显。
- `*Mask` 字段保留兼容但已废弃,兼容期内与对应完整值字段同值;新代码应使用无 `Mask` 的字段。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应修改 | 返回完整主体、联系人、资质字段及主类型 |
| 2 | 查询供应商账户信息 | GET | `/admin/supplier/items/{supplierId}/account-info/list` | 响应修改 | 返回可读账户完整账号,按权限决定附件字段 |
| 3 | 查询收款账户详情 | GET | `/admin/supplier/bank-accounts/{accountId}/view` | 响应修改 | 返回完整账号,按权限决定附件字段 |
| 4 | 查询供应商审批记录 | GET | `/admin/supplier/items/{supplierId}/approval-records/page` | 请求与响应修改 | 增加目标筛选及完整前后值可用性 |
| 5 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 响应修改 | `initialAccounts` 回显完整账号摘要 |
| 6 | 更新供应商 | PUT | `/admin/supplier/items/{supplierId}/update` | 响应修改 | 稳定回显主类型及完整初始账号摘要 |
| 7 | 提交供应商注册 | POST | `/admin/supplier/items/{supplierId}/submit` | 响应修改 | 审批结果增加完整初始账号摘要 |
## 三、接口详情
### 1. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
供应商详情和编辑表单初始化。管理端可直接使用完整字段,并用主类型字段初始化单选控件。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID |
| `tax_no` | String | 完整主体证件号,JSON 名保持既有口径 |
| `legalRepresentativeIdNo` | String/null | 完整法人居民身份证号 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | String/null | 完整永久文件地址 |
| `contactPhone` | String/null | 完整公司联系电话 |
| `contacts[].contactPhone` | String | 完整联系人电话 |
| `qualifications[].certNo` | String/null | 完整证照编号 |
| `types[].isPrimary` | Boolean | 当前类型是否为主类型 |
| `primaryTypeCode` / `primaryTypeName` | String/null | 主类型值和展示名称;无类型草稿为 null |
| `legalRepresentativeIdNoMask` / `contactPhoneMask` | String/null | 废弃兼容别名,与完整值字段同值 |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"fullName": "示例旅行服务有限公司",
"tax_no": "91350211M000100Y46",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdNoMask": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/front.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/back.jpg",
"contactPhone": "13800138000",
"contactPhoneMask": "13800138000",
"types": [{"typeCode": "HOTEL", "typeName": "酒店", "isPrimary": true}],
"primaryTypeCode": "HOTEL",
"primaryTypeName": "酒店",
"contacts": [{"contactName": "示例联系人", "contactPhone": "13900139000", "contactPhoneMask": "13900139000"}],
"qualifications": [{"qualType": "BUSINESS_LICENSE", "certNo": "LIC-2026-001", "certNoMask": "LIC-2026-001"}],
"updateTime": "2026-08-27 11:30:00"
}
}
```
#### 空数据 / 降级响应
无类型草稿返回 `types: []`、`primaryTypeCode: null`、`primaryTypeName: null`。联系人或资质为空时返回空数组;历史停用类型的名称无法从字典解析时,名称回退为类型值,不阻断详情。
#### 错误响应
```json
{
"code": 395001,
"message": "供应商不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 要求既有 `supplier:view` 和数据范围校验,登录态不能替代业务权限。
- 普通应用日志、异常和 Trace 不记录上述完整值。
- `*Mask` 仅用于旧客户端兼容,新代码不得继续依赖掩码语义。
### 2. 查询供应商账户信息 `GET /admin/supplier/items/{supplierId}/account-info/list`
**VO**: `SupplierAccountInfoRespVO`
#### 使用场景
在供应商结算信息区域展示已进入 PENDING、ACTIVE 或 DISABLED 的管理端可读账户。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID |
| `bankAccounts` | Array | 可读账户,默认账户优先 |
| `bankAccounts[].accountNo` | String | 完整银行账号 |
| `bankAccounts[].accountNoMask` | String | 废弃兼容别名,与 `accountNo` 同值 |
| `bankAccounts[].proofFileUrls` | Array | 有独立权限且审计成功时出现;无权限时整个字段省略 |
| `bankAccounts[].status` | String | `PENDING`、`ACTIVE` 或 `DISABLED` |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/account-info/list
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"bankAccounts": [{
"accountId": "2092800000000000011",
"accountName": "示例旅行服务有限公司",
"bankName": "示例银行",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"proofFileUrls": ["https://files.example.com/supplier/account-proof.pdf"],
"status": "ACTIVE",
"isDefault": "YES"
}],
"updateTime": "2026-08-27 11:31:00"
}
}
```
#### 空数据 / 降级响应
仅有 DRAFT 账户或没有账户时返回 `bankAccounts: []`。无 `supplier:account:proof:read` 时账号仍为完整值,但每个账户均省略 `proofFileUrls`,不是返回空数组。
#### 错误响应
证明附件同步审计不可用时失败关闭,不返回任何附件:
```json
{
"code": 395039,
"message": "暂时无法校验资源,请稍后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 要求 `supplier:view`;附件另需 `supplier:account:proof:read`。
- 每次获准的附件读取都会先同步写安全审计,失败时整个请求失败。
- DRAFT 初始账户不会出现在该读取接口中,应使用创建/更新响应的 `initialAccounts` 展示草稿摘要。
### 3. 查询收款账户详情 `GET /admin/supplier/bank-accounts/{accountId}/view`
**VO**: `SupplierBankAccountDetailRespVO`
#### 使用场景
查看单个已进入可读状态的收款账户完整信息。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `accountId` | Path | String | 是 | 正整数 ID 字符串 | 账户 ID |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `accountId` | String | 账户 ID |
| `accountName` | String | 收款户名 |
| `accountNo` | String | 完整银行账号 |
| `accountNoMask` | String | 废弃兼容别名,与完整账号同值 |
| `proofFileUrls` | Array | 有独立权限且同步审计成功时出现,否则省略 |
| `status` / `isDefault` | String | 账户状态与默认标记 |
#### 请求示例
```http
GET /admin/supplier/bank-accounts/2092800000000000011/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"accountId": "2092800000000000011",
"accountName": "示例旅行服务有限公司",
"accountType": "CORPORATE",
"bankName": "示例银行",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"proofFileUrls": ["https://files.example.com/supplier/account-proof.pdf"],
"settleMode": "PREPAY",
"invoiceType": "NONE",
"status": "ACTIVE",
"isDefault": "YES",
"updateTime": "2026-08-27 11:31:00"
}
}
```
#### 空数据 / 降级响应
无附件权限时响应省略 `proofFileUrls`;DRAFT、已删除或不存在的账户按不可读处理,不返回草稿详情。
#### 错误响应
```json
{
"code": 395001,
"message": "供应商不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 完整账号沿用基础查看权限,证明附件使用独立权限和同步审计。
- 账户必须属于未删除供应商且状态为 PENDING、ACTIVE 或 DISABLED。
- 无附件权限时后端读取阶段即排除附件字段,不是先读取后隐藏。
### 4. 查询供应商审批记录 `GET /admin/supplier/items/{supplierId}/approval-records/page`
**VO**: `SupplierApprovalRecordPageReqVO / PageResult<SupplierApprovalRecordRespVO>`
#### 使用场景
统一查看供应商主体与收款账户的变更历史,并区分完整新记录和不可恢复的历史掩码记录。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `page` / `limit` | Query | Integer | 否 | 正整数,`limit` 不超过 200 | 分页参数 |
| `approvalLogId` | Query | String | 否 | 正整数 ID 字符串 | 精确筛选审批 |
| `operationType` | Query | String | 否 | `CREATE/UPDATE/ENABLE/DISABLE/DELETE` | 操作类型 |
| `targetType` | Query | String | 否 | `SUPPLIER/ACCOUNT` | 本次增加的目标筛选 |
| `fieldName` / `status` | Query | String | 否 | 当前接口枚举 | 字段或状态筛选 |
| `from` / `to` | Query | String | 否 | 必须成对,`yyyy-MM-dd HH:mm:ss` | 时间范围 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `records[].targetType` | String | `SUPPLIER` 或 `ACCOUNT` |
| `records[].targetId` | String/null | 主体记录为 null,账户记录为账户 ID |
| `records[].oldValue` / `newValue` | String/null | 完整中文业务摘要,附件仍按独立权限投影 |
| `records[].valueAvailability` | String | `FULL` 或 `LEGACY_MASKED_UNRECOVERABLE` |
| `records[].oldValueMasked` / `newValueMasked` | String/null | 废弃兼容别名 |
| `total` / `page` / `pageSize` | Integer | 分页元数据 |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/approval-records/page?page=1&limit=20&targetType=ACCOUNT
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [{
"supplierId": "2092800000000000001",
"operationType": "ENABLE",
"targetType": "ACCOUNT",
"targetId": "2092800000000000011",
"oldValue": "账户状态:待审批;收款账号:6222021234567890",
"newValue": "账户状态:已生效;收款账号:6222021234567890",
"valueAvailability": "FULL",
"status": "已生效",
"operatorName": "测试管理员",
"operatorRole": "超级管理员",
"createTime": "2026-08-27 11:32:00"
}],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 降级响应
无匹配记录返回 `records: []` 和 `total: 0`。历史记录只保存掩码且无法恢复原值时,`valueAvailability` 返回 `LEGACY_MASKED_UNRECOVERABLE`,不会猜测或拼造完整值。
#### 错误响应
```json
{
"code": 400,
"message": "目标类型仅支持SUPPLIER或ACCOUNT",
"data": null,
"success": false
}
```
#### 业务边界
- 要求 `supplier:approval:read` 及供应商数据范围权限。
- 证明附件只有独立权限存在且同步审计成功时才进入完整摘要。
- 分页内主体记录和账户记录按创建时间、记录 ID 稳定排序。
### 5. 创建供应商注册草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO / SupplierWriteRespVO`
#### 使用场景
创建供应商草稿并可同时保存一个初始收款账户;成功响应直接回显该账户的完整账号摘要。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `fullName` | Body | String | 是 | 非空白,最长 500 字符 | 供应商全称 |
| `taxNo` | Body | String | 是 | 6 至 64 字符 | 主体证件号 |
| `types` | Body | Array | 否 | 草稿可为空,非空不得重复 | 供应商类型,首项成为主类型 |
| `mainCooperation` | Body | String | 是 | 非空白 | 主要合作内容 |
| `initialAccounts` | Body | Array | 否 | 最多 1 项 | 初始收款账户完整输入 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 新供应商 ID |
| `status` | String | `DRAFT` |
| `initialAccounts[].accountId` | String | 初始账户 ID |
| `initialAccounts[].accountNo` | String | 完整银行账号 |
| `initialAccounts[].accountNoMask` | String | 废弃兼容别名,与完整账号同值 |
| `initialAccounts[].status` | String | 创建时为 `DRAFT` |
| `updateTime` | String | 并发版本时间 |
#### 请求示例
```json
{
"fullName": "示例旅行服务有限公司",
"taxNo": "91350211M000100Y46",
"types": [{"typeCode": "HOTEL"}],
"mainCooperation": "酒店资源合作",
"initialAccounts": [{
"accountType": "CORPORATE",
"bankName": "示例银行",
"accountNo": "6222021234567890",
"proofFileUrls": ["https://files.example.com/supplier/account-proof.pdf"],
"settleMode": "PREPAY",
"invoiceType": "NONE"
}]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"supplierNo": null,
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [{
"accountId": "2092800000000000011",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"status": "DRAFT"
}],
"updateTime": "2026-08-27 11:30:00"
}
}
```
#### 空数据 / 降级响应
省略 `initialAccounts` 或传空数组时成功创建并返回 `initialAccounts: []`。草稿可传 `types: []`,此时详情的主类型字段为 null。
#### 错误响应
```json
{
"code": 395002,
"message": "无权执行该供应商写操作",
"data": null,
"success": false
}
```
#### 业务边界
- 仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有 `supplier:create` 的身份可写;`ADMIN` 明确拒绝。
- 初始账户最多一项,完整请求原子成功或失败,失败不留下主体或子项。
- 新写入和新审计使用完整业务值,但普通日志、异常、Trace 和跨服务消息不得携带这些值。
### 6. 更新供应商 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO / SupplierWriteRespVO`
#### 使用场景
增量更新供应商草稿或可变字段,并获得稳定的主类型与初始账户摘要回显。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `types` | Body | Array | 否 | 省略表示不改;空数组表示清空草稿类型 | 类型完整快照 |
| `changeReason` | Body | String | 是 | 非空白 | 变更原因 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 乐观并发版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` / `status` | String | 供应商 ID 和当前状态 |
| `initialAccounts[].accountNo` | String | 既有初始账户完整账号 |
| `initialAccounts[].accountNoMask` | String | 废弃兼容别名 |
| `updateTime` | String | 更新后的并发版本 |
#### 请求示例
```json
{
"types": [{"typeCode": "HOTEL", "isPrimary": true}],
"changeReason": "调整主合作类型",
"expectedUpdateTime": "2026-08-27 11:30:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [{
"accountId": "2092800000000000011",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"status": "DRAFT"
}],
"updateTime": "2026-08-27 11:35:00"
}
}
```
#### 空数据 / 降级响应
省略 `types` 保持现有类型;草稿传 `types: []` 会清空类型并在详情返回 null 主类型。响应没有初始账户时固定返回空数组。
#### 错误响应
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有 `supplier:update` 的身份可写。
- 非空类型集合稳定保留且恰有一个主类型;提交审批时仍要求至少一个类型。
- 并发版本、状态或权限不满足时失败且零写入。
### 7. 提交供应商注册 `POST /admin/supplier/items/{supplierId}/submit`
**VO**: `SupplierSubmitReqVO / SupplierApprovalCommandRespVO`
#### 使用场景
提交完整注册表单并进入审批;审批响应直接携带本次注册初始账户的完整摘要。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 草稿供应商 |
| `fullName` / `taxNo` | Body | String | 是 | 完整注册表单约束 | 主体信息 |
| `types` | Body | Array | 是 | 至少一项且不得重复 | 第一项为主类型 |
| `initialAccounts` | Body | Array | 否 | 最多一项;省略可保留既有草稿账户 | 初始账户完整快照 |
| `expectedUpdateTime` | Body | String | 是 | 必须等于当前版本 | 并发围栏 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `approvalLogId` / `requestNo` | String | 审批记录与幂等请求号 |
| `provider` / `approvalStatus` / `syncStatus` | String | 审批通道、结果与本地应用状态 |
| `initialAccounts[].accountId` | String | 本次注册关联账户 ID |
| `initialAccounts[].accountNo` | String | 完整银行账号 |
| `initialAccounts[].accountNoMask` | String | 废弃兼容别名 |
| `initialAccounts[].status` | String | 审批通过后为 `ACTIVE` |
#### 请求示例
```json
{
"fullName": "示例旅行服务有限公司",
"taxNo": "91350211M000100Y46",
"types": [{"typeCode": "HOTEL"}],
"mainCooperation": "酒店资源合作",
"licenseImageUrl": "https://files.example.com/supplier/license.jpg",
"qualifications": [{
"qualType": "BUSINESS_LICENSE",
"certNo": "LIC-2026-001",
"imageUrl": "https://files.example.com/supplier/license.jpg",
"permanentValid": true
}],
"expectedUpdateTime": "2026-08-27 11:35:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"approvalLogId": "2092800000000000021",
"requestNo": "SUP-REQ-EXAMPLE",
"provider": "LOCAL_AUTO",
"approvalStatus": "APPROVED",
"syncStatus": "APPLIED",
"initialAccounts": [{
"accountId": "2092800000000000011",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"status": "ACTIVE"
}],
"submittedAt": "2026-08-27 11:36:00",
"finishedAt": "2026-08-27 11:36:00"
}
}
```
#### 空数据 / 降级响应
没有初始账户时返回 `initialAccounts: []`。提交仍必须包含非空类型和当前完整注册资料,不因草稿阶段允许空类型而放宽。
#### 错误响应
```json
{
"code": 395008,
"message": "请至少选择一个类型并设置唯一主类型",
"data": null,
"success": false
}
```
#### 业务边界
- 仅可信 `FINANCE`、`SUPER_ADMIN` 且同时拥有更新和提交权限的身份可执行。
- 审批准备、结果应用、主体状态、账户状态和完整审计保持原有事务与幂等语义。
- 权限、状态、摘要或并发围栏失败时不允许部分写入。
## 四、契约约束与正确调用方式
| 场景 | 正确处理 |
|---|---|
| 主类型绑定 | 使用 `primaryTypeCode`;类型列表使用 `types[].isPrimary`,不要自行取第一项猜测 |
| 完整字段 | 使用 `tax_no`、`legalRepresentativeIdNo`、`contactPhone`、`certNo`、`accountNo` |
| 废弃别名 | `legalRepresentativeIdNoMask`、`contactPhoneMask`、`certNoMask`、`accountNoMask` 仅作旧客户端兼容 |
| 证明附件无权限 | `proofFileUrls` 字段不存在;不要把缺字段当接口异常或空附件 |
| 草稿账户 | 从写响应 `initialAccounts` 获取;账户读取接口只返回 PENDING/ACTIVE/DISABLED |
| 业务失败 | HTTP 状态之外必须检查 `code`、`success`、`message` 和 `data` |
管理端不得把业务请求体中的身份或角色作为授权依据,也不得缓存其他管理员读取到的完整敏感值供当前会话复用。
## 五、数据库行为
| 外部动作 | 可观察结果 |
|---|---|
| 创建/更新供应商 | 主体、联系人、资质、类型和初始账户在同一业务事务中成功或失败 |
| 注册提交 | 审批结果成功应用后主体和初始账户进入可读生效状态,响应返回完整账户摘要 |
| 新增或变更敏感值 | 后续授权读取返回与提交一致的完整业务值,唯一冲突或非法值整次失败 |
| 失败请求 | 未认证、无权、缺参、非法状态、并发冲突和审计失败均不留下部分业务写入 |
| 历史数据 | 可恢复历史值继续读取;只剩不可逆掩码的审批历史显式标记为不可恢复 |
这些是接口可观察行为;调用方不依赖具体存储结构,也不应自行维护明文/密文兼容状态。
## 六、边界行为
- 未登录经 Gateway 返回 `401`。
- `ADMIN` 可在既有查看权限与数据范围内读取完整业务值,但写入返回 `395002`。
- `FINANCE`、`SUPER_ADMIN` 仍需同时拥有对应平台权限才能写,角色名称本身不是唯一授权条件。
- 附件独立权限不足时字段省略;同步审计失败时请求失败,不降级泄露附件。
- 列表 `limit=201`、缺少必填字段或非法状态返回业务失败,并保持零写入。
- Snowflake ID 继续按字符串处理;时间格式继续为 `yyyy-MM-dd HH:mm:ss`。
## 六.5、枚举 / 数据字典
### `targetType` / `valueAvailability`
| 字段 | 值 | 中文与说明 |
|---|---|---|
| `targetType` | `SUPPLIER` | 供应商主体变更,`targetId` 为 null |
| `targetType` | `ACCOUNT` | 收款账户变更,`targetId` 为账户 ID |
| `valueAvailability` | `FULL` | 完整业务前后值可用 |
| `valueAvailability` | `LEGACY_MASKED_UNRECOVERABLE` | 历史只剩不可逆掩码,不推测原值 |
### 账户可读状态
| 值 | 中文 | 说明 |
|---|---|---|
| `PENDING` | 待审批 | 账户可在管理端读取 |
| `ACTIVE` | 已生效 | 正常可用账户 |
| `DISABLED` | 已停用 | 保留只读历史信息 |
| `DRAFT` | 草稿 | 不进入账户列表和账户详情 |
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| 税号、法人证件号、电话、证照号 | 主要返回掩码或混合口径 | 授权读取返回完整值 |
| `accountNo` | 可能为空或仅依赖 `accountNoMask` | 返回完整账号 |
| `*Mask` | 表示掩码 | 废弃兼容别名,暂与完整值同值 |
| `proofFileUrls` | 权限语义分散 | 独立权限 + 同步审计;无权时字段省略 |
| `types[].isPrimary` | 主类型回显不稳定 | 明确 Boolean 标记 |
| `primaryTypeCode/Name` | 不存在 | 根对象直接返回,空类型草稿为 null |
| `initialAccounts` | 写/提交响应摘要不完整 | 返回账户 ID、完整账号和状态 |
| 审批记录 | 主体与账户口径分散 | 统一目标类型、目标 ID、完整值和可用性 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 编辑页初始化 | 可能需要用掩码字段或猜测主类型 | 可直接绑定完整值与权威主类型 |
| 附件读取 | 容易把空值与无权混淆 | 无权省略字段,审计失败整体失败 |
| 草稿账户展示 | 读取接口与草稿状态语义不清 | 写响应展示草稿摘要,读取接口只展示可读状态 |
| 历史审批 | 无法区分完整值与不可逆掩码 | 通过 `valueAvailability` 明确区分 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。既有字段名保留,废弃别名在兼容窗口内继续返回。
- **前端是否必须同步上线**: 建议尽快。应切换到无 `Mask` 字段、接入主类型字段,并正确处理 `proofFileUrls` 缺失。
- **前端 workaround 清理点**: 删除自行猜主类型、对 `accountNoMask` 二次掩码、把附件缺字段强制转空数组等兼容逻辑。
- **敏感展示责任**: 完整值只在已授权业务页面按最小必要范围展示,不写入前端日志、埋点、错误上报或持久缓存。
## 七、不影响范围
- **仅影响**: 管理后台供应商基本信息、结算账户、注册提交与审批记录消费契约。
- **零影响**:
- 不新增或修改 Gateway 路由。
- 不修改角色、菜单、按钮或数据范围定义。
- 不修改小程序、C 端、订单、产品、车队接口。
- 不修改文件上传接口、Redis、MQ 或跨服务 DTO。
- 不允许 DRAFT 账户通过账户查询接口提前暴露。
## 八、测试环境已验证
最终后端提交 `aa735fb8531a3463b2b460965245e07ed8697775` 已通过 Deploy Panel 任务 `7a48028c` 发布 `hl-resource-service` 双实例,真实 TEST 身份经 Gateway 验证:
```text
历史兼容:完整值与数据库一致,税号关键字搜索命中 ✓
SUPER_ADMIN:创建草稿、详情完整值、注册审批、账号及附件审计读取 ✓
ADMIN:详情与完整账号可读,proofFileUrls 省略,写入拒绝 395002 ✓
未认证 401、缺参 400、limit=201 为 400、非法状态 400 ✓
新写主体/联系人/资质/账户及新审计均为完整值,失败请求零写入 ✓
合成供应商完成精确清理:17 张相关表检查、业务残留 0、失败标记残留 0 ✓
验收会话主动失效,敏感读取操作审计按审计规则保留 ✓
```
获批真实账号没有 `FINANCE` 角色,因此没有伪造该身份;`FINANCE` 与 `SUPER_ADMIN` 的服务端写权限同构由后端自动化测试覆盖,真实 TEST 写入使用 `SUPER_ADMIN`,并用真实 `ADMIN` 验证拒绝路径。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6478 | #6436 | 完整字段、主类型、权限、迁移与兼容读写 | ✅ 主交付 |
| #6483 | #6481 | 修复 TEST 排序规则下回填精确比较 | ✅ 补充修复 |
| #6486 | #6484 | 支持历史 Unicode 税号回填 | ✅ 补充修复 |
## 十、相关文档
- [主工单 #6436](https://git.1814.love:8443/wx/HL/issues/6436)
- [主 PR #6478](https://git.1814.love:8443/wx/HL/pulls/6478)
- [补充工单 #6481](https://git.1814.love:8443/wx/HL/issues/6481) / [PR #6483](https://git.1814.love:8443/wx/HL/pulls/6483)
- [补充工单 #6484](https://git.1814.love:8443/wx/HL/issues/6484) / [PR #6486](https://git.1814.love:8443/wx/HL/pulls/6486)
- 后端详细 API 说明:`docs/supplier/API-CHANGE-6436.html`
## 关联 / 联系人
### 链接
- **Issue**: [#6436](https://git.1814.love:8443/wx/HL/issues/6436)
- **PR**: [#6478](https://git.1814.love:8443/wx/HL/pulls/6478)
- **最终 TEST 提交**: [aa735fb8](https://git.1814.love:8443/wx/HL/commit/aa735fb8531a3463b2b460965245e07ed8697775)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,499 @@
---
schema: "hl-changelog/v2"
ticket: "6474"
title: "供应商详情分离变更记录与审批流水"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "199147de"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6519 已合并 dev-v3;补充缺陷 PR #6534 已恢复旧兼容查询。hl-resource-service 已由 Deploy Panel 任务 9e69fd2c 精确发布提交 aec1de2d 至 TEST,并以真实 SUPER_ADMIN、ADMIN 和受限 CUSTOMIZER 身份完成 Gateway 只读验收。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 供应商管理:详情分离变更记录与审批流水
供应商详情新增两个相互独立的只读分页接口:“变更记录”只返回供应商主体发生过的业务变化,“审批记录”只返回供应商主体审批事实。两个列表独立计数、独立筛选、独立排序,收款账户记录不会混入。
旧 `/admin/supplier/items/{supplierId}/approval-records/page` 继续保留,用于仍需主体与账户变更混合列表的兼容场景;本次不删除、不改名,也不要求现有调用方同步切换。
## 一、背景
旧审批记录接口承载的是主体与收款账户变更的兼容混合列表,不能同时满足详情页“业务变更”和“审批过程”两种独立展示语义。若管理端在本地拆分或二次计数,会出现分页总数不准确、账户记录混入主体历史、审批技术字段误展示等问题。
本次由后端直接提供两个稳定的业务白名单视图:
- “变更记录”展示操作类型、变更前后业务摘要、原因、生命周期状态、操作人、历史角色和发生时间。
- “审批记录”展示审批业务、申请人、状态、动作、审批人安全展示值、意见、提交时间和完成时间。
- 两个接口都在查询前校验可信管理身份、角色和 `supplier:approval:read` 权限。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 查询供应商主体变更记录 | GET | `/admin/supplier/items/{supplierId}/change-records/page` | 新增只读接口 | 独立分页返回主体变更业务摘要 |
| 2 | 查询供应商主体审批流水 | GET | `/admin/supplier/items/{supplierId}/approval-history/page` | 新增只读接口 | 独立分页返回主体审批过程与结果 |
## 三、接口详情
### 1. 查询供应商主体变更记录 `GET /admin/supplier/items/{supplierId}/change-records/page`
**VO**: `SupplierChangeRecordPageReqVO` / `SupplierChangeRecordRespVO`
#### 使用场景
管理端进入供应商详情的“变更记录”页签时调用。服务端完成主体记录筛选、分页和业务摘要投影;前端不要从旧混合列表中再次筛选主体记录,也不要自行拼接前后值摘要。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
| `page` | Query | Integer | 否 | 默认 `1`,最小 `1` | 当前页;兼容别名 `pageNo` |
| `pageSize` | Query | Integer | 否 | 默认 `20`,范围 `1..100` | 每页条数 |
| `operationType` | Query | String | 否 | `CREATE`、`UPDATE`、`ENABLE`、`DISABLE`、`DELETE` | 操作类型精确筛选 |
| `fieldName` | Query | String | 否 | 非空白,最长 64 字符 | 发生变化的业务字段精确筛选 |
| `status` | Query | String | 否 | 生命周期编码 | 按变更后的供应商状态筛选 |
| `from` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss` | 与 `to` 成对传入,按发生时间筛选,含边界 |
| `to` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss`,不得早于 `from` | 与 `from` 成对传入,含边界 |
| `sortBy` | Query | String | 否 | `occurredAt` 或 `changeLogId`,默认 `occurredAt` | 服务端白名单排序字段 |
| `sortDirection` | Query | String | 否 | `ASC` 或 `DESC`,不区分大小写,默认 `DESC` | 排序方向 |
#### 出参 `Result<PageResult<SupplierChangeRecordRespVO>>`
分页对象固定包含 `records`、`total`、`page`、`pageSize`。
| 字段 | 类型 | 说明 |
|---|---|---|
| `records` | Array | 当前页主体变更记录;无数据时为 `[]` |
| `total` | Integer | 符合筛选条件的主体变更总数,不包含账户记录 |
| `page` | Integer | 当前页码 |
| `pageSize` | Integer | 当前页容量 |
| `records[].operationType` | String | 操作类型编码 |
| `records[].oldValue` | String | 变更前中文业务摘要;空值使用明确占位 |
| `records[].newValue` | String | 变更后中文业务摘要;空值使用明确占位 |
| `records[].valueAvailability` | String | `FULL` 或 `LEGACY_MASKED_UNRECOVERABLE` |
| `records[].changeReason` | String | 业务变更原因或稳定占位 |
| `records[].status` | String/null | 变更后的供应商生命周期中文名 |
| `records[].operatorName` | String | 操作人展示名;系统操作为“系统”,依赖降级为“未知管理员” |
| `records[].operatorRole` | String | 操作发生时的角色快照中文名 |
| `records[].occurredAt` | String | 变更发生时间,格式 `yyyy-MM-dd HH:mm:ss` |
响应不会返回变更日志 ID、管理员内部 ID、原始审计 JSON、TraceId、账户 ID、证明附件或其他主体敏感快照字段。
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/change-records/page?page=1&pageSize=20&operationType=UPDATE&sortBy=occurredAt&sortDirection=DESC
Authorization: Bearer <admin-token>
```
GET 请求无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [
{
"operationType": "UPDATE",
"oldValue": "供应商全称:示例旅行服务公司",
"newValue": "供应商全称:示例旅行服务有限公司",
"valueAvailability": "FULL",
"changeReason": "修正供应商主体名称",
"status": "合作中",
"operatorName": "示例管理员",
"operatorRole": "超级管理员",
"occurredAt": "2026-08-27 15:20:00"
}
],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 降级响应
筛选结果为空仍返回成功分页,不回退到旧混合列表:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [],
"total": 0,
"page": 1,
"pageSize": 20
}
}
```
历史记录无法恢复完整原值时,仍返回已有安全摘要,并明确标记:
```json
{
"operationType": "UPDATE",
"oldValue": "138****8000",
"newValue": "139****9000",
"valueAvailability": "LEGACY_MASKED_UNRECOVERABLE",
"changeReason": "历史记录",
"status": "合作中",
"operatorName": "未知管理员",
"operatorRole": "管理员",
"occurredAt": "2026-07-01 10:00:00"
}
```
#### 错误响应
非法筛选、分页、排序或时间范围返回参数错误,例如只传 `from`:
```json
{
"code": 400,
"message": "from和to必须同时传入",
"success": false,
"data": null
}
```
供应商不存在或已删除:
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 要求 Gateway 登录态、可信管理员身份、允许的读角色以及 `supplier:approval:read` 平台权限;任一门禁失败时不查询历史数据。
- 代码角色矩阵沿用 `ADMIN`、`FINANCE`、`SUPER_ADMIN`;角色允许不等于拥有平台权限,两项必须同时满足。
- 只读取供应商主体变更,不包含收款账户变更或审批流水。
- `oldValue`、`newValue` 是后端形成的中文业务摘要,不是可回填编辑表单的结构化快照。
- User 姓名依赖异常只影响 `operatorName`,分页仍成功且不会以管理员内部 ID 降级。
- 接口只读,成功、空数据和失败场景均不修改供应商、审批、缓存或消息状态。
### 2. 查询供应商主体审批流水 `GET /admin/supplier/items/{supplierId}/approval-history/page`
**VO**: `SupplierApprovalHistoryPageReqVO` / `SupplierApprovalHistoryRespVO`
#### 使用场景
管理端进入供应商详情的“审批记录”页签时调用。该列表只表达主体审批申请、过程和结果,不包含主体字段变更摘要,也不包含收款账户审批。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
| `page` | Query | Integer | 否 | 默认 `1`,最小 `1` | 当前页;兼容别名 `pageNo` |
| `pageSize` | Query | Integer | 否 | 默认 `20`,范围 `1..100` | 每页条数 |
| `bizType` | Query | String | 否 | 大写字母开头,仅大写字母、数字、下划线,最长 32 字符 | 审批业务类型精确筛选 |
| `approvalStatus` | Query | String | 否 | 大写字母开头,仅大写字母、数字、下划线,最长 20 字符 | 审批状态精确筛选 |
| `action` | Query | String | 否 | 大写字母开头,仅大写字母、数字、下划线,最长 32 字符 | 审批动作或结果精确筛选 |
| `from` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss` | 与 `to` 成对传入,按提交时间筛选,含边界 |
| `to` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss`,不得早于 `from` | 与 `from` 成对传入,含边界 |
| `sortBy` | Query | String | 否 | `submittedAt` 或 `finishedAt`,默认 `submittedAt` | 服务端白名单排序字段 |
| `sortDirection` | Query | String | 否 | `ASC` 或 `DESC`,不区分大小写,默认 `DESC` | 排序方向 |
#### 出参 `Result<PageResult<SupplierApprovalHistoryRespVO>>`
分页对象固定包含 `records`、`total`、`page`、`pageSize`。
| 字段 | 类型 | 说明 |
|---|---|---|
| `records` | Array | 当前页主体审批记录;无数据时为 `[]` |
| `total` | Integer | 符合筛选条件的主体审批总数,不包含账户审批 |
| `page` | Integer | 当前页码 |
| `pageSize` | Integer | 当前页容量 |
| `records[].bizType` | String | 审批业务类型编码 |
| `records[].bizTypeName` | String | 审批业务类型中文名;未知编码显示“其他供应商审批” |
| `records[].applicantName` | String | 申请人展示名;系统申请为“系统”,依赖降级为“未知申请人” |
| `records[].approvalStatus` | String | 审批状态编码 |
| `records[].approvalStatusName` | String | 审批状态中文名;未知编码显示“未知状态” |
| `records[].action` | String/null | 审批动作或结果编码 |
| `records[].actionName` | String | 审批动作中文名;未知编码显示“其他动作” |
| `records[].approverName` | String | 安全展示值:“系统”“待审批”或“外部审批人” |
| `records[].opinion` | String | 审批意见;无意见时为 `—` |
| `records[].submittedAt` | String | 提交时间;历史缺失时使用该审批事实的创建时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `records[].finishedAt` | String/null | 完成时间;审批中为 `null`,格式 `yyyy-MM-dd HH:mm:ss` |
响应不会返回审批日志 ID、申请人内部 ID、requestNo、spNo、审批模板 ID、企微用户 ID、候选或详情摘要、候选或详情 JSON/密文、apply/sync 技术状态。
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/approval-history/page?page=1&pageSize=20&approvalStatus=APPROVED&sortBy=submittedAt&sortDirection=DESC
Authorization: Bearer <admin-token>
```
GET 请求无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [
{
"bizType": "PROFILE_CREATE",
"bizTypeName": "供应商建档审批",
"applicantName": "示例管理员",
"approvalStatus": "APPROVED",
"approvalStatusName": "已通过",
"action": "SYSTEM_AUTO_APPROVE",
"actionName": "系统自动通过",
"approverName": "系统",
"opinion": "—",
"submittedAt": "2026-08-27 14:00:00",
"finishedAt": "2026-08-27 14:00:01"
}
],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 降级响应
不存在符合条件的审批事实时返回成功空分页:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [],
"total": 0,
"page": 1,
"pageSize": 20
}
}
```
申请人姓名依赖不可用时,该记录仍正常返回,`applicantName` 为 `未知申请人`;不会回退为内部管理员 ID。
#### 错误响应
非法枚举、分页、排序或反向时间范围返回参数错误,例如:
```json
{
"code": 400,
"message": "from不能晚于to",
"success": false,
"data": null
}
```
无业务读取权限时返回拒绝结果,且不查询审批历史:
```json
{
"code": 403,
"message": "无权访问供应商数据",
"success": false,
"data": null
}
```
#### 业务边界
- 权限条件与变更记录接口相同:可信管理身份、`ADMIN`/`FINANCE`/`SUPER_ADMIN` 读角色和 `supplier:approval:read` 平台权限缺一不可。
- 只读取供应商主体审批,不包含收款账户审批或主体字段变更记录。
- 时间筛选基于提交时间;历史提交时间为空时使用该审批事实的创建时间。
- 申请人名称每页最多批量补全一次;User 服务异常、空响应或缺失用户时安全降级,整页不返回 500。
- 审批人只返回业务安全展示值,不暴露企微或外部审批系统标识。
- 接口只读,不推进审批状态,不触发审批回调,也不产生缓存、消息或配置副作用。
## 四、契约约束与正确调用方式
### 详情页接入映射
| 页面区域 | 正确接口 | 数据范围 |
|---|---|---|
| 变更记录 | `GET /admin/supplier/items/{supplierId}/change-records/page` | 仅主体变更 |
| 审批记录 | `GET /admin/supplier/items/{supplierId}/approval-history/page` | 仅主体审批 |
| 旧兼容混合列表 | `GET /admin/supplier/items/{supplierId}/approval-records/page` | 主体与账户变更,契约不变 |
### ✅ 正确 / ❌ 错误调用对照
| 场景 | 调用 / 结果 |
|---|---|
| ✅ 分别加载两个页签 | 两条新接口分别维护自己的 `page`、`pageSize`、筛选和 `total` |
| ✅ 查询完整时间区间 | 同时传 `from=2026-08-01 00:00:00` 与 `to=2026-08-31 23:59:59` |
| ✅ 兼容旧页面 | 继续调用旧 `approval-records/page`,无需因本次新增接口修改 |
| ❌ 在前端拆旧混合列表 | 分页后再过滤会得到错误总数,也不能生成审批流水 |
| ❌ 只传一侧时间 | 返回 `400`,不执行历史查询 |
| ❌ 把 `supplierId` 转为 Number | 可能丢失精度;ID 必须始终按 String 传输和比较 |
调用方必须同时检查 `code`、`message`、`success` 和 `data`,不能只用 HTTP 状态判断业务成功。
## 六、边界行为
- 未登录或登录态失效:统一结果业务码 `401`,不进入供应商查询。
- 已登录但角色或平台权限不满足:业务码 `403`,不读取历史。
- `supplierId<=0`、`page<1`、`pageSize` 不在 `1..100`、非法筛选或排序:业务码 `400`。
- 供应商不存在或已软删除:业务码 `395001`。
- `from`、`to` 必须同时传入,且 `from<=to`;时间边界包含起止时刻。
- 请求超过最后一页:返回原请求页码、空 `records` 和真实 `total`,不自动改页。
- 两个新接口的 `total` 分别统计自己的数据集,互不借用;账户变更和账户审批都不进入。
- 旧 `approval-records/page` 的路径、请求、响应及主体/账户混合语义保持兼容。
## 六.5、枚举 / 数据字典
### `operationType`(变更记录操作类型)
**所属字段**: `SupplierChangeRecordPageReqVO.operationType` / `SupplierChangeRecordRespVO.operationType` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `CREATE` | 创建 | 创建供应商主体 |
| `UPDATE` | 更新 | 更新主体业务字段 |
| `ENABLE` | 启用 | 恢复可用状态 |
| `DISABLE` | 停用 | 暂停或禁用合作 |
| `DELETE` | 删除 | 软删除业务事实 |
### `status`(变更后的供应商生命周期)
**所属字段**: `SupplierChangeRecordPageReqVO.status` | **类型**: `String`
| 值 | 中文展示 |
|---|---|
| `DRAFT` | 草稿 |
| `VETTING` | 注册审核中 |
| `ACTIVE` | 合作中 |
| `SUSPENDED` | 暂停合作 |
| `FROZEN` | 已冻结 |
| `BLACKLIST` | 黑名单 |
| `ARCHIVED` | 已归档 |
### `valueAvailability`(变更摘要可用性)
**所属字段**: `SupplierChangeRecordRespVO.valueAvailability` | **类型**: `String`
| 值 | 中文 | 调用方处理 |
|---|---|---|
| `FULL` | 完整业务摘要可用 | 正常展示 `oldValue` / `newValue` |
| `LEGACY_MASKED_UNRECOVERABLE` | 历史原值不可恢复 | 展示现有摘要,并标注其为历史脱敏值;不要提示用户重试 |
### `bizType`(主体审批业务类型)
**所属字段**: `SupplierApprovalHistoryPageReqVO.bizType` / `SupplierApprovalHistoryRespVO.bizType` | **类型**: `String`
| 值 | 中文展示 | 说明 |
|---|---|---|
| `PROFILE_CREATE` | 供应商建档审批 | 注册或建档审批 |
| `STATUS_CHANGE` | 供应商状态变更审批 | 生命周期状态变更审批 |
| 其他合法大写编码 | 其他供应商审批 | 为历史及后续业务保留兼容 |
### `approvalStatus`(主体审批状态)
**所属字段**: `SupplierApprovalHistoryPageReqVO.approvalStatus` / `SupplierApprovalHistoryRespVO.approvalStatus` | **类型**: `String`
| 值 | 中文展示 |
|---|---|
| `PENDING` | 审批中 |
| `APPROVED` | 已通过 |
| `REJECTED` | 已驳回 |
| `CANCELED` / `CANCELLED` | 已撤销 |
| `FAILED` | 失败 |
| 其他合法大写编码 | 未知状态 |
### `action`(主体审批动作或结果)
**所属字段**: `SupplierApprovalHistoryPageReqVO.action` / `SupplierApprovalHistoryRespVO.action` | **类型**: `String`
| 值 | 中文展示 |
|---|---|
| `SUBMIT` | 已提交 |
| `SYSTEM_AUTO_APPROVE` | 系统自动通过 |
| `APPLY_FAIL` | 结果应用失败 |
| `APPROVE` | 通过 |
| `REJECT` | 驳回 |
| `REVOKE` | 撤销 |
| `CANCEL` | 取消 |
| 其他合法大写编码 | 其他动作 |
## 七、不影响范围
- **仅新增**:供应商详情的两个管理端只读分页契约。
- **保持兼容**:旧 `approval-records/page` 继续返回主体与账户变更混合列表。
- **零影响**:供应商详情、创建、更新、提交、归档、暂停合作、拉黑、删除及收款账户管理接口。
- **零影响**:供应商写权限、数据范围、状态机、审批提交/结果应用、事务、锁、幂等、审计与软删除语义。
- **零影响**:数据库结构与历史数据;本次无 migration、数据回填或破坏性数据操作。
- **零影响**:Gateway 顶级路由、Nacos 配置、Redis、MQ 和跨服务写链路。
- 后端仓库未修改任何管理端前端源码;管理端接入状态保持 `pending`。
## 八、测试环境已验证
- 本地自动化:主功能定向测试 79 项零失败;补充空上下文兼容测试套件 51 项零失败;最终 `hl-resource-service` 全量 2158 项零失败、零错误(38 项条件跳过);`GatewayRouteAuditTest` 4 项零失败。
- 合并:主 PR #6519 合并提交 `3dc80ad69630127d91fa973a682051b2ef5c41d8`;验收发现旧兼容接口空上下文缺陷后,补充工单 #6532 / PR #6534 以最小修复合入,最新合并提交为 `aec1de2db2b4ed07d78877c1ccd4e532919bdaf3`。
- TEST 精确部署:Deploy Panel 任务 `9e69fd2c` 成功发布 `aec1de2db2b4ed07d78877c1ccd4e532919bdaf3`;构建退出码 0,任务期 10 次有效采样均至少 2 个运行进程、2 个健康启用 Nacos 实例,零不可用采样,部署窗日志无失败标记。
- 真实 Gateway:使用现有真实 `SUPER_ADMIN` 与同账号可切换的 `ADMIN` 身份,两条新接口的成功分页、字段白名单、独立总数、筛选、排序、边界参数和旧接口兼容均通过;`CUSTOMIZER` 返回 `403`,未认证返回 `401`。
- TEST 数据覆盖:目标供应商存在 18 条主体变更事实和 1 条主体审批事实;对应账户变更、账户审批均为 0,验证两个新接口没有跨域混页。审批样本为 `APPROVED` / `SYSTEM_AUTO_APPROVE`;环境没有外部审批人样本,未伪造业务数据。
- 身份说明:代码角色矩阵仍包含 `ADMIN`、`FINANCE`、`SUPER_ADMIN`。TEST 角色目录存在 `FINANCE`,但当前获批真实账号不能切换到该角色;按工单确认使用现有 `SUPER_ADMIN`(并覆盖 `ADMIN`)替代 FINANCE 实测,不创建、不修改、不伪造财务账号。
- 清理:验收全程只读,供应商数据指纹前后一致,业务测试数据创建数为 0;真实会话均已失效处理,无数据库、Redis、MQ 或临时配置需要清理。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6486 | #6436 | 冻结供应商授权完整值与历史摘要语义 | ✅ 有效,本次沿用 |
| #6519 | #6474 | 新增主体变更记录与主体审批流水独立分页 | ✅ 本次主功能 |
| #6534 | #6532 | 修复旧兼容接口投影空上下文时的空指针 | ✅ 有效,保障历史兼容 |
## 十、相关文档
- 关联 Issue:[#6474](https://git.1814.love:8443/wx/HL/issues/6474)
- 关联 PR:[#6519](https://git.1814.love:8443/wx/HL/pulls/6519)
- 补充缺陷 Issue:[#6532](https://git.1814.love:8443/wx/HL/issues/6532)
- 补充缺陷 PR:[#6534](https://git.1814.love:8443/wx/HL/pulls/6534)
- 管理端接入:将“变更记录”“审批记录”分别切换到两条新接口;前端引用待回填。
## 撤回
1. 管理端先停止请求两个新路径,并恢复使用旧 `approval-records/page`,避免代码回退窗口产生请求失败。
2. 从最新 `dev-v3` 创建独立回退分支,对主功能合并执行 `git revert -m 1 --no-edit 3dc80ad69630127d91fa973a682051b2ef5c41d8`,验证后经独立 PR 合入。
3. #6532 的空上下文修复可独立保留,它修复的是旧兼容接口且不依赖两个新路径。若明确要求连同该修复一起撤回,再按从新到旧顺序执行 `git revert -m 1 --no-edit aec1de2db2b4ed07d78877c1ccd4e532919bdaf3`;这样会重新引入旧兼容接口在特定记录上的 500 风险,不作为推荐方案。
4. 使用 Deploy Panel 两阶段客户端,仅滚动部署 `hl-resource-service`;本次无数据库、配置、Redis 或 MQ 恢复步骤,也无不可逆数据影响。
5. 撤回后经 Gateway 验证两个新路径不可用、旧兼容接口按选定回退范围正常,复测未认证和越权,并确认 Resource 双实例、Nacos、日志及零写入。
6. 同步发布本 Changelog 的撤回说明;不得仅改文件名表达状态。
## 关联 / 联系人
### 链接
- **Issue**: [#6474](https://git.1814.love:8443/wx/HL/issues/6474)
- **PR**: [#6519](https://git.1814.love:8443/wx/HL/pulls/6519)
- **Merge commit**: [`3dc80ad6`](https://git.1814.love:8443/wx/HL/commit/3dc80ad69630127d91fa973a682051b2ef5c41d8)
- **补充 PR**: [#6534](https://git.1814.love:8443/wx/HL/pulls/6534)
- **TEST 目标提交**: [`aec1de2d`](https://git.1814.love:8443/wx/HL/commit/aec1de2db2b4ed07d78877c1ccd4e532919bdaf3)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,347 @@
---
schema: "hl-changelog/v2"
ticket: "6476"
title: "供应商详情补齐地址备注并统一表单校验"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "6635a2ae"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6517 已合并 dev-v3(合并提交 a952a04c);hl-resource-service 已随 dev-v3 精确提交 b269e5fd 由 Deploy Panel 任务 938ca09e 发布 TEST。真实 TEST 身份已验证 address/remark 创建、详情、更新回显,创建/更新同口径校验、权限失败零写入及测试数据清理。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 供应商管理:详情补齐地址备注并统一表单校验
供应商详情接口现在完整返回主体的 `address` 和 `remark`,管理端可用同一份详情快照初始化查看页与编辑表单。创建、提交和更新原本已有的业务字段与校验未增加新的必填项;本工单用契约测试和真实 TEST 验收固定三条写链路的一致口径。
本次没有新增接口路径、权限点或业务错误码。
## 一、背景
创建和更新请求已经接受地址、备注等主体字段,但基础信息详情此前没有回传 `address`、`remark`,导致管理端打开编辑页时无法完整还原已保存表单。同时,创建、提交和更新分属不同请求模型,消费方需要一个明确、可验证的共同校验口径。
本次处理后:
- 详情响应补齐主体地址与内部备注。
- 创建、提交、更新对共同主体字段、类型、联系人、资质和合同继续执行同一业务规则。
- 更新只额外要求变更原因、主体并发版本,以及被修改子项的 ID/版本。
- #6436/#6499 已交付的授权完整值语义保持不变,不重新引入脱敏值或“留空表示保留旧敏感值”的旧约定。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应新增字段 | 根对象新增 `address`、`remark`,用于完整初始化查看与编辑表单 |
创建草稿、提交注册和更新资料的路径、请求字段及错误码没有结构性变化,因此不作为新增接口列入上表;其稳定调用规则在第四节集中说明。
## 三、接口详情
### 1. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
管理端进入供应商详情或编辑页时调用。响应是主体、类型、联系人、资质、合同及并发版本的完整快照;编辑页应直接使用其中的 `address`、`remark` 和 `updateTime`,不得用本地空值覆盖。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
#### 出参 `Result<SupplierBasicInfoRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID |
| `fullName` / `shortName` | String/null | 供应商全称与简称 |
| `tax_no` | String | 完整主体证件号;JSON 字段名保持 `tax_no` |
| `legalRepresentative` | String/null | 法定代表人姓名 |
| `legalRepresentativeIdNo` | String/null | 完整法人居民身份证号 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | String/null | 法人证件正反面永久文件地址 |
| `contactPhone` | String/null | 完整公司联系电话 |
| `establishDate` | String/null | 成立日期,格式 `yyyy-MM-dd` |
| `registeredCapital` / `businessScope` | String/null | 注册资本与经营范围 |
| `address` | String/null | 本次新增回显:注册地址或经营地址 |
| `staffScale` | String/null | 人员规模字典值 |
| `mainCooperation` | String | 主要合作内容 |
| `remark` | String/null | 本次新增回显:供应商主体内部备注 |
| `types` | Array | 类型完整集合;每项包含 `isPrimary` |
| `primaryTypeCode` / `primaryTypeName` | String/null | 当前主类型 |
| `contacts` / `qualifications` / `contracts` | Array | 联系人、资质和合同完整集合;现有项包含字符串 ID 与 `updateTime` |
| `status` | String | 当前供应商状态 |
| `updateTime` | String | 主体并发版本,格式 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"fullName": "示例旅行服务有限公司",
"shortName": "示例旅行",
"tax_no": "91350211M000100Y46",
"legalRepresentative": "示例法人",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdNoMask": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/id-front.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/id-back.jpg",
"contactPhone": "13800138000",
"contactPhoneMask": "13800138000",
"establishDate": "2020-01-01",
"registeredCapital": "100万元",
"businessScope": "境内旅游服务",
"address": "福建省厦门市示例路 1 号",
"staffScale": "LT50",
"mainCooperation": "酒店与景区资源合作",
"remark": "重点合作供应商",
"types": [{"typeCode": "HOTEL", "typeName": "酒店", "isPrimary": true}],
"primaryTypeCode": "HOTEL",
"primaryTypeName": "酒店",
"contacts": [],
"qualifications": [],
"contracts": [],
"status": "DRAFT",
"updateTime": "2026-08-27 15:30:00"
}
}
```
#### 空数据 / 降级响应
历史供应商未填写地址或备注时,字段明确返回 `null`;无类型、联系人、资质或合同时,相应集合返回空数组,不把缺失数据当成接口异常:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"address": null,
"remark": null,
"types": [],
"primaryTypeCode": null,
"primaryTypeName": null,
"contacts": [],
"qualifications": [],
"contracts": [],
"updateTime": "2026-08-27 15:30:00"
}
}
```
#### 错误响应
供应商不存在、已删除或超出数据范围时不返回空详情:
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 要求可信管理身份、`supplier:view` 平台权限和供应商数据范围;登录态不能代替业务权限。
- `address`、`remark` 属于响应的向后兼容增加;旧客户端忽略未知字段即可继续运行。
- `legalRepresentativeIdNoMask`、`contactPhoneMask` 等废弃兼容别名继续与完整值字段同值;新代码使用无 `Mask` 字段。
- Long ID 一律按字符串保存、比较和传输。
- 本接口只读,不修改主体版本、缓存或审批状态。
## 四、契约约束与正确调用方式
以下三个既有写接口没有新增路径或字段,但共同业务口径由本工单固定:
- 创建草稿:`POST /admin/supplier/items/add`
- 提交注册:`POST /admin/supplier/items/{supplierId}/submit`
- 更新资料:`PUT /admin/supplier/items/{supplierId}/update`
### 共同主体字段规则
| 字段 | 创建/提交 | 更新 | 统一约束 |
|---|---|---|---|
| `fullName` | 必填 | 可选 | 最长 500 字符 |
| `shortName` | 可选 | 可选 | 最长 300 字符 |
| `taxNo` | 必填 | 可选,且仅允许在状态规则内修改 | 6 至 64 位字母、数字或展示分隔符;身份证/统一社会信用代码还校验日期或校验位 |
| `legalRepresentative` | 可选 | 可选 | 最长 500 字符 |
| `legalRepresentativeIdNo` | 可选 | 可选 | 18 位合法居民身份证号;与正反面地址按完整三字段组合校验 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | 可选 | 可选 | 公网 HTTPS 永久文件地址,单项最长 1000 字符 |
| `contactPhone` | 可选 | 可选 | 7 至 20 位合法电话字符 |
| `establishDate` | 可选 | 可选 | `yyyy-MM-dd`,不得晚于当前日期 |
| `registeredCapital` | 可选 | 可选 | 最长 50 字符 |
| `businessScope` | 可选 | 可选 | 最长 500 字符 |
| `address` | 可选 | 可选 | 最长 500 字符;创建和更新同口径 |
| `staffScale` | 可选 | 可选 | `LT50`、`R50_200`、`R200_500`、`GT500` |
| `mainCooperation` | 必填 | 可选 | 创建/提交不能为空白;更新传值时按同一业务含义保存 |
| `licenseImageUrl` | 可选 | 可选 | 最长 500 字符,并与营业执照资质归一化 |
| `remark` | 可选 | 可选 | 供应商主体内部备注,不等同于审批意见或 `changeReason` |
集合上限与语义保持不变:`types` 最多 15 项;`contacts`、`qualifications`、`contracts` 各最多 100 项;`initialAccounts` 最多 1 项。非空类型必须来自生效字典且不得重复,非空联系人集合必须形成唯一默认联系人,资质和合同继续执行类型、日期、金额、归属和完整快照校验。
更新场景额外要求:
- `changeReason` 必填、非空白、最长 500 字符,并进入业务审计。
- `expectedUpdateTime` 必须等于详情最新 `updateTime`。
- 已有联系人、资质、合同随完整快照更新时,必须带回对应字符串 ID 和子项 `updateTime`。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload / 结果 |
|---|---|
| ✅ 更新地址和备注 | `{"address":"福建省厦门市示例路 2 号","remark":"地址已确认","changeReason":"更新地址和备注","expectedUpdateTime":"2026-08-27 15:30:00"}` |
| ✅ 只更新备注 | `{"remark":"新的内部备注","changeReason":"补充内部备注","expectedUpdateTime":"2026-08-27 15:30:00"}` |
| ❌ 地址超过 500 字符 | 创建、更新均返回 `400`,零写入 |
| ❌ 成立日期晚于当前日期 | 创建、更新均返回 `400`,零写入 |
| ❌ 更新缺少 `changeReason` | 返回 `400`,零写入 |
| ❌ 使用旧 `expectedUpdateTime` | 返回 `395014`,零写入;必须刷新详情后由用户确认 |
### 正确更新顺序
1. 先查询详情,保存字符串 ID、子项版本和根对象 `updateTime`。
2. 用户提交时只发送实际变更字段,并附非空 `changeReason`、最新 `expectedUpdateTime`。
3. 更新成功后采用响应里的新版本;需要完整表单时重新查询详情。
4. 遇到 `395014` 先刷新,不得自动用旧表单覆盖他人修改。
## 五、数据库行为
- 成功创建、提交或更新时,主体与本次携带的类型、联系人、资质、合同及业务审计按聚合事务一起成功。
- 地址和备注保存后,详情读取与变更审计使用同一业务值;`remark` 不会被提升为审批意见或变更原因。
- 请求校验、权限、状态、归属或并发版本失败时零写入,主体 `updateTime` 不变化。
- 本次没有数据库结构变更、Flyway migration 或历史数据回填;旧记录的地址/备注原值保持不变。
- 不新增 Redis、MQ、Feign 或跨服务副作用。
## 六、边界行为
- 未登录或 Token 失效:`401`,不把无认证响应当作正向验收。
- ADMIN 等无写权限角色调用创建、提交或更新:`395002`,零写入;已有详情仍按读取权限返回。
- 供应商不存在或已删除:`395001`。
- 并发版本过期:`395014`,调用方刷新详情后重提。
- 地址超过 500 字符、未来成立日期、必填字段缺失或组合不完整:`400`,零写入。
- 历史 `address`、`remark` 为空:详情返回 `null`,不报错、不猜测默认值。
- `ARCHIVED` 等不可修改状态继续由既有状态门禁拒绝更新。
- 业务失败可能仍使用 HTTP 200,调用方必须同时判断响应体 `code`、`success`、`data`。
## 六.5、枚举 / 数据字典
### `staffScale`(供应商人员规模稳定值)
**所属字段**: `SupplierDraftSaveReqVO.staffScale / SupplierUpdateReqVO.staffScale / SupplierBasicInfoRespVO.staffScale` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `LT50` | 50 人以下 | 小于 50 人 |
| `R50_200` | 50 至 200 人 | 50 至 200 人档 |
| `R200_500` | 200 至 500 人 | 200 至 500 人档 |
| `GT500` | 500 人以上 | 大于 500 人 |
`types[].typeCode`、`contacts[].contactRole`、`qualifications[].qualType` 继续从对应生效数据字典读取,不在客户端硬编码展示名。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| 详情根对象 `address` | 未返回,已保存值无法用于表单回填 | 返回保存的地址;未填写为 `null` |
| 详情根对象 `remark` | 未返回,已保存值无法用于表单回填 | 返回主体内部备注;未填写为 `null` |
| 创建/提交/更新请求字段 | 已存在 | 无新增字段、无新增必填项 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 打开编辑页 | 详情缺少地址和备注,表单初始化不完整 | 一次详情调用可完整初始化主体字段 |
| 共同字段校验 | 实现存在,但缺少跨创建/更新契约锁定 | 自动化和 TEST 验收固定创建/更新同口径 |
| 完整敏感值回显 | 按 #6436/#6499 返回授权完整值 | 保持不变 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。响应新增两个可空字段,旧客户端可忽略;请求无新增必填项。
- **前端是否必须同步上线**: 为闭合编辑体验需要接入 `address`、`remark`,但不构成旧客户端继续运行的硬门禁。
- **前端 workaround 清理点**: 删除编辑页对地址、备注的本地空值兜底,改为使用详情真实值。
- **QA 重点**: 新建后打开详情、更新后刷新详情、地址长度 500/501、今天/未来成立日期、ADMIN 越权、旧版本并发冲突。
## 七、不影响范围
- **仅影响**: 管理后台供应商详情/编辑表单的主体地址、内部备注回填,以及已有写接口校验口径的契约固定。
- **零影响**:
- Gateway 路由和认证级别。
- 供应商生命周期状态机、审批、收款账户、证明附件、资源关系及归档语义。
- 数据库结构、历史数据、Nacos 配置、Redis、MQ、Feign 和其他服务。
- 小程序、C 端、Web、H5、桌面端接口。
- #6436/#6499 的授权完整值和废弃 `*Mask` 兼容语义。
## 八、测试环境已验证
- 本地自动化:供应商定向测试 49 项通过;最新 `dev-v3` 上 `hl-resource-service` Reactor 全量 2,153 项,0 失败、0 错误、38 项条件跳过。
- 部署:Deploy Panel API 任务 `938ca09e` 终态 `success`;目标与实际提交均为 `b269e5fdca2e1d29e0336314a909885e9d1f9d16`,包含本工单合并提交 `a952a04c`。
- 采样:部署任务期 8 个有效采样;每个采样至少 2 个运行进程、2 个健康启用 Nacos 实例,零观测不可用采样。该证据只证明采样点,不代表采样间绝对连续。
- 真实 Gateway:SUPER_ADMIN 创建完整表单后,详情精确回显 `address`、`remark`、完整授权字段、主类型及联系人/资质/合同 ID 和版本;更新后 API、数据库主体与更新审计一致。
- 失败路径:创建/更新的地址超长均返回 `400`;创建/更新的未来成立日期均返回 `400`;ADMIN 更新返回 `395002`;所有失败均验证零写入、版本不变。
- 清理:临时供应商先经业务删除接口软删除,确认主体已删除且活动子项为 0;随后按本次唯一标记精确清理 8 行测试数据,17 张关联表回读,剩余供应商 0 行;测试会话已注销并验证失效。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6478 / #6483 / #6486 | #6436 | 供应商授权完整值、主类型及同日修正 | ✅ 有效,本次保持 |
| #6517 | #6476 | 详情补齐地址备注并固定表单校验契约 | ✅ 最新 |
## 十、相关文档
- 关联 Issue:[#6476](https://git.1814.love:8443/wx/HL/issues/6476)
- 关联 PR:[#6517](https://git.1814.love:8443/wx/HL/pulls/6517)
- 相关完整值契约:`changelogs-v2/2026-08/27_6436_供应商敏感字段完整回显与主类型-修改接口-管理后台.md`
- 管理端接入:读取详情 `address`、`remark`,更新时保留最新 `expectedUpdateTime`;前端引用待回填。
## 撤回
1. 从最新 `dev-v3` 创建独立回退分支,执行:
```bash
git revert -m 1 --no-edit a952a04cf80f24d6c50cbec4b992c82f8580beba
```
2. 运行供应商定向测试、`hl-resource-service` 全量测试和差异检查,经独立 PR 合入。
3. 使用同一 Deploy Panel 两阶段客户端,仅滚动部署 `hl-resource-service`,并绑定批准回退分支的精确提交。
4. 管理端停止依赖详情响应中的 `address`、`remark`,再撤回本 Changelog;既有请求字段和完整值契约保持不变。
5. 无数据库、配置、Redis 或 MQ 恢复步骤;已保存的地址和备注数据保留,不做破坏性回填或删除。
6. 撤回后经 Gateway 复测详情、创建、更新、未认证、越权、并发冲突和失败零写入,并确认 Resource 双实例与 Nacos 健康。
## 关联 / 联系人
### 链接
- **Issue**: [#6476](https://git.1814.love:8443/wx/HL/issues/6476)
- **PR**: [#6517](https://git.1814.love:8443/wx/HL/pulls/6517)
- **实现提交**: [`3cd07d27`](https://git.1814.love:8443/wx/HL/commit/3cd07d27dc876f17af1751ec7ef9de2ddbd71949)
- **Merge commit**: [`a952a04c`](https://git.1814.love:8443/wx/HL/commit/a952a04cf80f24d6c50cbec4b992c82f8580beba)
- **TEST 目标提交**: [`b269e5fd`](https://git.1814.love:8443/wx/HL/commit/b269e5fdca2e1d29e0336314a909885e9d1f9d16)
### 联系人
- **后端负责人**: @lc
- **管理端负责人**: @mmg(`frontend_status: pending`)
@@ -0,0 +1,308 @@
---
schema: "hl-changelog/v2"
ticket: "6518"
title: "供应商草稿创建即生成编号"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "d546912c"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6531 已合并 dev-v3(合并提交 66aed70a);hl-resource-service 已由 Deploy Panel 任务 e977bf7b 精确发布该提交。真实 TEST 身份完成 47 项 Gateway 断言,验证创建即返回 SUP{supplierId}、列表/详情/更新稳定回显、权限失败零写入、审计事实和业务清理。管理端需停止把新建草稿 supplierNo 当作空值或等待审批后再取。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 🔧 供应商管理:草稿创建即生成编号
`POST /admin/supplier/items/add` 成功后,响应中的 `supplierNo` 现在立即为非空字符串,格式固定为 `SUP{supplierId}`。该编号与本次创建的供应商绑定,后续列表、详情、更新、提交及审批流程均保持同一值。
请求结构、权限点、状态机和业务错误码没有新增或删除。
## 一、背景
此前供应商编号在审批通过时才补齐,因此新建草稿阶段的创建响应、列表和详情可能返回 `supplierNo: null`。管理端若需要展示或引用编号,只能等待审批或使用临时占位。
本次发布后:
- 新建草稿成功即取得正式编号,不再存在“先创建、后编号”的等待窗口。
- 编号格式为字符串 `SUP{supplierId}`;例如 `supplierId="2094000000000000001"` 对应 `supplierNo="SUP2094000000000000001"`。
- 编号在更新、提交、审批和状态变化中不重算、不覆盖。
- 草稿删除后原编号不再分配给其他供应商。
- 发布前已经存在且编号为空的历史记录仍可能返回 `null`;其审批通过时继续兼容补齐。调用方不能自行推导或写入编号。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 响应行为修改 | 成功响应的 `supplierNo` 从草稿期可空改为立即返回 `SUP{supplierId}` |
列表、详情、更新和审批相关响应中的 `supplierNo` 字段结构未变化;对于本次发布后创建的供应商,它们会稳定返回创建响应中的同一编号。
## 三、接口详情
### 1. 创建供应商注册草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO` → `Result<SupplierWriteRespVO>`
#### 使用场景
管理端首次保存供应商注册表单。创建成功后可立即展示、复制或继续传递后端返回的正式 `supplierNo`,无需等待提交审批。
#### 入参
本次没有新增请求字段,`supplierNo` 也不允许由客户端提交。
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `fullName` | Body | String | 是 | 非空,最长 500 | 供应商法定全称 |
| `shortName` | Body | String/null | 否 | 最长 300 | 供应商简称 |
| `taxNo` | Body | String | 是 | 6 至 64 位字母、数字或展示分隔符 | 主体证件号 |
| `types` | Body | Array/null | 否 | 最多 15 项;非空时类型值有效且不得重复 | 供应商类型完整集合 |
| `legalRepresentative` | Body | String/null | 否 | 最长 500 | 法定代表人 |
| `legalRepresentativeIdNo` | Body | String/null | 否 | 为空或合法 18 位居民身份证号 | 法人证件号 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | Body | String/null | 否 | 单项最长 1000 | 法人证件正反面永久地址 |
| `contactPhone` | Body | String/null | 否 | 7 至 20 位合法电话字符 | 公司联系电话 |
| `establishDate` | Body | String/null | 否 | `yyyy-MM-dd`,不得晚于当天 | 成立日期 |
| `registeredCapital` | Body | String/null | 否 | 最长 50 | 注册资本文本 |
| `businessScope` / `address` | Body | String/null | 否 | 单项最长 500 | 经营范围与地址 |
| `staffScale` | Body | String/null | 否 | `LT50`、`R50_200`、`R200_500`、`GT500` | 人员规模 |
| `mainCooperation` | Body | String | 是 | 非空 | 主要合作内容 |
| `licenseImageUrl` | Body | String/null | 否 | 最长 500 | 营业执照影像快捷字段 |
| `remark` | Body | String/null | 否 | - | 供应商内部备注 |
| `contacts` | Body | Array/null | 否 | 最多 100 项 | 联系人完整集合;非空时继续执行角色和唯一默认联系人规则 |
| `qualifications` | Body | Array/null | 否 | 最多 100 项 | 资质完整集合;继续执行类型、证号和有效期规则 |
| `contracts` | Body | Array/null | 否 | 最多 100 项 | 合同完整集合;继续执行日期、金额和状态规则 |
| `initialAccounts` | Body | Array/null | 否 | 最多 1 项 | 初始收款账户集合 |
| `duplicateConfirmToken` | Body | String/null | 否 | 最长 256,一次性使用 | 存在近似主体时按后端返回值二次确认 |
| `expectedUpdateTime` | Body | String/null | 否 | 创建草稿时不传 | 仅已有草稿提交审批时使用 |
#### 出参 `Result<SupplierWriteRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 新建供应商 ID;必须按字符串保存和传输 |
| `supplierNo` | String | 本次行为变化:创建成功立即返回 `SUP{supplierId}`,非空且全生命周期稳定 |
| `status` | String | 新建草稿固定为 `DRAFT` |
| `onboardingStage` | String | 新建草稿固定为 `PROFILE_DRAFT` |
| `initialAccounts` | Array | 初始收款账户摘要;未提交时通常为空数组 |
| `updateTime` | String | 当前并发版本,格式 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
POST /admin/supplier/items/add
Authorization: Bearer <admin-token>
Content-Type: application/json
```
```json
{
"fullName": "示例草原旅行服务有限公司",
"shortName": "示例草原旅行",
"taxNo": "91350211M000100Y46",
"mainCooperation": "酒店与景区资源合作",
"types": [],
"contacts": [],
"qualifications": [],
"contracts": [],
"initialAccounts": []
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2094000000000000001",
"supplierNo": "SUP2094000000000000001",
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [],
"updateTime": "2026-08-27 16:24:30"
}
}
```
#### 空数据 / 降级响应
创建接口没有“成功但 data 为空”的降级分支:创建成功必须返回完整 `SupplierWriteRespVO`,失败则返回明确业务错误。编号的历史空数据兼容只出现在发布前遗留记录的列表或详情读取中;这类尚未补齐的记录仍可能出现:
```json
{
"supplierId": "2089000000000000001",
"supplierNo": null,
"status": "DRAFT"
}
```
管理端可为这种历史空值显示 `—`,但不得本地生成或回写编号。
#### 错误响应
请求缺少必填字段或字段不合法时,业务失败且不创建草稿:
```json
{
"code": 400,
"message": "统一社会信用代码不能为空",
"success": false,
"data": null
}
```
真实身份没有 `supplier:create` 写权限时:
```json
{
"code": 395002,
"message": "无供应商写入权限",
"success": false,
"data": null
}
```
#### 业务边界
- 外部请求必须经过 Gateway;未认证返回业务码 `401`,不能把该负向结果当作正向验收。
- 写操作仅允许既有授权角色并再次校验 `supplier:create`;普通 `ADMIN` 被拒绝且零写入。
- `supplierNo` 只在整个创建成功后返回;任何字段校验、权限、重复主体确认或持久化失败都不会留下可见的半成品编号。
- 创建成功后,列表、详情和更新响应必须返回同一 `supplierNo`。
- 客户端只消费后端响应,不能提交、覆盖、重算、截断或把编号转成数字。
- 业务失败可能仍使用 HTTP 200,必须同时判断响应体 `code`、`success` 和 `data`。
## 四、契约约束与正确调用方式
### 正确消费顺序
1. 提交创建请求并确认 `code=200`、`success=true`。
2. 将 `data.supplierId`、`data.supplierNo`、`data.updateTime` 全部按字符串保存。
3. 页面立即展示 `data.supplierNo`;刷新列表或详情时核对同一字段,不再等待审批。
4. 后续更新继续传最新 `expectedUpdateTime`,但不得把 `supplierNo` 放进请求体。
### ✅ 正确 / ❌ 错误行为对照
| 场景 | 正确行为 |
|---|---|
| ✅ 新草稿创建成功 | 直接显示响应 `supplierNo`,例如 `SUP2094000000000000001` |
| ✅ 列表或详情刷新 | 使用接口返回的同一编号,不做本地拼接 |
| ✅ 历史草稿仍为空 | 显示 `—` 并等待后端兼容补齐,不写回猜测值 |
| ❌ 本地生成编号 | 禁止用时间、计数器或客户端缓存生成 |
| ❌ 把编号当数字 | 禁止移除 `SUP`、转数值或做算术运算 |
| ❌ 删除后复用 | 禁止把已删除供应商的旧编号分配给新供应商 |
## 五、数据库行为
- 创建成功时,供应商草稿、正式编号、可选子项和创建审计作为一个业务整体生效;响应中的编号与后续读取一致。
- 创建失败时,供应商和编号都不产生可见结果,不返回部分成功。
- 更新、提交或审批不改变已生成编号。
- 草稿删除后不再出现在正常列表和详情中,但其编号不会重新分配。
- 本次不要求调用方执行数据迁移,也不改变现有请求字段。
## 六、边界行为
- 未认证:业务码 `401`,零写入。
- 无写权限:业务码 `395002`,零写入。
- 必填字段缺失、格式或组合不合法:业务码 `400`,零写入。
- 发现近似主体且未完成二次确认:业务码 `395007`,按响应中的一次性确认信息继续既有流程。
- 发布后新草稿:`supplierNo` 必为非空 `SUP{supplierId}`。
- 发布前历史空编号:读取时仍允许 `null`;审批通过后按兼容规则补齐。
- 更新、提交、审批、暂停、拉黑等后续操作:编号保持不变。
- 软删除后再创建:新供应商获得新的 `supplierId` 和新的 `supplierNo`,不复用旧编号。
## 六.5、枚举 / 数据字典
本次没有新增枚举或数据字典。`status`、`onboardingStage`、供应商类型、联系人角色、资质类型等继续沿用现有取值;编号格式不是字典值。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| 创建响应 `data.supplierNo` | 新草稿通常为 `null`,审批通过后才补齐 | 创建成功立即返回非空 `SUP{supplierId}` |
| `supplierId`、`status`、`onboardingStage`、`initialAccounts`、`updateTime` | 已存在 | 字段和类型不变 |
| 创建请求 | 不接受 `supplierNo` | 仍不接受 `supplierNo`,请求无新增字段 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 新建后展示编号 | 需要空值占位或等待审批 | 创建成功即可展示正式编号 |
| 列表、详情、更新回显 | 草稿期可能为空 | 对发布后新建草稿稳定返回创建时编号 |
| 提交与审批 | 审批通过时生成编号 | 保留创建时编号;只为历史空值兼容补齐 |
| 删除后再次创建 | 无明确前端口径 | 新供应商取得新编号,旧编号不复用 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。字段已存在,只把发布后新草稿的值从 `null` 收紧为稳定非空字符串。
- **前端是否必须同步上线**: 需要适配体验。旧客户端仍可运行,但会继续把已经可用的编号显示为空或等待审批。
- **前端 workaround 清理点**: 删除“草稿编号为空”“审批通过后再刷新编号”的新建流程兜底,创建成功后直接使用 `data.supplierNo`。
- **历史兼容**: 仍保留对发布前空编号记录的 `null` 展示兼容,前端不要把所有历史数据强断言为非空。
- **QA 重点**: 创建响应、立即列表/详情、更新后详情、未认证、ADMIN 越权、非法请求零写入、软删除后新编号不复用。
## 七、不影响范围
- **仅影响**: 管理后台供应商草稿创建成功后的编号生成时点和后续稳定回显。
- **零影响**:
- 创建请求字段、供应商类型可选语义、联系人/资质/合同/初始账户规则。
- Gateway 路由、认证方式、权限码和数据范围。
- 供应商生命周期状态机、审批级数、暂停、拉黑、归档和资源关系行为。
- 小程序、C 端、Web、H5、桌面端接口。
- 其他服务接口、缓存和消息契约。
## 八、测试环境已验证
- 本地自动化:供应商编号、审批兼容、完整回显、事务边界、权限和迁移契约定向测试 61 项通过;`hl-resource-service` 全量 2,157 项零失败、零错误,38 项仓库既有条件跳过;Gateway 路由审计 4 项通过。
- 部署:Deploy Panel API 任务 `e977bf7b` 终态 `success`;目标与实际提交均为 `66aed70a2c9919046b9e4362b9db3219858ec6e1`。
- 可用性采样:任务期 6 个有效采样均至少有 2 个运行进程、2 个健康启用实例,零观测不可用采样;该证据只证明采样点,不代表采样间绝对连续。
- 真实 Gateway:SUPER_ADMIN 创建草稿后,响应立即满足 `supplierNo=SUP{supplierId}`;列表、详情、更新响应保持同一编号;独立变更记录同时存在 CREATE/UPDATE 事实。
- 失败路径:未认证请求被 Gateway 拒绝;真实 ADMIN 创建返回 `395002`;非法税号返回 `400`;两类失败均验证零可见写入。TEST 未配置可用 FINANCE 身份,其角色等价性由服务端权限测试覆盖,未伪造身份。
- 删除与不复用:首个草稿经业务 API 软删除并确认列表、详情不可见;随后创建的新草稿获得不同编号。
- 清理:两条临时草稿均经业务 API 软删除并确认不可见,测试角色已恢复,会话已注销;未执行物理删除、缓存或 MQ 操作,仅保留正常的创建、更新、删除审计事实。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6274 | #6273 | 审批记录响应增加 `supplierNo` 字段 | ✅ 有效,本次收紧新草稿取值时点 |
| #6344 | #6343 | 新建和修改允许供应商类型为空 | ✅ 有效,请求语义不变 |
| #6517 | #6476 | 详情补齐地址备注并统一表单校验 | ✅ 有效,详情继续稳定回显 |
| #6531 | #6518 | 草稿创建即生成唯一供应商编号 | ✅ 最新 |
## 十、相关文档
- 关联 Issue:[#6518](https://git.1814.love:8443/wx/HL/issues/6518)
- 关联 PR:[#6531](https://git.1814.love:8443/wx/HL/pulls/6531)
- 历史编号响应契约:`changelogs-v2/2026-08/24_6273_供应商审批记录补充供应商编号-修改接口-管理后台.md`
- 管理端接入:创建成功立即读取并展示 `data.supplierNo`;历史空值仍保留 `—` 兼容,前端引用待回填。
## 撤回
1. 管理端先恢复“新草稿编号可能为空”的兼容展示,不再依赖创建响应立即有值。
2. 后端从最新 `dev-v3` 创建独立回退分支,revert #6518 合并提交 `66aed70a2c9919046b9e4362b9db3219858ec6e1`,验证后经独立 PR 合入并重新发布 `hl-resource-service`。
3. 无接口字段删除或请求结构回退;发布窗口内已生成的编号默认保留,不执行批量清空或复用。
4. 撤回后经 Gateway 复测创建响应可空、历史审批补齐、列表/详情/更新兼容、未认证与越权零写入。
5. 以新的 Changelog 提交标记本契约撤回,不改写已发布提交历史。
## 关联 / 联系人
### 链接
- **Issue**: [#6518](https://git.1814.love:8443/wx/HL/issues/6518)
- **PR**: [#6531](https://git.1814.love:8443/wx/HL/pulls/6531)
- **实现提交**: [`69665d72`](https://git.1814.love:8443/wx/HL/commit/69665d7249b5d269e56c36abbfeafbdbbbe72d45)
- **Merge commit**: [`66aed70a`](https://git.1814.love:8443/wx/HL/commit/66aed70a2c9919046b9e4362b9db3219858ec6e1)
### 联系人
- **后端负责人**: @lc
- **管理端负责人**: 待认领(`frontend_status: pending`)
@@ -0,0 +1,524 @@
---
schema: "hl-changelog/v2"
ticket: "6544"
title: "供应商合同独立登记"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: "pending"
target_release: "v2.1"
verified_at: "2026-08-29"
status_note: "PR #6559、#6591、#6606 已合并 dev-v3;hl-resource-service 已通过 Deploy Panel 任务 ca2fe4d7 部署最终提交 c579c87014f56452fea2fce5075b31ae6fd12a35。#6591 的写后回读、395052–395058、金额 scale 等值与草稿合同存在门禁均已进入 TEST。#6544 基线曾完成独立合同写链路实证;本次最终提交因运行时身份缺少 supplier:update,按用户确认冻结权限依赖的真实写复测,未配置权限、未发业务写请求,不把缺失权限记为通过。"
updated_at: "2026-08-29"
base: "dev-v3"
---
# 供应商管理:合同独立登记
供应商合同从注册聚合中拆出,改为独立登记、独立更新、独立软删除的维护边界。合同不再随供应商建档审批提交,审批通过与否都不影响合同登记;已登记合同在详情回显中继续随供应商返回。
## 一、背景
旧版把合同作为供应商注册聚合的一部分随审批走(#6397),审批候选 v2 起不再携带 contracts。为支持签约后可随时登记线下合同、按业务状态独立更新/作废,本次新增三个独立合同写接口:登记(add)、完整替换更新(update)、软删除(del)。合同不参与供应商生命周期状态机,不受建档/审批/归档状态推进约束;删除草稿供应商时若仍有未删除合同会被拒绝(需先独立删合同)。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 独立登记供应商合同 | POST | `/admin/supplier/items/{supplierId}/contracts/add` | 新增写接口 | 在既有供应商下登记一份线下合同,不进入建档审批 |
| 2 | 独立更新供应商合同 | PUT | `/admin/supplier/items/{supplierId}/contracts/{contractId}/update` | 新增写接口 | 带乐观版本整份替换合同全部业务字段 |
| 3 | 独立删除供应商合同 | DELETE | `/admin/supplier/items/{supplierId}/contracts/{contractId}/del` | 新增写接口 | 带乐观版本软删除,审计原因必填 |
## 三、接口详情
### 1. 独立登记供应商合同 `POST /admin/supplier/items/{supplierId}/contracts/add`
**VO**: `SupplierContractCreateReqVO` / `SupplierContractRespVO`
#### 使用场景
管理端在供应商详情「合同」区域新增一条线下合同。合同登记与供应商审批解耦,登记成功立即在详情合同列表随时间返回 `updateTime` 并发版本。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
| `contractName` | Body | String | 是 | 最长 500 字符 | 合同名称 |
| `contractNo` | Body | String | 否 | 最长 100 字符 | 合同编号;可空字符串 |
| `contractType` | Body | String | 是 | `FRAME` / `SINGLE_TRIP` / `PURCHASE` | 合同类型 |
| `signDate` | Body | String | 否 | `yyyy-MM-dd` | 合同签署日期 |
| `startDate` | Body | String | 是 | `yyyy-MM-dd` | 有效期开始日期 |
| `endDate` | Body | String | 是 | `yyyy-MM-dd`,不得早于 `startDate` | 有效期结束日期 |
| `amount` | Body | String | 否 | 非负,最多 10 位整数 2 位小数 | 合同金额;按字符串传输与比较 |
| `pricingMode` | Body | String | 否 | 最长 100 字符 | 计价方式说明 |
| `settleCycle` | Body | String | 否 | 最长 32 字符 | 结算周期 |
| `status` | Body | String | 是 | `DRAFT` / `ACTIVE` / `EXPIRED` | 可维护的合同状态 |
| `scanFileUrl` | Body | String | 否 | 最长 1000 字符 | 合同扫描件永久文件地址 |
| `remark` | Body | String | 否 | 最长 500 字符 | 合同备注 |
| `changeReason` | Body | String | 是 | 最长 500 字符 | 本次登记原因,写入供应商变更审计 |
#### 出参 `Result<SupplierContractRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `contractId` | String | 合同 ID(雪花,保证转字符串传输) |
| `contractName` | String | 合同名称 |
| `contractNo` | String/null | 合同编号 |
| `contractType` | String | 合同类型 |
| `signDate` | String/null | 合同签署日期 |
| `startDate` | String | 有效期开始日期 |
| `endDate` | String | 有效期结束日期 |
| `amount` | String | 合同金额(保证转字符串传输,保留两位小数场景由后端规范) |
| `pricingMode` | String/null | 计价方式 |
| `settleCycle` | String/null | 结算周期 |
| `status` | String | 合同状态;历史终止合同可能返回 `TERMINATED` |
| `scanFileUrl` | String/null | 合同扫描件地址 |
| `remark` | String/null | 合同备注 |
| `updateTime` | String | 当前并发版本,格式 `yyyy-MM-dd HH:mm:ss`;请原样用于下一次 update/del |
#### 请求示例
```http
POST /admin/supplier/items/2091381911266967553/contracts/add
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"contractName": "2026年度框架合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "线下签署后登记",
"changeReason": "线下签署后登记"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2093378327078154241",
"contractName": "2026年度框架合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "线下签署后登记",
"updateTime": "2026-08-29 00:41:00"
},
"traceId": null
}
```
#### 空数据 / 降级响应
登记成功后 `data` 恒为完整合同对象(不会返回 null)。金额为空时 `amount` 返回 null,前端按无金额展示,不要把 null 当 `"0.00"`:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2093378327078154241",
"contractName": "无金额合同",
"contractNo": null,
"contractType": "PURCHASE",
"signDate": null,
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": null,
"pricingMode": null,
"settleCycle": null,
"status": "DRAFT",
"scanFileUrl": null,
"remark": null,
"updateTime": "2026-08-29 00:41:00"
}
}
```
#### 错误响应
供应商不存在或已软删除:
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
已归档供应商拒绝合同写入(不查询、不锁行、不落审计):
```json
{
"code": 395031,
"message": "已归档供应商仅允许查看",
"success": false,
"data": null
}
```
正常 HTTP 请求先经过 Bean Validation;缺必填字段或长度超限仍返回 `400` 和中文字段文案。`395052`–`395056` 是 Service/事务层防旁路校验的冻结错误码,不替代 Controller 的 `400`:
```json
{
"code": 400,
"message": "合同名称不能为空",
"success": false,
"data": null
}
```
#### 业务边界
- 权限:可信管理员身份 + `FINANCE`/`SUPER_ADMIN` 写角色 + `supplier:update` 平台权限;`ADMIN` 或任一平台权限门禁失败均不产生数据库写。
- 合同登记只写 `supplier_contract` 单表与一条 `CREATE` 审计事实,不推进供应商主体版本,不进审批。
- 幂等:同一管理员 + 同一供应商 + 同一规范化载荷 5 秒窗口内重复提交只执行一次;锁键按供应商分区。
- 事务内先锁供应商行(FOR UPDATE),改合同必在事务中完成,失败整体回滚。
- `supplierId`、`contractId`、`amount` 必须按字符串处理,禁止转 JavaScript Number。
### 2. 独立更新供应商合同 `PUT /admin/supplier/items/{supplierId}/contracts/{contractId}/update`
**VO**: `SupplierContractUpdateReqVO`(继承 Create 全字段 + `expectedUpdateTime`) / `SupplierContractRespVO`
#### 使用场景
管理端对已登记合同做整份替换更新。请求体必须携带目标合同当前 `updateTime` 作为乐观版本围栏;服务端版本不匹配时整体拒绝且不产生任何写。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 Number |
| `contractId` | Path | String | 是 | 正整数 ID 字符串 | 目标合同;不得转为 Number |
| *(Create 全部字段)* | Body | String | 同新增 | 同新增 | 整份替换所需全字段 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 目标合同当前并发版本,来自详情或上次写响应 |
#### 出参 `Result<SupplierContractRespVO>`
与新增相同,`updateTime` 返回新版本(严格晚于旧版本),请用该值继续后续围栏。
| 字段 | 类型 | 说明 |
|---|---|---|
| `contractId` | String | 合同 ID(字符串) |
| (其余字段) | - | 同新增出参,`updateTime` 为新并发版本 |
#### 请求示例
```http
PUT /admin/supplier/items/2091381911266967553/contracts/2093378327078154241/update
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"contractName": "2026年度框架合同(变更)",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-10-01",
"endDate": "2027-08-31",
"amount": "1500.00",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "续约调整",
"changeReason": "续约调整",
"expectedUpdateTime": "2026-08-29 00:41:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2093378327078154241",
"contractName": "2026年度框架合同(变更)",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-10-01",
"endDate": "2027-08-31",
"amount": "1500.00",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "续约调整",
"updateTime": "2026-08-29 00:41:01"
}
}
```
#### 空数据 / 降级响应
更新接口无空数据场景。请求体携带的整份字段(含空值)会覆盖原值:`contractNo`、`signDate`、`pricingMode`、`settleCycle`、`scanFileUrl`、`remark` 传 null 或空串会被清空;`amount` 传 null 会清除金额。清空能力只在更新接口生效,新增不触发。
#### 错误响应
版本不匹配/已过期(前端收到后请重新拉详情取最新 `updateTime` 再重试):
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"success": false,
"data": null
}
```
合同不存在、已删除或不属于路径供应商(跨供应商同码隐藏):
```json
{
"code": 395051,
"message": "供应商合同不存在",
"success": false,
"data": null
}
```
请求与当前内容完全一致(无实际变化,不推进版本):
```json
{
"code": 400,
"message": "未检测到实际变化",
"success": false,
"data": null
}
```
#### 业务边界
- 同一供应商下同一合同的 update 与 del 共享锁键,串行化后版本围栏在事务内校验。
- `payloadChanged` 用值比较(金额忽略小数位 scale),纯金额 `1200.5` 与 `1200.50` 等价,不会误判为变更。
- 更新推进合同自身 `updateTime`(秒级严格递增),不推进供应商主体版本。
- 5 秒幂等窗口按双 ID + 载荷摘要去重;锁键串行化同行写。
- 审计记录完整前后快照差异(`contracts` 字段组)。
### 3. 独立删除供应商合同 `DELETE /admin/supplier/items/{supplierId}/contracts/{contractId}/del`
**VO**: `SupplierContractDeleteReqVO` / 无返回体
#### 使用场景
对已登记合同作废软删除。删除后 `deletedAt` 写入当前时间,查询与详情不再返回;与同一合同 update 共享锁键和版本围栏。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `contractId` | Path | String | 是 | 正整数 ID 字符串 | 目标合同 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 目标合同当前并发版本 |
| `changeReason` | Body | String | 是 | 最长 500 字符 | 本次删除原因,写入审计 |
#### 出参 `Result<Void>`
成功时 `data` 为 null:
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | 恒为 `200` 表示成功 |
| `data` | null | 删除接口无返回体 |
#### 请求示例
```http
DELETE /admin/supplier/items/2091381911266967553/contracts/2093378327078154241/del
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"expectedUpdateTime": "2026-08-29 00:41:01",
"changeReason": "线下合同作废"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": null
}
```
#### 空数据 / 降级响应
删除成功恒返回上述结构;无空数据分支。删除后合同立即从查询/详情消失,如需保留展示请走更新改状态而非删除。
#### 错误响应
版本不匹配:
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"success": false,
"data": null
}
```
合同不存在或已删除:
```json
{
"code": 395051,
"message": "供应商合同不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 删除是软删除,不物理清理业务合同数据;删除与完整删除审计同事务提交或回滚。
- 删除草稿供应商时若存在未删除合同,档案删除会被拒绝(提示先独立删合同,错误码见「六、边界行为」)。
- 已删除合同的 `expectedUpdateTime` 不再有效,误传原版本返回合同不存在。
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | 调用 / 结果 |
|---|---|
| ✅ 新增后立即保存返回值 | 保存 `updateTime` 用于后续 update/del 版本围栏 |
| ✅ 更新时整份提交 | 传全字段;想清空的字段显式传 null 或空串 |
| ✅ 金额按字符串 | `"amount": "1200.50"`,`contractId`/`supplierId` 恒为字符串 |
| ✅ 并发被拒后重试 | 重新 GET 详情取最新 `updateTime`,再带新版本重试 |
| ❌ 把 ID/金额转 Number | 可能丢精度;必须按字符串传输与比较 |
| ❌ 只传变更字段 | update 是整份替换语义;缺字段会被清空 |
| ❌ 依赖审批候选带合同 | 审批候选 v2 起不携带 `contracts`;合同一律走独立接口维护 |
### 关键提示(当前 TEST 构建)
- 已登记的合同通过详情接口随供应商返回的 `contracts` 数组回显,前端不必重复维护列表状态。
- **B1 已修复并部署**:登记(add)插入后按合同 ID 回读数据库持久化实体,响应与 CREATE 审计使用同一真实秒级 `updateTime`;该值可直接用于下一次 update/del 版本围栏。
## 五、数据库行为
- 写操作仅影响 `supplier_contract` 一行(新增 insert / 更新 update / 删除软删),以及 `supplier_change_log` 一条对应合同审计事实(`fieldName=contract`,前后完整快照差异)。
- 三个写接口均不修改 `supplier_main`(供应商主体版本不变),不进审批流,不写缓存、MQ 或跨服务数据。
- 更新与删除仅在版本围栏通过后产生写;版本不匹配时不产生任何数据库副作用。
- 软删除使用统一 `deleted_at` 逻辑删除,物理行保留;查询与详情自动过滤。
## 六、边界行为
- 未登录/登录失效:业务码 `401`;角色或平台权限不足:`403`,不查询不写入。
- `supplierId`/`contractId` 非法(非数字、超长、<=0):`400`;供应商不存在或已软删除:`395001`。
- 已归档供应商:`395031`(写入一律拒绝)。
- 未知或非生命周期状态供应商:`395005`(状态机门禁先行失败)。
- 版本不匹配:`395014`;请求无实际变化:`395057`。
- 删除草稿供应商仍有未删除合同:`395058`「已有合同登记,请先独立删除合同」。
- Controller 的 Bean Validation/绑定失败仍返回 `400`;Service/事务层防旁路校验使用 `395052`–`395056`,其中缺失合同并发版本文案为「预期更新时间不能为空」。
## 六.5、枚举 / 数据字典
### `contractType`(合同类型)
**所属字段**: `SupplierContractCreateReqVO.contractType` / `SupplierContractRespVO.contractType` | **类型**: `String`
| 值 | 说明 |
|---|---|
| `FRAME` | 框架合同 |
| `SINGLE_TRIP` | 单团单合同 |
| `PURCHASE` | 采购合同 |
### `status`(合同状态)
**所属字段**: `SupplierContractCreateReqVO.status` / `SupplierContractRespVO.status` | **类型**: `String`
| 值 | 说明 |
|---|---|
| `DRAFT` | 草稿 |
| `ACTIVE` | 生效中(可维护) |
| `EXPIRED` | 已过期(可维护) |
| `TERMINATED` | 已终止(仅历史回显旧值,写接口不允许设置) |
## 七、不影响范围
- **仅新增**:三个独立合同写接口;合同登记与审批、状态机、归档完全解耦。
- **保持兼容**:供应商建档/更新/提交/审批/归档/暂停/拉黑/删除接口契约不变;详情回显 `contracts` 数组不变。
- **零影响**:审批候选 v2 起不含 `contracts`(v1 候选中的 contracts 字段被兼容忽略);不阻断既有审批流。
- **零影响**:收款账户、资质、资源关联、历史/审批流水等供应商子域。
- **零影响**:Gateway 路由(沿用 `/admin/supplier/**`)、数据库结构(无迁移)、Redis、MQ、Feign 契约。
## 八、测试环境已验证
- 本地自动化:合同命令/事务/校验器定向测试(SupplierContractTransactionServiceTest、SupplierContractValidatorTest、SupplierContractServiceTest、SupplierAggregateWriterTest、SupplierErrorCodeContractTest)51 项零失败;每日审查修复后 `hl-resource-service` 全量 2181 项零失败、零错误(38 项条件跳过)。
- 主 PR #6559、审查修复 PR #6591 与中文版本文案补充 PR #6606 均已合入 `dev-v3`;最终 Resource 合并提交为 `d7e455492648b085445691ccde4a13198d4cd6c6`。
- TEST 部署:Deploy Panel 任务 `ca2fe4d7` 成功,预期/实际提交均为 `c579c87014f56452fea2fce5075b31ae6fd12a35`;双进程、Nacos 两实例、6 个任务期采样和两类日志门禁通过,零观测不可用采样。
- 最终自动化:合同定向 73 项、供应商真实 MySQL Testcontainers 398 项、Resource 全量 2181 项均为 0 失败/0 错误;全量保留 38 项既有条件跳过。
- Gateway 边界:#6544 基线写链路已有真实 TEST 实证;最终部署回读身份为 `SUPER_ADMIN`,但当前平台权限不含 `supplier:update`。用户明确冻结该权限依赖的写复测,本轮没有配置/绕过权限,也没有发合同业务写请求;权限与事务成功/拒绝路径以最终自动化证据补充,不宣称运行时缺失权限已通过。
- 数据清理:本轮最终部署和冻结验收未创建供应商、合同、缓存、MQ 或审批合成数据;TEST 身份已登出。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6559 | #6544 | 独立合同登记三写接口 + 边界拆分 | ✅ 本次主功能 |
| #6591 | #6604 | 合同模块每日审查修复 7 项(写后读回/错误码/VO 文档/金额比较) | ✅ 已部署 |
| #6606 | #6604 | 合同并发版本校验中文文案与全局错误码审计修复 | ✅ 已部署 |
| #6519 | #6474 | 供应商变更/审批分离(前置演进) | ✅ 不受影响 |
## 十、相关文档
- 关联 Issue:[#6544](https://git.1814.love:8443/wx/HL/issues/6544)
- 关联 PR:[#6559](https://git.1814.love:8443/wx/HL/pulls/6559) / [#6591](https://git.1814.love:8443/wx/HL/pulls/6591) / [#6606](https://git.1814.love:8443/wx/HL/pulls/6606)
- 管理端接入:详情页「合同」区改为直接调 add/update/del 三接口;提交审批的候选不再携带 contracts。
## 撤回
1. 管理端停止请求三个 contract 写路径,并清除详情页合同编辑入口。
2. 从最新 `dev-v3` 建独立回退分支,按依赖逆序回退 `d7e455492648b085445691ccde4a13198d4cd6c6`、`9eb9c96c09975a3ccf4966a257cc77bdf9f743b9` 与 `be0fe8125197b759459c975d211319e10cad150c`,验证后经独立 PR 合入。
3. 仅需回退写接口时可先保留回显逻辑,只移除 add/update/del 路由与前端入口。
4. 使用 Deploy Panel 两阶段客户端仅滚动部署 `hl-resource-service`;本次无数据库、配置、Redis 或 MQ 恢复步骤。
5. 撤回后经 Gateway 验证三个写路径不可用、详情 `contracts` 回显按选定回退范围正常,并确认零写入。
6. 同步发布本 Changelog 的撤回说明;不得仅改文件名表达状态。
## 关联 / 联系人
### 链接
- **Issue**: [#6544](https://git.1814.love:8443/wx/HL/issues/6544)
- **PR**: [#6559](https://git.1814.love:8443/wx/HL/pulls/6559) / [#6591](https://git.1814.love:8443/wx/HL/pulls/6591) / [#6606](https://git.1814.love:8443/wx/HL/pulls/6606)
- **Merge commit**: [`be0fe8125`](https://git.1814.love:8443/wx/HL/commit/be0fe8125197b759459c975d211319e10cad150c) / [`9eb9c96c0`](https://git.1814.love:8443/wx/HL/commit/9eb9c96c09975a3ccf4966a257cc77bdf9f743b9) / [`d7e455492`](https://git.1814.love:8443/wx/HL/commit/d7e455492648b085445691ccde4a13198d4cd6c6)
### 联系人
- **后端负责人**: @lc
+204 -7
查看文件
@@ -30,6 +30,7 @@ const REQUIRED_KEYS = [
'ticket',
'title',
'consumer',
'author',
'change_type',
'backend_status',
'gateway_status',
@@ -38,9 +39,31 @@ const REQUIRED_KEYS = [
'frontend_ref',
'target_release',
'verified_at',
'status_note',
'updated_at',
'base',
];
const HTTP_METHOD_PATTERN = '(?:GET|POST|PUT|PATCH|DELETE)';
const API_TEMPLATE_SECTION_PREFIXES = [
'二、变更接口清单',
'三、接口详情',
'四、契约约束与正确调用方式',
'六、边界行为',
'七、不影响范围',
'八、测试环境已验证',
'十、相关文档',
'关联 / 联系人',
];
const API_DETAIL_SUBSECTIONS = [
'使用场景',
'入参',
'出参',
'请求示例',
'响应示例',
'空数据 / 降级响应',
'错误响应',
'业务边界',
];
function ruleError(code, file, message) {
return { code, path: file, message };
@@ -68,6 +91,181 @@ function isIsoDateOrTime(value) {
&& Number.isFinite(Date.parse(value));
}
function sectionByPrefix(body, prefix, level = 2) {
const marker = `${'#'.repeat(level)} ${prefix}`;
const lines = String(body ?? '').replaceAll('\r\n', '\n').split('\n');
const start = lines.findIndex((line) => line.trim().startsWith(marker));
if (start < 0) {
return undefined;
}
const nextMarker = '#'.repeat(level);
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
const value = lines[index].trim();
if (value.startsWith(`${nextMarker} `) && !value.startsWith(`${nextMarker}#`)) {
end = index;
break;
}
}
return lines.slice(start + 1, end).join('\n');
}
function apiEndpointKey(method, endpointPath) {
return `${method.trim().toUpperCase()} ${endpointPath.trim()}`;
}
function validateApiTemplate(file, metadata, body) {
const errors = [];
const missingSections = API_TEMPLATE_SECTION_PREFIXES.filter(
(prefix) => sectionByPrefix(body, prefix) === undefined,
);
if (missingSections.length > 0) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
`接口类正文必须仿照 CHANGELOG_TEMPLATE.md,缺少章节: ${missingSections.join('、')}`,
));
}
const listSection = sectionByPrefix(body, '二、变更接口清单');
const detailSection = sectionByPrefix(body, '三、接口详情');
if (listSection === undefined || detailSection === undefined) {
return errors;
}
const requiredHeader = /^\|\s*#\s*\|\s*接口\s*\|\s*方法\s*\|\s*路径\s*\|\s*变更类型\s*\|\s*说明\s*\|\s*$/m;
if (!requiredHeader.test(listSection)) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'“二、变更接口清单”必须使用模板列: #、接口、方法、路径、变更类型、说明',
));
}
const listPattern = new RegExp(
'^\\|\\s*\\d+\\s*\\|\\s*[^|]+\\|\\s*('
+ HTTP_METHOD_PATTERN
+ ')\\s*\\|\\s*`([^`]+)`\\s*\\|\\s*[^|]+\\|\\s*[^|]+\\|\\s*$',
'gm',
);
const listed = [...listSection.matchAll(listPattern)]
.map((match) => apiEndpointKey(match[1], match[2]));
if (listed.length === 0) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'“二、变更接口清单”至少需要一条带 METHOD 和反引号路径的接口记录',
));
}
const detailPattern = new RegExp(
'^###\\s+\\d+\\.\\s+.+?\\s+`('
+ HTTP_METHOD_PATTERN
+ ')\\s+([^`]+)`\\s*$',
'gm',
);
const detailMatches = [...detailSection.matchAll(detailPattern)];
const detailed = detailMatches.map((match) => apiEndpointKey(match[1], match[2]));
if (detailMatches.length === 0) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'“三、接口详情”至少需要一个“### N. 接口名 `METHOD /path`”子节',
));
}
const listedSet = [...new Set(listed)].sort();
const detailedSet = [...new Set(detailed)].sort();
if (
listed.length !== listedSet.length
|| detailed.length !== detailedSet.length
|| listedSet.join('\n') !== detailedSet.join('\n')
) {
errors.push(ruleError(
'E_API_ENDPOINTS',
file,
'接口清单与逐接口详情的 METHOD/path 必须去重且一一对应',
));
}
for (let index = 0; index < detailMatches.length; index += 1) {
const match = detailMatches[index];
const next = detailMatches[index + 1];
const block = detailSection.slice(
match.index + match[0].length,
next ? next.index : detailSection.length,
);
const missing = API_DETAIL_SUBSECTIONS.filter(
(prefix) => sectionByPrefix(block, prefix, 4) === undefined,
);
const usage = sectionByPrefix(block, '使用场景', 4) ?? '';
const input = sectionByPrefix(block, '入参', 4) ?? '';
const output = sectionByPrefix(block, '出参', 4) ?? '';
const requestExample = sectionByPrefix(block, '请求示例', 4) ?? '';
const responseExample = sectionByPrefix(block, '响应示例', 4) ?? '';
const emptyResponse = sectionByPrefix(block, '空数据 / 降级响应', 4) ?? '';
const errorExample = sectionByPrefix(block, '错误响应', 4) ?? '';
const boundary = sectionByPrefix(block, '业务边界', 4) ?? '';
if (!/^\*\*VO\*\*:\s*`[^`]+`/m.test(block)) {
missing.push('VO 契约');
}
if (!usage.trim()) {
missing.push('使用场景说明');
}
if (!/^\|\s*字段\s*\|\s*位置\s*\|\s*类型\s*\|\s*必填\s*\|\s*约束\s*\|\s*说明\s*\|\s*$/m.test(input)) {
missing.push('入参字段表');
}
if (!/^\|\s*字段\s*\|\s*类型\s*\|\s*说明\s*\|\s*$/m.test(output)) {
missing.push('出参字段表');
}
if (!/```(?:json|http)\s*\n[\s\S]+?\n```/.test(requestExample)) {
missing.push('请求示例代码块');
}
if (!/```json\s*\n[\s\S]+?\n```/.test(responseExample)) {
missing.push('响应示例 JSON');
}
if (!emptyResponse.trim()) {
missing.push('空数据 / 降级说明');
}
if (!/```json\s*\n[\s\S]+?\n```/.test(errorExample)) {
missing.push('错误响应 JSON');
}
if (!/^\s*[-*]\s+\S/m.test(boundary)) {
missing.push('业务边界条目');
}
if (missing.length > 0) {
errors.push(ruleError(
'E_API_DETAIL',
file,
`${detailed[index]} 缺少逐接口自包含内容: ${[...new Set(missing)].join('、')}`,
));
}
}
const writeMethods = listed.some((entry) => /^(?:POST|PUT|PATCH|DELETE) /.test(entry));
if (writeMethods && sectionByPrefix(body, '五、数据库行为') === undefined) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'包含写接口时必须按模板提供“五、数据库行为”章节(只写外部可观察行为,不泄露表结构)',
));
}
if (
['修改接口', '删除接口'].includes(metadata.change_type)
&& (
sectionByPrefix(body, '六.6、修改前后对比') === undefined
|| sectionByPrefix(body, '六.7、影响评估') === undefined
)
) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'修改/删除接口必须提供“六.6、修改前后对比”和“六.7、影响评估”章节',
));
}
return errors;
}
export function parseFrontmatter(text) {
const value = String(text ?? '').replaceAll('\r\n', '\n');
if (!value.startsWith('---\n')) {
@@ -165,11 +363,14 @@ export function validateV2Document(file, text, { requireV2 = false } = {}) {
errors.push(ruleError('E_REQUIRED', file, `frontmatter 缺少 ${key}`));
}
}
for (const key of ['ticket', 'title', 'consumer', 'change_type', 'backend_status', 'gateway_status', 'frontend_status', 'updated_at', 'base']) {
for (const key of ['ticket', 'title', 'consumer', 'author', 'change_type', 'backend_status', 'gateway_status', 'frontend_status', 'updated_at', 'base']) {
if (!metadata[key]?.trim()) {
errors.push(ruleError('E_REQUIRED', file, `${key} 不能为空`));
}
}
if (metadata.author && !/^\S+\(GIT\)$/.test(metadata.author)) {
errors.push(ruleError('E_AUTHOR', file, 'author 必须使用“登录名(GIT)”格式'));
}
if (!CHANGE_TYPES.has(metadata.change_type)) {
errors.push(ruleError('E_CHANGE_TYPE', file, `change_type 非法: ${metadata.change_type || '(空)'}`));
}
@@ -213,13 +414,9 @@ export function validateV2Document(file, text, { requireV2 = false } = {}) {
if (/\{\{[^{}\n]+\}\}|\bTODO\b|待补充/i.test(body)) {
errors.push(ruleError('E_PLACEHOLDER', file, '正文仍有 TODO、待补充或模板占位符'));
}
// 接口类条目必须有接口清单章节与验证章节;修复/前端类条目结构自由,不强制
// 接口类条目必须逐项遵循根模板,保证前端不依赖 Swagger 或口头补充也能联调。
if (API_CHANGE_TYPES.has(metadata.change_type)) {
const hasApiSection = /^##[^\n]*(变更接口|变更清单|变更内容|接口详情|变更点|接口变化|行为变化)/m.test(body);
const hasEvidenceSection = /^##[^\n]*(验证|测试|复现|证据)/m.test(body);
if (!hasApiSection || !hasEvidenceSection) {
errors.push(ruleError('E_SECTIONS', file, '接口类正文缺少接口清单(变更接口/变更清单)或验证(验证证据/测试)章节'));
}
errors.push(...validateApiTemplate(file, metadata, body));
}
return errors;
}
+90 -2
查看文件
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
@@ -21,6 +21,7 @@ function metadata(overrides = {}) {
ticket: '5218',
title: '工作流治理',
consumer: 'admin',
author: 'lc(GIT)',
change_type: '修改接口',
backend_status: 'deployed',
gateway_status: 'verified',
@@ -41,7 +42,15 @@ function document(overrides = {}) {
const frontmatter = Object.entries(fields)
.map(([key, value]) => `${key}: "${value}"`)
.join('\n');
return `---\n${frontmatter}\n---\n\n# 工作流治理\n\n## 变更接口\n\n- 无业务接口变化。\n\n## 验证证据\n\n- 自动化测试通过。\n`;
return `---\n${frontmatter}\n---\n\n# 工作流治理\n\n## 二、变更接口清单\n\n| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |\n|---|---|---|---|---|---|\n| 1 | 保存配置 | POST | \`/admin/workflow/config\` | 修改请求 | 保存工作流配置 |\n\n## 三、接口详情\n\n### 1. 保存配置 \`POST /admin/workflow/config\`\n\n**VO**: \`WorkflowConfigReqVO / String\`\n\n#### 使用场景\n\n- 管理员保存工作流配置。\n\n#### 入参\n\n| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |\n|---|---|---|---|---|---|\n| name | Body | String | ✅ | 非空 | 配置名称 |\n\n#### 出参 \`Result<String>\`\n\n| 字段 | 类型 | 说明 |\n|---|---|---|\n| data | String | 配置 ID |\n\n#### 请求示例\n\n\`\`\`json\n{ "name": "审批流" }\n\`\`\`\n\n#### 响应示例\n\n\`\`\`json\n{ "code": 200, "message": "成功", "data": "1", "success": true }\n\`\`\`\n\n#### 空数据 / 降级响应\n\n成功时一定返回字符串 ID。\n\n#### 错误响应\n\n\`\`\`json\n{ "code": 400, "message": "name 不能为空", "data": null, "success": false }\n\`\`\`\n\n#### 业务边界\n\n- 业务失败也必须检查响应体 code。\n\n## 四、契约约束与正确调用方式\n\n- 请求体必须使用 JSON。\n\n## 五、数据库行为\n\n- 成功请求保存一份配置,失败请求不产生业务写入。\n\n## 六、边界行为\n\n- 未登录返回 401。\n\n## 六.6、修改前后对比\n\n| 字段 | 改前 | 改后 |\n|---|---|---|\n| name | 可为空 | 必填 |\n\n## 六.7、影响评估\n\n- **是否破坏向后兼容**: 否\n- **前端是否必须同步上线**: 是\n- **前端 workaround 清理点**: 无\n\n## 七、不影响范围\n\n- 查询接口不变。\n\n## 八、测试环境已验证\n\n- POST /admin/workflow/config → 200 ✓\n\n## 十、相关文档\n\n- Issue: #5218\n\n## 关联 / 联系人\n\n- **后端负责人**: @lc\n`;
}
function incompleteApiDocument() {
const fields = metadata();
const frontmatter = Object.entries(fields)
.map(([key, value]) => `${key}: "${value}"`)
.join('\n');
return `---\n${frontmatter}\n---\n\n# 工作流治理\n\n## 变更接口\n\n- POST /admin/workflow/config。\n\n## 验证证据\n\n- 自动化测试通过。\n`;
}
test('parses quoted flat YAML frontmatter', () => {
@@ -50,10 +59,89 @@ test('parses quoted flat YAML frontmatter', () => {
assert.equal(parsed.metadata.frontend_status, 'pending');
});
test('CHANGELOG_TEMPLATE.md contains every section enforced for API handoffs', () => {
const template = readFileSync(
new URL('../CHANGELOG_TEMPLATE.md', import.meta.url),
'utf8',
);
for (const value of [
'author: "{推送者登录名}(GIT)"',
'## 二、变更接口清单',
'## 三、接口详情',
'#### 使用场景',
'#### 入参',
'#### 出参',
'#### 请求示例',
'#### 响应示例',
'#### 空数据 / 降级响应',
'#### 错误响应',
'#### 业务边界',
'## 四、契约约束与正确调用方式',
'## 五、数据库行为',
'## 六、边界行为',
'## 六.6、修改前后对比',
'## 六.7、影响评估',
'## 七、不影响范围',
'## 八、测试环境已验证',
'## 十、相关文档',
'## 关联 / 联系人',
]) {
assert.ok(template.includes(value), `template missing ${value}`);
}
});
test('accepts a complete v2 handoff with pending frontend consumption', () => {
assert.deepEqual(validateV2Document(FILE, document(), { requireV2: true }), []);
});
test('rejects an API handoff that does not follow CHANGELOG_TEMPLATE.md', () => {
const errors = validateV2Document(FILE, incompleteApiDocument(), { requireV2: true });
assert.ok(errors.some(({ code }) => code === 'E_API_TEMPLATE'));
});
test('rejects an endpoint detail without a required self-contained contract block', () => {
const value = document().replace('#### 错误响应', '#### 异常说明');
const errors = validateV2Document(FILE, value, { requireV2: true });
assert.ok(errors.some(({ code }) => code === 'E_API_DETAIL'));
});
test('requires a use case and explicit empty/degraded behavior for every endpoint', () => {
const value = document()
.replace('#### 使用场景', '#### 调用时机')
.replace('#### 空数据 / 降级响应', '#### 无数据');
const errors = validateV2Document(FILE, value, { requireV2: true });
assert.ok(errors.some(({ code, message }) => (
code === 'E_API_DETAIL'
&& message.includes('使用场景')
&& message.includes('空数据 / 降级响应')
)));
});
test('rejects a mismatch between the API list and endpoint detail headings', () => {
const value = document().replace(
'### 1. 保存配置 `POST /admin/workflow/config`',
'### 1. 保存配置 `POST /admin/workflow/other`',
);
const errors = validateV2Document(FILE, value, { requireV2: true });
assert.ok(errors.some(({ code }) => code === 'E_API_ENDPOINTS'));
});
test('requires the template author format and write-operation database section', () => {
const invalidAuthor = validateV2Document(
FILE,
document({ author: 'lc' }),
{ requireV2: true },
);
assert.ok(invalidAuthor.some(({ code }) => code === 'E_AUTHOR'));
const withoutDatabaseBehavior = document().replace(
'## 五、数据库行为',
'## 五、持久化说明',
);
const errors = validateV2Document(FILE, withoutDatabaseBehavior, { requireV2: true });
assert.ok(errors.some(({ code }) => code === 'E_API_TEMPLATE'));
});
test('rejects backend and gateway pending at publication', () => {
const errors = validateV2Document(
FILE,