比较提交

...
作者 SHA1 备注 提交日期
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
共修改 14 个文件,包含 2388 行新增和 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,849 @@
---
schema: "hl-changelog/v2"
ticket: "6350"
title: "行政区划主数据与三级联动"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "主工单 #6350 及补充工单 #6384、#6389、#6395、#6399、#6409 的六个 PR 均已合并 dev-v3。精确提交 b62054035a139e3c689179ef75e2d052d08dde52 已部署 TEST;真实管理员经 Gateway 完成未认证、读取、失败零写入、创建、CAS 更新、数据库和 Redis 一致性验收。#6422 已按 CHANGELOG_TEMPLATE.md 补齐四个接口的前端联调契约。"
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
>
> **日期**: 2026-08-26
>
> **影响范围**: 管理后台行政区划维护、省/市/县三级选择器和历史值父链回显
---
## ⚠️ 关键变化
- 本次补文档不改变 TEST 上已经部署的接口行为;它把原 Changelog 中分散的说明整理为可直接联调的完整契约。
- 前端不得再把行政区划 ID 当 JavaScript number:响应中的所有行政区划 ID 均为 JSON string,缺失层级保持真正的 `null`。
- 三级联动不允许通过行政区编码截位推断父子关系;必须逐级调用 `children`,编辑回显先调用 `path`。
- 业务失败可能仍使用 HTTP 200,调用方必须同时判断响应体 `code`、`success`、`message` 和 `data`。
---
## 一、背景
管理后台需要一套统一的行政区划主数据能力,同时支持:
- 省、市、县三级选择器逐级加载;
- 编辑历史业务数据时,从任意节点恢复完整父链;
- 具有专用权限的管理员创建自定义行政区或导航聚合节点;
- 使用 `rowVersion` 防止两位管理员并发更新时互相覆盖。
接口当前只开放省、市、县三级写入,层级代码依次为 `PROVINCE`、`PREFECTURE`、`COUNTY`。接口返回的示例 ID 仅用于展示字符串类型,前端不得硬编码。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 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=2090000000000000001&selectedId=2090000000000000035 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": "2090000000000000001",
"items": [
{
"id": "2090000000000000035",
"parentId": "2090000000000000001",
"codeStandard": "GB/T 2260",
"regionCode": "110100",
"levelCode": "PREFECTURE",
"depth": 2,
"name": "北京市",
"shortName": null,
"nodeKind": "ADMINISTRATIVE",
"navigable": true,
"selectable": true,
"hasChildren": true,
"currentlyEffective": true,
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 100,
"rowVersion": 0
}
],
"defaultId": "2090000000000000035"
},
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
#### 空数据 / 降级响应
父节点存在但没有当前有效直接子节点时,返回成功空数组:
```json
{
"code": 200,
"message": "成功",
"data": {
"parentId": "2090000000000000035",
"items": [],
"defaultId": null
},
"success": true
}
```
#### 错误响应
父节点不存在或已删除:
```json
{
"code": 210801,
"message": "行政区划节点不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 只返回 `status=ACTIVE` 且当前有效的直接子节点,停用或历史节点不会混入当前选择项。
- `selectedId` 不在本次 `items` 中时不会报错,而是稳定回退到排序后的第一项;空列表回退为 `null`。
- 排序固定为 `sortOrder`、`regionCode`、`id`,前端不得另用行政区编码推断顺序或父子关系。
- `navigable` 控制能否继续向下加载,`selectable` 控制能否作为最终值;两者不能相互替代。
- 未登录返回业务码 `401`;`parentId=0` 等非正整数参数返回业务码 `400`。
---
### 2. 父链回显 `GET /admin/region/{id}/path`
**VO**: `AdministrativeRegionPathVO / AdministrativeRegionItemVO`
#### 使用场景
编辑已有业务数据时,后端只保存了一个最终行政区 ID。前端先调用本接口得到完整省/市/县父链,再按父链逐层调用 `children` 加载每一级可选项。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `Authorization` | Header | String | ✅ | `Bearer <token>` | 真实管理端登录令牌 |
| `id` | Path | String | ✅ | 十进制正整数 | 要回显的省、市或县节点 ID |
无请求体。
#### 出参 `Result<AdministrativeRegionPathVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | `200` 表示成功;业务失败读取具体业务码 |
| `message` | String | 响应说明 |
| `success` | Boolean | 仅当 `code=200` 时为 `true` |
| `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/2090000000000000376/path HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员令牌>
Accept: application/json
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"selectedId": "2090000000000000376",
"provinceId": "2090000000000000001",
"prefectureId": "2090000000000000035",
"countyId": "2090000000000000376",
"path": [
{
"id": "2090000000000000001",
"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": 100,
"rowVersion": 0
},
{
"id": "2090000000000000035",
"parentId": "2090000000000000001",
"codeStandard": "GB/T 2260",
"regionCode": "110100",
"levelCode": "PREFECTURE",
"depth": 2,
"name": "北京市",
"shortName": null,
"nodeKind": "ADMINISTRATIVE",
"navigable": true,
"selectable": true,
"hasChildren": true,
"currentlyEffective": true,
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 100,
"rowVersion": 0
},
{
"id": "2090000000000000376",
"parentId": "2090000000000000035",
"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": 100,
"rowVersion": 0
}
]
},
"success": true
}
```
#### 空数据 / 降级响应
本接口不返回“成功但空父链”。目标不存在、父链不完整或超过安全深度时使用错误响应,不返回部分路径。
```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"`。
- 历史停用节点允许回显,节点自身 `currentlyEffective=false`;前端只能展示历史值,不能把它重新加入当前可选列表。
- 父链异常时整体失败,不返回可被误用的部分路径。
- 未登录返回业务码 `401`;不存在节点返回 `210801`。
---
### 3. 创建行政区划节点 `POST /admin/region`
**VO**: `AdministrativeRegionCreateReqVO / String`
#### 使用场景
具有 `system:region:create` 权限的管理员创建自定义业务行政区,或创建只用于层级导航、不可作为最终业务值的聚合节点。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `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` |
| `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": "2090000000000000035",
"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",
"success": true
}
```
#### 空数据 / 降级响应
本接口成功时一定返回字符串 ID,不存在 `data=null` 的成功语义。失败时不创建节点。
#### 错误响应
层级代码与父链派生深度不一致:
```json
{
"code": 210813,
"message": "行政区划层级代码与父链深度不一致",
"data": null,
"success": false
}
```
#### 业务边界
- 当前最大写入深度为 3;省、市、县层级必须分别使用 `PROVINCE`、`PREFECTURE`、`COUNTY`。
- 父节点必须存在、可导航、有效期完整覆盖子节点,并且不会让写入深度超过三级。
- `AGGREGATION` 必须同时满足 `codeStandard=HL_GROUP`、`navigable=true`、`selectable=false`;它只能导航,不能作为最终业务值。
- `ADMINISTRATIVE` 不得占用 `HL_GROUP`;旧命名空间 `HL_INTERNAL` 不允许产生新写入。
- 同一 `codeStandard + regionCode` 的版本有效期不得重叠;有效期采用左闭右开 `[validFrom, validTo)`。
- 缺少权限返回 `210802`;空请求或格式错误返回 `400`;全部失败路径零业务写入。
---
### 4. 更新行政区划节点 `PUT /admin/region/{id}`
**VO**: `AdministrativeRegionUpdateReqVO / Void`
#### 使用场景
具有 `system:region:update` 权限的管理员更新节点名称、父级、层级、可导航/可选状态、有效期、排序和扩展信息。请求必须携带最近读取到的完整可变字段和 `rowVersion`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `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 字符 | 结构化扩展;旧别名 `extJson` 仍兼容 |
| `rowVersion` | Body | Integer | ✅ | 大于等于 0,必须等于当前版本 | CAS 并发版本,成功后自动加 1 |
`codeStandard`、`regionCode`、`nodeKind`、`sourceVersion` 和 `sourceRef` 不属于更新接口,不能通过请求改写。
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | `200` 表示更新成功 |
| `message` | String | 成功时为“行政区划更新成功” |
| `success` | Boolean | 更新成功为 `true` |
| `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": "2090000000000000035",
"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,
"success": true
}
```
#### 空数据 / 降级响应
本接口成功时 `data=null` 是固定契约,调用方以 `code=200 && success=true` 判断成功。
#### 错误响应
提交了已经过期的 `rowVersion`:
```json
{
"code": 210807,
"message": "行政区划已被其他操作更新,请刷新后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 更新采用完整可变字段快照,不是局部 PATCH;前端必须先读取最新节点信息再提交。
- `rowVersion` 冲突返回 `210807` 且零写入;前端应重新加载,而不是自动覆盖重试。
- 有子节点的分支禁止移动父级或改变层级,返回 `210810`。
- 父节点提前停用、取消导航或缩短有效期,导致现有子节点失去合法父级时返回 `210814`。
- 已到生效日的 `GB/T 2260` 法定节点不能原位改写历史属性;应先关闭旧版本,再创建有效期不重叠的新版本。
- 成功后 `rowVersion` 增加 1;再次编辑必须使用新版本号。
---
## 四、契约约束与正确调用方式
### 三级联动新建流程
```text
GET /admin/region/children
→ 用户选择 provinceId
GET /admin/region/children?parentId={provinceId}
→ 用户选择 prefectureId
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` |
| ❌ 根层传 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 失败时原节点保持不变 |
前端无需理解或操作物理表、缓存键和审计表,也不得通过其他接口绕过创建/更新权限和版本检查。
---
## 六、边界行为
- 未登录:业务码 `401`。
- 已登录但缺少创建/更新专用权限:`210802`。
- Query/Path ID 为 0、负数或非十进制正整数:`400`。
- 父节点或目标节点不存在:`210801`。
- 父级不可导航、有效期不能覆盖子级:`210803`。
- 写入超过当前三级边界:`210804`。
- 自引用或父链成环:`210805`。
- 当前编码或版本冲突:`210806` / `210815`。
- `rowVersion` 过期:`210807`,零写入。
- `validTo` 不晚于 `validFrom`:`210808`。
- 父链异常:`210811`,不返回部分路径。
- `extension` 不是 JSON object 或过长:`210812`。
- 层级代码与父链深度不一致:`210813`。
- 老数据兼容:停用历史节点可由 `path` 回显;旧缓存中的数字 ID 可由后端读取,但当前 API 响应始终输出 string ID。
### 业务错误码
| code | message | 典型接口与场景 |
|---:|---|---|
| `400` | 参数校验失败文案 | 任一接口的 ID 非正整数、Body 缺少必填字段或格式不合法 |
| `401` | 未认证文案 | 任一接口未携带有效管理端身份 |
| `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`
| 值 | 中文 | 说明 |
|---|---|---|
| `GB/T 2260` | 法定行政区编码 | `regionCode` 必须为六位数字;生效历史受不可变保护 |
| `HL_CUSTOM` | 自定义行政区编码 | 管理端创建普通自定义行政区的推荐命名空间 |
| `HL_GROUP` | 导航聚合编码 | 只能与 `AGGREGATION` 搭配 |
---
## 六.6、修改前后对比
本条为新增接口,没有需要兼容的旧 `/admin/region/**` 公开接口。补充工单 #6409 在正式前端接入前固定了 ID 输出类型:
### 字段级对比
| 字段 | 初始实现风险 | 当前已部署契约 |
|---|---|---|
| 所有行政区划 Long ID | 小数值可能被全局序列化规则输出为 JSON number | 一律输出 JSON string |
| 缺失父级/层级 | 可能被调用方误当字符串处理 | 保持真正的 JSON `null` |
| `extension` 历史入参 | 旧调用方可能使用 `extJson` | 请求继续兼容 `extJson`,当前文档统一使用 `extension` |
### 行为级对比
| 行为 | 接入前 | 当前已部署行为 |
|---|---|---|
| 三级联动 | 无统一公开接口,容易截行政区编码推断 | 逐级调用 `children`,以后端父子关系为准 |
| 编辑回显 | 调用方自行拼装父链 | 调用 `path` 返回完整有序父链和三级快捷 ID |
| 并发更新 | 无本接口旧语义 | 必须提交 `rowVersion`,冲突返回 `210807` 且零写入 |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否;这是新增接口,且正式前端接入前已经固定字符串 ID 契约。
- **前端是否必须同步上线**: 是;`frontend_status=pending`,管理端需接入三级选择器、历史回显和主数据维护。
- **前端 workaround 清理点**: 不得保留行政区编码截位、Long ID 转 number、只判断 HTTP status 或把 `hasChildren` 等同于 `selectable` 的临时逻辑。
---
## 七、不影响范围
- **仅影响**: 管理后台行政区划维护、省/市/县三级选择器及历史行政区值回显。
- **零影响**:
- 小程序端现有行政区接口;
- 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 的合并提交。
- 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)
- 主 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)
---
## 撤回
本次 #6422 仅补充 Changelog 和模板校验,不改变后端制品。若文档补充需要撤回,从最新 `hl-api-changelog/main` 创建独立分支,revert #6422 对应合并提交并重新运行仓库全部校验;TEST 行政区划 API、数据、配置、Redis 和 MQ 均不需要回退。撤回后前端暂以当前 Controller/VO 契约为准,并重新提交一份通过模板门禁的自包含文档。
---
## 关联 / 联系人
### 链接
- **Issue**: [#6350](https://git.1814.love:8443/wx/HL/issues/6350)
- **补充文档 Issue**: [#6422](https://git.1814.love:8443/wx/HL/issues/6422)
- **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,392 @@
---
schema: "hl-changelog/v2"
ticket: "6397"
title: "供应商注册合同聚合信息"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "f2a4200d"
target_release: ""
verified_at: "2026-08-26"
status_note: "PR #6405 已合并 dev-v3;Deploy Panel 任务 af3f205b 成功发布 Resource 双实例,合同接口已完成 23 项真实 Gateway 验收。TEST 工作副本存在服务器本地提交导致精确提交回读仍被阻塞,后端工单保持开启;本条只交接已经实测存在的接口契约。"
updated_at: "2026-08-26"
base: "dev-v3"
---
# 🔧 供应商注册合同聚合信息
供应商创建草稿、提交注册和基础信息详情现统一支持合同完整快照。管理端应把“合同信息”放在“资质证照”之后、现有账户区域之前,并把原“初始账户”展示标题改为“结算信息”。
展示名调整不改变接口字段:原请求字段仍为 `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`。
## 公共合同字段
### 请求字段 `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` |
历史数据可能返回只读状态 `TERMINATED`;创建和提交请求不得发送该状态。
## 1. 创建供应商注册草稿
`POST /admin/supplier/items/add`
### 使用场景与边界
- `contracts` 可省略、为 `null` 或空数组,旧客户端行为不变。
- 非空时最多 100 项,合同与供应商主体、资质和 `initialAccounts` 一起成功或一起失败。
- 创建请求中的每个合同都是新合同,禁止携带 `contractId`。
- 写入仍要求现有供应商创建权限;仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有对应平台权限的身份可执行。
### 典型请求
```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
}
```
### 失败响应:创建携带合同 ID
```json
{
"code": 400,
"message": "创建草稿不能携带合同ID",
"data": null,
"success": false
}
```
失败时不会留下供应商主体或部分合同。
## 2. 提交供应商注册
`POST /admin/supplier/items/{supplierId}/submit`
### 使用场景与快照语义
- `contracts` 省略或为 `null`:本次不处理合同,保留草稿当前合同。
- `contracts: []`:明确清空当前全部合同。
- 非空数组:作为完整快照;带 `contractId` 的项覆盖当前合同,不带 ID 的项新增,当前已有但数组中遗漏的合同删除。
- 带 ID 的合同必须属于路径中的供应商;不属于当前供应商、重复 ID、非法枚举、负金额或日期逆序均失败。
- `expectedUpdateTime` 仍是供应商聚合并发版本;发生并发修改时调用方应刷新详情后重新组装完整表单。
- 合同快照会进入本次审批资料,但提交注册不会自动改写合同自身的 `status`。
### 典型请求
```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
}
```
### 失败响应:合同不属于当前供应商
```json
{
"code": 400,
"message": "合同不属于当前供应商",
"data": null,
"success": false
}
```
该失败会回滚本次提交表单中的主体、资质、合同和审计变化,供应商仍保持原状态和原版本。
## 3. 查询供应商基本信息
`GET /admin/supplier/items/{supplierId}/basic-info/view`
### 请求
```http
GET /admin/supplier/items/2090300000000063970/basic-info/view
Authorization: Bearer <admin-token>
```
无请求体。读取继续要求可信读角色和 `supplier:view` 平台权限。
### 成功响应
```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
}
```
合同按 `contractId` 升序返回,只包含当前有效合同。没有合同时返回空数组 `[]`,不返回 `null`。
### 常见失败
| 场景 | `code` | 前端处理 |
|---|---:|---|
| 未登录或 Token 失效 | `401` | 跳转登录,不展示空详情 |
| 可信角色或 `supplier:view` 平台权限不足 | `403` | 展示无权限状态 |
| 供应商不存在或已删除 | `395001` | 返回列表并刷新 |
## 修改前后对比
| 场景 | 修改前 | 修改后 |
|---|---|---|
| 创建草稿 | 请求不能携带合同 | 可选携带完整 `contracts`,与草稿一起成功或失败 |
| 提交注册 | 提交表单不能维护合同 | 可省略保留、空数组清空或提交完整合同快照 |
| 基础信息 | 不返回合同列表 | 返回完整 `contracts[]` 及字符串 ID、版本 |
| 账户区域标题 | 页面显示“初始账户” | 页面应显示“结算信息”,接口字段仍为 `initialAccounts` |
| 页面区块顺序 | 资质后直接进入账户区域 | 资质证照 → 合同信息 → 结算信息 |
## 兼容性与管理端接入事项
1. 在“资质证照”区域之后新增“合同信息”表格,在合同之后显示原账户表格。
2. 原账户表格标题改为“结算信息”;所有请求和响应继续使用 `initialAccounts`,不要改字段名。
3. 创建草稿时合同为完整新项,不发送 `contractId`;编辑后提交时,既有合同必须原样带回字符串 `contractId`。
4. 提交表单是完整快照。用户明确删除全部合同时发送 `contracts: []`;未加载合同或不处理合同时省略字段,不要误发空数组。
5. 所有 ID 均作为字符串保存、比较和回传,不经过 Number 转换。
6. 本次不新增接口路径、权限点或业务错误码;旧客户端省略 `contracts` 时继续可用。
7. 管理端源码不在本后端工单中修改,前端状态保持 `pending`,直至完成页签、标题和表格接入并提供前端引用。
## TEST 验证证据
- 自动化:供应商定向测试 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`。
- 清理:两个临时 DRAFT 均通过业务删除接口软删除并回读为不存在;仅保留不可逆的 CREATE/DELETE 操作审计。
- 环境限制:部署前锁定 `origin/dev-v3=1a16a5aec7d0b95ec87e6fb222581060a3135984`,但面板 Git API 回读到服务器本地短提交 `6f7d3ca78`,Gitea 无法解析该对象。接口行为已真实验证,后端工单仍等待测试环境恢复精确远端提交后复验,不能据此宣称最终交付完成。
## 撤回
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 健康。
## 关联 / 联系人
- **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
+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,