比较提交
11
次代码提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
ebc2fecbfd | ||
|
|
e362441e9c | ||
|
|
81aab7abb0 | ||
|
|
4f81ef1cf9 | ||
|
|
ca11783f72 | ||
|
|
3a2c1ae38d | ||
|
|
7b37f31df6 | ||
|
|
49f8b6d0a0 | ||
|
|
3d6f6245a0 | ||
|
|
ff16cfd94c | ||
|
|
2e18de27e1 |
+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
|
||||
|
||||
@@ -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,67 +7,290 @@ author: "lc(GIT)"
|
||||
change_type: "新增接口"
|
||||
backend_status: "deployed"
|
||||
gateway_status: "verified"
|
||||
frontend_status: "pending"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
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 已由 Deploy Panel 任务 52771074(hl-user-service)和 b1db24b2(hl-gateway)部署 TEST;真实管理员经 Gateway 完成未认证、读取、失败零写入、创建、CAS 更新、数据库和 Redis 一致性验收,临时数据、缓存快照及登录会话均已恢复。管理端尚待接入新增接口。"
|
||||
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
|
||||
>
|
||||
> **影响范围**: 管理后台行政区划维护、省/市/县三级选择器和历史值父链回显
|
||||
|
||||
> **服务**:`hl-user-service`,统一经 Gateway `/admin/region/**` 访问
|
||||
> **Issue**:#6350、#6384、#6389、#6395、#6399、#6409
|
||||
> **PR**:#6359、#6387、#6393、#6398、#6402、#6412
|
||||
> **影响范围**:管理后台行政区划维护、三级选择器和历史值父链回显
|
||||
---
|
||||
|
||||
## 关键约定
|
||||
## ⚠️ 关键变化
|
||||
|
||||
- 所有响应中的行政区划 ID 均为 JSON 字符串,包括 `data`、`parentId`、`defaultId`、`selectedId`、`provinceId`、`prefectureId`、`countyId` 和节点 `id`;没有父级或对应层级时保持 `null`。
|
||||
- 当前写入边界为省、市、县三级,层级代码依次为 `PROVINCE`、`PREFECTURE`、`COUNTY`;服务端根据父链计算 `depth`,请求不能直接指定深度。
|
||||
- `ADMINISTRATIVE` 表示可作为业务行政区的节点;`AGGREGATION` 只用于导航分组,必须使用 `HL_GROUP`、`navigable=true`、`selectable=false`。
|
||||
- 有效期使用左闭右开区间 `[validFrom, validTo)`;`validTo=null` 表示持续有效。
|
||||
- 读接口只要求真实管理员登录;创建还要求 `system:region:create`,更新要求 `system:region:update`。
|
||||
- 统一响应为 `Result<T>`。业务失败可能仍是 HTTP 200,调用方必须同时检查 `code`、`success`、`message` 和 `data`。
|
||||
- 本次补文档不改变 TEST 上已经部署的接口行为;它把原 Changelog 中分散的说明整理为可直接联调的完整契约。
|
||||
- 前端不得再把行政区划 ID 当 JavaScript number:响应中的所有行政区划 ID 均为 JSON string,缺失层级保持真正的 `null`。
|
||||
- 三级联动不允许通过行政区编码截位推断父子关系;必须逐级调用 `children`,编辑回显先调用 `path`。
|
||||
- `PREFECTURE` 是第二层导航层,不保证一定是可选择的法定地级行政区;直辖市等链路会返回 `AGGREGATION` 聚合节点,必须继续下钻且不得作为最终业务值。
|
||||
- 当前没有单节点详情接口,读响应也不返回 `extension`;更新接口省略或传 `null` 都会清空该字段,前端不能把它理解成“保持原值”。
|
||||
- 业务失败可能仍使用 HTTP 200,调用方必须同时判断响应体 `code`、`success`、`message` 和 `data`。
|
||||
|
||||
## 变更接口清单
|
||||
---
|
||||
|
||||
| 方法 | 路径 | 权限 | 用途 |
|
||||
|---|---|---|---|
|
||||
| POST | `/admin/region` | `system:region:create` | 创建行政区或导航聚合节点 |
|
||||
| PUT | `/admin/region/{id}` | `system:region:update` | 使用 `rowVersion` 更新可变字段 |
|
||||
| GET | `/admin/region/children` | 管理员登录 | 查询根层或指定父节点的当前有效直接子节点 |
|
||||
| GET | `/admin/region/{id}/path` | 管理员登录 | 返回任意节点从省级根开始的完整父链 |
|
||||
## 一、背景
|
||||
|
||||
## 1. 单级联动列表
|
||||
管理后台需要一套统一的行政区划主数据能力,同时支持:
|
||||
|
||||
- 省、市、县三级选择器逐级加载;
|
||||
- 编辑历史业务数据时,从任意节点恢复完整父链;
|
||||
- 具有专用权限的管理员创建自定义行政区、带来源证据的新法定版本或导航聚合节点;
|
||||
- 使用 `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
|
||||
GET /admin/region/children?parentId=1&selectedId=35 HTTP/1.1
|
||||
Host: api.test.1814.love:9443
|
||||
Authorization: Bearer <管理员令牌>
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
查询省级根列表时省略 `parentId`。`selectedId` 只有在本次 `items` 中存在时才作为 `defaultId`;否则回退为排序后的首项,空列表返回 `null`。
|
||||
根层省级列表:
|
||||
|
||||
```http
|
||||
GET /admin/region/children HTTP/1.1
|
||||
Host: api.test.1814.love:9443
|
||||
Authorization: Bearer <管理员令牌>
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"success": true,
|
||||
"data": {
|
||||
"parentId": "1",
|
||||
"items": [
|
||||
{
|
||||
"id": "35",
|
||||
"parentId": "1",
|
||||
"codeStandard": "GB/T 2260",
|
||||
"regionCode": "110100",
|
||||
"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",
|
||||
@@ -78,80 +301,143 @@ Authorization: Bearer <管理员令牌>
|
||||
"status": "ACTIVE",
|
||||
"validFrom": "2025-12-31",
|
||||
"validTo": null,
|
||||
"sortOrder": 100,
|
||||
"sortOrder": 10,
|
||||
"rowVersion": 0
|
||||
}
|
||||
],
|
||||
"defaultId": "35"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
仅返回中国业务当日有效且状态为 `ACTIVE` 的直接子节点,排序稳定为 `sortOrder`、`regionCode`、`id`。停用或历史节点不会进入联动列表,但仍可通过父链接口回显。
|
||||
|
||||
## 2. 父链回显
|
||||
|
||||
```http
|
||||
GET /admin/region/376/path
|
||||
Authorization: Bearer <管理员令牌>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"success": true,
|
||||
"data": {
|
||||
"selectedId": "376",
|
||||
"provinceId": "1",
|
||||
"prefectureId": "35",
|
||||
"countyId": "376",
|
||||
"path": [
|
||||
{
|
||||
"id": "1",
|
||||
"parentId": null,
|
||||
"levelCode": "PROVINCE",
|
||||
"depth": 1,
|
||||
"name": "北京市",
|
||||
"nodeKind": "ADMINISTRATIVE",
|
||||
"currentlyEffective": true
|
||||
},
|
||||
{
|
||||
"id": "35",
|
||||
"parentId": "1",
|
||||
"codeStandard": "HL_GROUP",
|
||||
"regionCode": "HL-GROUP-110000",
|
||||
"levelCode": "PREFECTURE",
|
||||
"depth": 2,
|
||||
"name": "北京市",
|
||||
"nodeKind": "ADMINISTRATIVE",
|
||||
"currentlyEffective": true
|
||||
"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",
|
||||
"currentlyEffective": true
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
目标为省级或地级时,尚未到达的快捷层级字段返回 `null`。历史停用节点可返回,但节点自身的 `currentlyEffective=false`,调用方不能把它重新放入当前可选列表。父链存在孤儿、环、越级或超过当前安全深度时,接口失败且不返回部分路径。
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
## 3. 创建节点
|
||||
|
||||
```http
|
||||
POST /admin/region
|
||||
Authorization: Bearer <具有 system:region:create 的管理员令牌>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
创建一个县级业务行政区示例:
|
||||
本接口不返回“成功但空父链”。目标不存在、父链不完整或超过当前三级安全边界时使用错误响应,不返回部分路径。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",
|
||||
@@ -174,28 +460,94 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
成功响应的 `data` 是字符串 ID:
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"success": true,
|
||||
"data": "2090000000000000001"
|
||||
"data": "2090000000000001001",
|
||||
"traceId": "a1b2c3d4-e5f6-7890",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
`extension` 只能是 JSON 对象;历史入参名 `extJson` 仅作为请求别名兼容,响应统一使用 `extension`。服务端会记录来源版本、来源定位、操作人和来源校验值,并在事务提交后刷新行政区划独立缓存。
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
## 4. 更新节点
|
||||
本接口成功时一定返回字符串 ID,不存在 `data=null` 的成功语义。失败时事务回滚且不创建节点;本接口没有“Redis 不可用仍受理写入”的公开降级承诺。
|
||||
|
||||
```http
|
||||
PUT /admin/region/2090000000000000001
|
||||
Authorization: Bearer <具有 system:region:update 的管理员令牌>
|
||||
Content-Type: application/json
|
||||
```
|
||||
#### 错误响应
|
||||
|
||||
层级代码与父链派生深度不一致:
|
||||
|
||||
```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",
|
||||
@@ -214,77 +566,312 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "行政区划更新成功",
|
||||
"success": true,
|
||||
"data": null
|
||||
"data": null,
|
||||
"traceId": "a1b2c3d4-e5f6-7890",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
更新采用完整可变字段加 `rowVersion` 的 CAS 语义。成功后版本号递增;并发版本过期返回 `210807`,不会覆盖另一位管理员的提交。编码标准、地区编码、节点类型和来源证据不可通过更新接口改写。
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
已到生效日的 `GB/T 2260` 法定节点不能原位修改历史属性;只能保持其他字段不变,将旧版本关闭,再创建不重叠的新版本。有子节点的分支不能直接移动或改变层级,父节点的状态、导航能力和有效期必须持续覆盖当前或未来仍会生效的子版本。
|
||||
本接口成功时 `data=null` 是固定契约,调用方以 `code=200 && success=true` 判断成功。本接口没有“Redis 不可用仍受理写入”的公开降级承诺,也不会返回更新后的节点快照。
|
||||
|
||||
## 字段说明
|
||||
#### 错误响应
|
||||
|
||||
| 字段 | JSON 类型 | 说明 |
|
||||
提交了已经过期的 `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 返回。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库行为
|
||||
|
||||
| 前端操作 | 外部可观察的持久化行为 | 失败行为 |
|
||||
|---|---|---|
|
||||
| `id`、`parentId` | string / null | 行政区划节点与父节点 ID;响应始终为字符串 |
|
||||
| `codeStandard` | string | `GB/T 2260`、`HL_CUSTOM` 或聚合节点专用 `HL_GROUP` |
|
||||
| `regionCode` | string | 同一编码标准内的业务标识;`GB/T 2260` 必须为六位数字 |
|
||||
| `levelCode` | string | 当前三级固定为 `PROVINCE`、`PREFECTURE`、`COUNTY` |
|
||||
| `nodeKind` | string | `ADMINISTRATIVE` 或 `AGGREGATION` |
|
||||
| `navigable` | boolean | 是否可继续逐级导航 |
|
||||
| `selectable` | boolean | 是否可作为最终业务值 |
|
||||
| `hasChildren` | boolean | 是否存在当前有效、启用的直接子节点 |
|
||||
| `currentlyEffective` | boolean | 节点在中国业务当日是否处于有效时间窗且为 `ACTIVE` |
|
||||
| `validFrom`、`validTo` | string / null | `yyyy-MM-dd`;结束日为开区间 |
|
||||
| `status` | string | `ACTIVE` 或 `INACTIVE` |
|
||||
| `rowVersion` | integer | 更新 CAS 版本;创建后从 0 开始 |
|
||||
| 查询 `children` | 只读,不改变节点、版本或审计 | 不写入 |
|
||||
| 查询 `path` | 只读,不改变节点、版本或审计 | 不写入,不返回部分父链 |
|
||||
| 创建节点 | 成功新增一个节点,初始 `rowVersion=0`;数据库事务提交后异步刷新节点和父级列表缓存 | 参数、权限、父链、有效期或唯一性失败时不新增节点 |
|
||||
| 更新节点 | 成功更新一个节点并令 `rowVersion+1`;数据库事务提交后异步刷新节点、旧/新父级及上层列表缓存 | 版本冲突或业务 guard 失败时原节点保持不变 |
|
||||
|
||||
## 业务错误码
|
||||
写接口成功只证明数据库事务已经提交,不承诺紧随其后的第一个缓存读取已经反映新值。前端不得自行操作缓存;需要立即回显时应短暂重读,并始终以新的 `rowVersion` 为准。读取阶段 Redis 故障会降级数据库;写入口还受公共幂等保护,不能据此推导“Redis 故障时写入一定成功”。
|
||||
|
||||
| code | message | 典型场景 |
|
||||
---
|
||||
|
||||
## 六、边界行为
|
||||
|
||||
- 未登录:业务码 `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 | 典型接口与场景 |
|
||||
|---:|---|---|
|
||||
| `210801` | 行政区划节点不存在 | 查询不存在的父级或父链目标 |
|
||||
| `210802` | 无行政区划维护权限 | 创建或更新缺少细粒度权限 |
|
||||
| `210803` | 行政区划父节点不合法 | 父级不可导航或有效期不能覆盖子级 |
|
||||
| `210804` | 行政区划层级超过当前允许深度 | 写入超过当前三级边界 |
|
||||
| `210805` | 行政区划父链存在循环 | 自引用或移动后形成祖先环 |
|
||||
| `210806` | 行政区划当前编码已存在 | 当前唯一键冲突 |
|
||||
| `210807` | 行政区划已被其他操作更新,请刷新后重试 | `rowVersion` 过期 |
|
||||
| `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` | 存在子节点的行政区划不能移动或变更层级 | 分支移动或改层级 |
|
||||
| `210811` | 行政区划父链数据不完整 | 孤儿、越级、环或异常终止 |
|
||||
| `210812` | 行政区划扩展字段必须是合法 JSON | `extension` 不是 JSON 对象 |
|
||||
| `210813` | 行政区划层级代码与父链深度不一致 | 省市县代码与派生深度不匹配 |
|
||||
| `210814` | 行政区划更新与现有子节点状态或有效期冲突 | 父级提前停用、取消导航或缩短窗口 |
|
||||
| `210815` | 行政区划编码的版本有效期与既有数据重叠 | 同编码版本时间窗重叠 |
|
||||
| `210816` | 行政区划节点类型或编码命名空间不合法 | 聚合节点类型、命名空间或可选性组合错误 |
|
||||
| `210817` | 已生效法定行政区划只能关闭旧版本后新增 | 原位改写生效中的法定节点 |
|
||||
| `210818` | 行政区划数据来源信息不完整 | 缺少来源版本或定位 |
|
||||
| `210819` | 法定行政区划代码必须为六位数字 | `GB/T 2260` 新版本编码格式错误 |
|
||||
| `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` 格式错误 |
|
||||
|
||||
参数格式错误继续使用统一参数错误响应,例如未登录返回业务码 `401`,`parentId=0` 或空创建请求返回业务码 `400`。
|
||||
---
|
||||
|
||||
## 兼容与接入建议
|
||||
## 六.5、枚举 / 数据字典
|
||||
|
||||
- 三级选择器首次加载调用不带 `parentId` 的 `children`;选择省、市后分别以当前 ID 继续查询下一层。
|
||||
- 编辑历史数据时先调用 `/{id}/path` 得到完整选中链,再逐层加载 `children`;不要根据行政区编码截位推断父子关系。
|
||||
- `AGGREGATION` 节点可能可导航但不可选,页面应分别使用 `navigable` 和 `selectable`,不要只根据 `hasChildren` 判断。
|
||||
- 客户端必须把响应 ID 当字符串保存和比较;请求路径与查询参数可以继续发送十进制字符串。
|
||||
- 旧行政区划缓存中的数字 ID 与新缓存中的字符串 ID 均可被后端读取,滚动部署期间无需调用方切换缓存版本。
|
||||
### `levelCode`(行政区划层级)
|
||||
|
||||
## 验证证据
|
||||
**所属字段**: `AdministrativeRegionCreateReqVO.levelCode`、`AdministrativeRegionUpdateReqVO.levelCode`、`AdministrativeRegionItemVO.levelCode` | **类型**: `String`
|
||||
|
||||
- PR #6359、#6387、#6393、#6398、#6402、#6412 均已合并 `dev-v3`,六个合并提交都包含在 TEST 精确提交 `b62054035a139e3c689179ef75e2d052d08dde52` 中。
|
||||
- Deploy Panel API 任务 `52771074` 部署 `hl-user-service`,任务 `b1db24b2` 部署 `hl-gateway`;两项脚本均为 `/opt/hulalv/scripts/deploy-backend.sh`,终态 `success`、退出码 0,双实例、Nacos 健康注册、滚动采样与部署窗口日志检查通过。
|
||||
- 真实 TEST 管理员经 Gateway 验证未认证返回 401、根级/子级联动、父链回显、失败零写入、创建字符串 ID、数据库落行、Redis 回填、CAS 更新和更新后缓存一致性。
|
||||
- 验收创建的唯一测试行已精确删除;数据库总量与测试命名空间恢复基线,四个 Redis 快照按原值和绝对过期时间恢复,缓存锁释放,登录会话注销。操作审计按系统设计保留。
|
||||
| 值 | 中文 | 说明 |
|
||||
|---|---|---|
|
||||
| `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)
|
||||
|
||||
---
|
||||
|
||||
## 撤回
|
||||
|
||||
代码撤回需回退上述六个 PR 并按依赖逆序重新部署 `hl-gateway` 与 `hl-user-service`;调用方停止访问 `/admin/region/**`,并经 Gateway 验证新增路由已不可达、既有 User 接口正常。数据库迁移已经在 TEST 应用,回退代码时保留行政区划表和权限元数据,不执行降版 DDL;Redis 仅在确需重建时精确清理 `hl:user:region:v1:` 命名空间,禁止触碰登录及其他业务缓存。无 MQ 或跨服务写入需要补偿。
|
||||
本次 #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
|
||||
|
||||
@@ -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: "49b071b0"
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
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"
|
||||
|
||||
@@ -7,12 +7,12 @@ author: "lc(GIT)"
|
||||
change_type: "修改接口"
|
||||
backend_status: "deployed"
|
||||
gateway_status: "verified"
|
||||
frontend_status: "verified"
|
||||
frontend_status: "pending"
|
||||
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 工作副本存在服务器本地提交导致精确提交回读仍被阻塞,后端工单保持开启;本条只交接已经实测存在的接口契约。"
|
||||
status_note: "PR #6405 已合并 dev-v3;Deploy Panel 任务 af3f205b 成功发布 Resource 双实例,合同接口已完成 23 项真实 Gateway 验收。TEST 工作副本存在服务器本地提交导致精确提交回读仍被阻塞,后端工单保持开启;本条只交接已经实测存在的接口契约。2026-08-26 晚 PR #6450 修正:响应 contracts[].amount 由 Number 改为 String(金额全站统一字符串输出),前端 f2a4200d 按 Number 集成处需改为按字符串解析,改完请回填 frontend_status。"
|
||||
updated_at: "2026-08-26"
|
||||
base: "dev-v3"
|
||||
---
|
||||
@@ -23,19 +23,29 @@ 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` 列表 |
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
|---:|---|---|---|---|---|
|
||||
| 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[]`
|
||||
- **响应 `contracts[].amount` 由 Number 改为 String**:此前(f2a4200d 验收时)响应示例为 `"amount": 1200.50`,现实际输出 `"amount": "1200.50"`,与全站金额字段(`contractId` 之外的金额一律字符串)对齐,防 JavaScript 浮点精度丢失。
|
||||
- **请求侧不变**:`contracts[].amount` 请求仍按 Number 传(字符串同值也可被兼容解析),无需改表单提交。
|
||||
- 前端处理:详情/列表展示处把 amount 当字符串渲染即可,参与运算前 `Number(...)` 转换。
|
||||
|
||||
## 三、接口详情
|
||||
|
||||
三个接口的合同字段完全一致,先在「公共合同字段」统一约定;各接口再分节给出自包含的使用场景、入参、出参、示例与错误。
|
||||
|
||||
### 公共合同字段
|
||||
|
||||
#### 请求字段 `contracts[]`
|
||||
|
||||
| 字段 | 类型 | 创建必填 | 提交既有项必填 | 约束与说明 |
|
||||
|---|---|---:|---:|---|
|
||||
@@ -53,7 +63,7 @@ base: "dev-v3"
|
||||
| `scanFileUrl` | String | 否 | 否 | 合同扫描件永久地址,最长 1000 字符 |
|
||||
| `remark` | String | 否 | 否 | 最长 500 字符 |
|
||||
|
||||
### 响应字段 `contracts[]`
|
||||
#### 响应字段 `contracts[]`
|
||||
|
||||
详情返回上述全部业务字段,并额外返回:
|
||||
|
||||
@@ -62,20 +72,40 @@ base: "dev-v3"
|
||||
| `contractId` | String | 合同 ID,始终按字符串处理,不得转 JavaScript Number |
|
||||
| `updateTime` | String | 合同当前版本,格式 `yyyy-MM-dd HH:mm:ss` |
|
||||
|
||||
响应中的 `amount` 为 String(如 `"1200.50"`,PR #6450 起),请求中的 `amount` 仍按 Number 传。
|
||||
|
||||
历史数据可能返回只读状态 `TERMINATED`;创建和提交请求不得发送该状态。
|
||||
|
||||
## 1. 创建供应商注册草稿
|
||||
### 1. 创建供应商注册草稿 `POST /admin/supplier/items/add`
|
||||
|
||||
`POST /admin/supplier/items/add`
|
||||
**VO**: `SupplierDraftSaveReqVO`(请求;响应 data 字段见下表)
|
||||
|
||||
### 使用场景与边界
|
||||
#### 使用场景
|
||||
|
||||
- `contracts` 可省略、为 `null` 或空数组,旧客户端行为不变。
|
||||
- 非空时最多 100 项,合同与供应商主体、资质和 `initialAccounts` 一起成功或一起失败。
|
||||
- 创建请求中的每个合同都是新合同,禁止携带 `contractId`。
|
||||
- 写入仍要求现有供应商创建权限;仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有对应平台权限的身份可执行。
|
||||
供应商注册第一步:创建草稿。`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
|
||||
@@ -123,7 +153,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
### 成功响应
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -141,7 +171,13 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
### 失败响应:创建携带合同 ID
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
`contracts` 省略、为 `null` 或空数组均可正常创建,旧客户端行为不变;创建响应不返回合同明细(合同通过详情接口回显)。草稿无结算账户时 `initialAccounts` 返回空数组 `[]`,不返回 `null`。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
创建请求携带合同 ID(创建时每个合同都是新项,禁止 `contractId`):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -154,20 +190,44 @@ Content-Type: application/json
|
||||
|
||||
失败时不会留下供应商主体或部分合同。
|
||||
|
||||
## 2. 提交供应商注册
|
||||
#### 业务边界
|
||||
|
||||
`POST /admin/supplier/items/{supplierId}/submit`
|
||||
- 非空时最多 100 项,合同与供应商主体、资质和 `initialAccounts` 一起成功或一起失败。
|
||||
- 创建请求中的每个合同都是新合同,禁止携带 `contractId`。
|
||||
- 写入仍要求现有供应商创建权限;仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有对应平台权限的身份可执行。
|
||||
- `endDate` 早于 `startDate` 直接校验失败,零写入。
|
||||
|
||||
### 使用场景与快照语义
|
||||
### 2. 提交供应商注册 `POST /admin/supplier/items/{supplierId}/submit`
|
||||
|
||||
- `contracts` 省略或为 `null`:本次不处理合同,保留草稿当前合同。
|
||||
- `contracts: []`:明确清空当前全部合同。
|
||||
- 非空数组:作为完整快照;带 `contractId` 的项覆盖当前合同,不带 ID 的项新增,当前已有但数组中遗漏的合同删除。
|
||||
- 带 ID 的合同必须属于路径中的供应商;不属于当前供应商、重复 ID、非法枚举、负金额或日期逆序均失败。
|
||||
- `expectedUpdateTime` 仍是供应商聚合并发版本;发生并发修改时调用方应刷新详情后重新组装完整表单。
|
||||
- 合同快照会进入本次审批资料,但提交注册不会自动改写合同自身的 `status`。
|
||||
**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
|
||||
@@ -204,7 +264,7 @@ Content-Type: application/json
|
||||
"signDate": "2026-08-26",
|
||||
"startDate": "2026-09-01",
|
||||
"endDate": "2027-08-31",
|
||||
"amount": 1200.50,
|
||||
"amount": "1200.50",
|
||||
"pricingMode": "按团结算",
|
||||
"settleCycle": "MONTHLY",
|
||||
"status": "ACTIVE",
|
||||
@@ -217,7 +277,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
### 成功响应
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -238,7 +298,13 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
### 失败响应:合同不属于当前供应商
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
`contracts` 省略或为 `null` 时保留草稿当前合同,走既有审批流程,无降级差异;`contracts: []` 为明确清空(不是省略),会删除当前全部合同。草稿本身无合同时按无合同提交,不报错。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
合同不属于当前供应商(或重复 ID、非法枚举、负金额、日期逆序等同族校验失败):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -251,20 +317,50 @@ Content-Type: application/json
|
||||
|
||||
该失败会回滚本次提交表单中的主体、资质、合同和审计变化,供应商仍保持原状态和原版本。
|
||||
|
||||
## 3. 查询供应商基本信息
|
||||
#### 业务边界
|
||||
|
||||
`GET /admin/supplier/items/{supplierId}/basic-info/view`
|
||||
- 带 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>
|
||||
```
|
||||
|
||||
无请求体。读取继续要求可信读角色和 `supplier:view` 平台权限。
|
||||
|
||||
### 成功响应
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -322,7 +418,7 @@ Authorization: Bearer <admin-token>
|
||||
"signDate": "2026-08-26",
|
||||
"startDate": "2026-09-01",
|
||||
"endDate": "2027-08-31",
|
||||
"amount": 1200.50,
|
||||
"amount": "1200.50",
|
||||
"pricingMode": "按团结算",
|
||||
"settleCycle": "MONTHLY",
|
||||
"status": "DRAFT",
|
||||
@@ -337,9 +433,22 @@ Authorization: Bearer <admin-token>
|
||||
}
|
||||
```
|
||||
|
||||
合同按 `contractId` 升序返回,只包含当前有效合同。没有合同时返回空数组 `[]`,不返回 `null`。
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
### 常见失败
|
||||
没有合同时返回空数组 `"contracts": []`,不返回 `null`;合同按 `contractId` 升序返回,只包含当前有效合同。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
常见失败(统一 `Result` 包装,HTTP 200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 395001,
|
||||
"message": "供应商不存在",
|
||||
"data": null,
|
||||
"success": false
|
||||
}
|
||||
```
|
||||
|
||||
| 场景 | `code` | 前端处理 |
|
||||
|---|---:|---|
|
||||
@@ -347,17 +456,13 @@ Authorization: Bearer <admin-token>
|
||||
| 可信角色或 `supplier:view` 平台权限不足 | `403` | 展示无权限状态 |
|
||||
| 供应商不存在或已删除 | `395001` | 返回列表并刷新 |
|
||||
|
||||
## 修改前后对比
|
||||
#### 业务边界
|
||||
|
||||
| 场景 | 修改前 | 修改后 |
|
||||
|---|---|---|
|
||||
| 创建草稿 | 请求不能携带合同 | 可选携带完整 `contracts`,与草稿一起成功或失败 |
|
||||
| 提交注册 | 提交表单不能维护合同 | 可省略保留、空数组清空或提交完整合同快照 |
|
||||
| 基础信息 | 不返回合同列表 | 返回完整 `contracts[]` 及字符串 ID、版本 |
|
||||
| 账户区域标题 | 页面显示“初始账户” | 页面应显示“结算信息”,接口字段仍为 `initialAccounts` |
|
||||
| 页面区块顺序 | 资质后直接进入账户区域 | 资质证照 → 合同信息 → 结算信息 |
|
||||
- 读取要求可信读角色(`ADMIN`/`FINANCE`/`SUPER_ADMIN`)和 `supplier:view` 平台权限。
|
||||
- 敏感字段(税号、证件号、手机号)一律脱敏返回,前端不得期待明文。
|
||||
- `updateTime` 是后续提交表单的并发版本,必须原样缓存回传。
|
||||
|
||||
## 兼容性与管理端接入事项
|
||||
## 四、契约约束与正确调用方式
|
||||
|
||||
1. 在“资质证照”区域之后新增“合同信息”表格,在合同之后显示原账户表格。
|
||||
2. 原账户表格标题改为“结算信息”;所有请求和响应继续使用 `initialAccounts`,不要改字段名。
|
||||
@@ -367,15 +472,65 @@ Authorization: Bearer <admin-token>
|
||||
6. 本次不新增接口路径、权限点或业务错误码;旧客户端省略 `contracts` 时继续可用。
|
||||
7. 管理端源码不在本后端工单中修改,前端状态保持 `pending`,直至完成页签、标题和表格接入并提供前端引用。
|
||||
|
||||
## TEST 验证证据
|
||||
## 五、数据库行为
|
||||
|
||||
- 创建草稿与提交注册均为聚合级单事务写入:供应商主体、资质、合同快照、结算账户与审计记录一起成功或一起失败,任一校验失败零写入。
|
||||
- 合同快照随供应商聚合版本(`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 合入。
|
||||
@@ -384,6 +539,12 @@ Authorization: Bearer <admin-token>
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户