From 3d6f6245a02505f8f4be639ce35dd01a243c0cb0 Mon Sep 17 00:00:00 2001 From: lc Date: Wed, 26 Aug 2026 16:23:15 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=A1=8C=E6=94=BF=E5=8C=BA?= =?UTF-8?q?=E5=88=92=E4=B8=89=E7=BA=A7=E8=81=94=E5=8A=A8=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=B8=8E=E6=A8=A1=E6=9D=BF=E9=97=A8=E7=A6=81?= =?UTF-8?q?=EF=BC=88#6422=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .githooks/pre-push | 6 +- BACKEND_CHANGELOG_DELIVERY_GUIDE.md | 8 +- CHANGELOG_TEMPLATE.md | 14 +- CONTRIBUTING.md | 18 + ...政区划主数据与三级联动-新增接口-管理后台.md | 849 +++++++++++++++--- scripts/validate-changelog-frontmatter.mjs | 211 ++++- tests/validate-changelog-frontmatter.test.mjs | 92 +- 7 files changed, 1038 insertions(+), 160 deletions(-) diff --git a/.githooks/pre-push b/.githooks/pre-push index b719d0b8..0f50244a 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -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 diff --git a/BACKEND_CHANGELOG_DELIVERY_GUIDE.md b/BACKEND_CHANGELOG_DELIVERY_GUIDE.md index f86b1b99..c26360b5 100644 --- a/BACKEND_CHANGELOG_DELIVERY_GUIDE.md +++ b/BACKEND_CHANGELOG_DELIVERY_GUIDE.md @@ -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 diff --git a/CHANGELOG_TEMPLATE.md b/CHANGELOG_TEMPLATE.md index 16604069..b96e6a15 100644 --- a/CHANGELOG_TEMPLATE.md +++ b/CHANGELOG_TEMPLATE.md @@ -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 渲染建议。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6701974d..95e6aafb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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` 是机器可识别的唯一映射源; diff --git a/changelogs-v2/2026-08/26_6350_行政区划主数据与三级联动-新增接口-管理后台.md b/changelogs-v2/2026-08/26_6350_行政区划主数据与三级联动-新增接口-管理后台.md index 26637227..64fdb3c6 100644 --- a/changelogs-v2/2026-08/26_6350_行政区划主数据与三级联动-新增接口-管理后台.md +++ b/changelogs-v2/2026-08/26_6350_行政区划主数据与三级联动-新增接口-管理后台.md @@ -11,59 +11,141 @@ frontend_status: "pending" frontend_owner: "" frontend_ref: "" target_release: "" -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 一致性验收,临时数据、缓存快照及登录会话均已恢复。管理端尚待接入新增接口。" +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 +> +> **影响范围**: 管理后台行政区划维护、省/市/县三级选择器和历史值父链回显 -> **服务**:`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`。业务失败可能仍是 HTTP 200,调用方必须同时检查 `code`、`success`、`message` 和 `data`。 +- 本次补文档不改变 TEST 上已经部署的接口行为;它把原 Changelog 中分散的说明整理为可直接联调的完整契约。 +- 前端不得再把行政区划 ID 当 JavaScript number:响应中的所有行政区划 ID 均为 JSON string,缺失层级保持真正的 `null`。 +- 三级联动不允许通过行政区编码截位推断父子关系;必须逐级调用 `children`,编辑回显先调用 `path`。 +- 业务失败可能仍使用 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`。接口返回的示例 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 ` | 真实管理端登录令牌 | +| `parentId` | Query | String | ❌ | 十进制正整数;根层省级列表必须省略 | 指定要查询直接子节点的父节点 ID | +| `selectedId` | Query | String | ❌ | 十进制正整数 | 编辑回显时希望优先选中的本级节点 ID | + +无请求体。 + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `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=2090000000000000001&selectedId=2090000000000000035 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", + "parentId": "2090000000000000001", "items": [ { - "id": "35", - "parentId": "1", + "id": "2090000000000000035", + "parentId": "2090000000000000001", "codeStandard": "GB/T 2260", "regionCode": "110100", "levelCode": "PREFECTURE", @@ -82,80 +164,277 @@ Authorization: Bearer <管理员令牌> "rowVersion": 0 } ], - "defaultId": "35" - } + "defaultId": "2090000000000000035" + }, + "traceId": "a1b2c3d4-e5f6-7890", + "success": true } ``` -仅返回中国业务当日有效且状态为 `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", - "levelCode": "PREFECTURE", - "depth": 2, - "name": "北京市", - "nodeKind": "ADMINISTRATIVE", - "currentlyEffective": true - }, - { - "id": "376", - "parentId": "35", - "levelCode": "COUNTY", - "depth": 3, - "name": "东城区", - "nodeKind": "ADMINISTRATIVE", - "currentlyEffective": true - } - ] - } + "parentId": "2090000000000000035", + "items": [], + "defaultId": null + }, + "success": true } ``` -目标为省级或地级时,尚未到达的快捷层级字段返回 `null`。历史停用节点可返回,但节点自身的 `currentlyEffective=false`,调用方不能把它重新放入当前可选列表。父链存在孤儿、环、越级或超过当前安全深度时,接口失败且不返回部分路径。 +#### 错误响应 -## 3. 创建节点 - -```http -POST /admin/region -Authorization: Bearer <具有 system:region:create 的管理员令牌> -Content-Type: application/json -``` - -创建一个县级业务行政区示例: +父节点不存在或已删除: ```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 ` | 真实管理端登录令牌 | +| `id` | Path | String | ✅ | 十进制正整数 | 要回显的省、市或县节点 ID | + +无请求体。 + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `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 ` 且具有 `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` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `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": "35", + "parentId": "2090000000000000035", "levelCode": "COUNTY", "nodeKind": "ADMINISTRATIVE", "name": "示例业务区", @@ -174,30 +453,93 @@ Content-Type: application/json } ``` -成功响应的 `data` 是字符串 ID: +#### 响应示例 ```json { "code": 200, "message": "成功", - "success": true, - "data": "2090000000000000001" + "data": "2090000000000001001", + "success": true } ``` -`extension` 只能是 JSON 对象;历史入参名 `extJson` 仅作为请求别名兼容,响应统一使用 `extension`。服务端会记录来源版本、来源定位、操作人和来源校验值,并在事务提交后刷新行政区划独立缓存。 +#### 空数据 / 降级响应 -## 4. 更新节点 +本接口成功时一定返回字符串 ID,不存在 `data=null` 的成功语义。失败时不创建节点。 -```http -PUT /admin/region/2090000000000000001 -Authorization: Bearer <具有 system:region:update 的管理员令牌> -Content-Type: application/json -``` +#### 错误响应 + +层级代码与父链派生深度不一致: ```json { - "parentId": "35", + "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 ` 且具有 `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` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `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": "示例区", @@ -214,77 +556,294 @@ Content-Type: application/json } ``` +#### 响应示例 + ```json { "code": 200, "message": "行政区划更新成功", - "success": true, - "data": null + "data": null, + "success": true } ``` -更新采用完整可变字段加 `rowVersion` 的 CAS 语义。成功后版本号递增;并发版本过期返回 `210807`,不会覆盖另一位管理员的提交。编码标准、地区编码、节点类型和来源证据不可通过更新接口改写。 +#### 空数据 / 降级响应 -已到生效日的 `GB/T 2260` 法定节点不能原位修改历史属性;只能保持其他字段不变,将旧版本关闭,再创建不重叠的新版本。有子节点的分支不能直接移动或改变层级,父节点的状态、导航能力和有效期必须持续覆盖当前或未来仍会生效的子版本。 +本接口成功时 `data=null` 是固定契约,调用方以 `code=200 && success=true` 判断成功。 -## 字段说明 +#### 错误响应 -| 字段 | JSON 类型 | 说明 | +提交了已经过期的 `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 返回。 + +--- + +## 五、数据库行为 + +| 前端操作 | 外部可观察的持久化行为 | 失败行为 | |---|---|---| -| `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 失败时原节点保持不变 | -## 业务错误码 +前端无需理解或操作物理表、缓存键和审计表,也不得通过其他接口绕过创建/更新权限和版本检查。 -| 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`。 +- 老数据兼容:停用历史节点可由 `path` 回显;旧缓存中的数字 ID 可由后端读取,但当前 API 响应始终输出 string ID。 + +### 业务错误码 + +| code | message | 典型接口与场景 | |---:|---|---| -| `210801` | 行政区划节点不存在 | 查询不存在的父级或父链目标 | -| `210802` | 无行政区划维护权限 | 创建或更新缺少细粒度权限 | -| `210803` | 行政区划父节点不合法 | 父级不可导航或有效期不能覆盖子级 | -| `210804` | 行政区划层级超过当前允许深度 | 写入超过当前三级边界 | -| `210805` | 行政区划父链存在循环 | 自引用或移动后形成祖先环 | -| `210806` | 行政区划当前编码已存在 | 当前唯一键冲突 | -| `210807` | 行政区划已被其他操作更新,请刷新后重试 | `rowVersion` 过期 | +| `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` | 存在子节点的行政区划不能移动或变更层级 | 分支移动或改层级 | -| `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` + +| 值 | 中文 | 说明 | +|---|---|---| +| `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) + +--- ## 撤回 -代码撤回需回退上述六个 PR 并按依赖逆序重新部署 `hl-gateway` 与 `hl-user-service`;调用方停止访问 `/admin/region/**`,并经 Gateway 验证新增路由已不可达、既有 User 接口正常。数据库迁移已经在 TEST 应用,回退代码时保留行政区划表和权限元数据,不执行降版 DDL;Redis 仅在确需重建时精确清理 `hl:user:region:v1:` 命名空间,禁止触碰登录及其他业务缓存。无 MQ 或跨服务写入需要补偿。 +本次 #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 diff --git a/scripts/validate-changelog-frontmatter.mjs b/scripts/validate-changelog-frontmatter.mjs index 63fe26bd..74cbb9ae 100644 --- a/scripts/validate-changelog-frontmatter.mjs +++ b/scripts/validate-changelog-frontmatter.mjs @@ -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; } diff --git a/tests/validate-changelog-frontmatter.test.mjs b/tests/validate-changelog-frontmatter.test.mjs index 23f7178d..69da1a37 100644 --- a/tests/validate-changelog-frontmatter.test.mjs +++ b/tests/validate-changelog-frontmatter.test.mjs @@ -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\`\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, -- 2.43.0