比较提交

...
作者 SHA1 备注 提交日期
lc d3b5c0e8c9 docs: 纠正供应商合同独立登记交付状态 (#6544)
changelog-filename-gate / validate (pull_request) Successful in 1s
2026-08-29 09:40:18 +08:00
API Changelog Bot 0474e6ff78 docs: 交付供应商合同独立登记契约(#6544)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-29 00:55:23 +08:00
Mimingguang 9aace47441 chore(changelog): 回写 #6474 供应商变更/审批分离 verified(199147de/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 17:01:30 +08:00
lc 605859b12f docs: 交付供应商历史分离契约(#6474)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:44:13 +08:00
Mimingguang 734a9b1112 chore(changelog): 回写 #6518 供应商草稿即生成编号 verified(d546912c/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:38:45 +08:00
lc 20272bb706 docs: 下发供应商草稿编号契约(#6518)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:28:01 +08:00
Mimingguang 59090c889b chore(changelog): 回写 #6476 供应商详情地址备注 verified(6635a2ae/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:03:03 +08:00
lc 07387e506f docs(changelog): 记录 #6476 供应商表单契约
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 15:41:38 +08:00
Mimingguang e4f7c5cb0a chore(changelog): 回写 #6436 供应商敏感字段完整回显 verified(5957c58c/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 12:31:26 +08:00
lc cf3fe30a0f docs: 交付 #6436 供应商完整字段契约
changelog-filename-gate / validate (push) Successful in 3s
2026-08-27 11:42:09 +08:00
Mimingguang e91101c037 docs(changelog): 回写 #6397 amount String 化前端 verified
changelog-filename-gate / validate (push) Successful in 2s
前端 002475b2 已按字符串重新集成 PR #6450 修正(响应 contracts[].amount Number→String),fillForm toContractAmount 归一 number、详情列透传、提交侧 Number 不变,补 String 回填测试。
2026-08-27 00:25:29 +08:00
API Changelog Bot ebc2fecbfd docs(changelog): #6397 全面对齐模板——逐接口自包含详情 + 五、数据库行为 + 九、相关历史PR
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 00:04:27 +08:00
API Changelog Bot e362441e9c docs(changelog): #6397 接口详情子节编号化 + 补六.6/六.7 章节 2026-08-26 23:58:34 +08:00
API Changelog Bot 81aab7abb0 docs(changelog): #6397 对齐 CHANGELOG_TEMPLATE 必备章节(二/三/四/六/七/八/十) 2026-08-26 23:56:51 +08:00
API Changelog Bot 4f81ef1cf9 fix(changelog): #6397 响应 contracts[].amount Number→String 同日修正 (PR #6450)
后端每日审查红线修复: 合同金额 BigDecimal 补 ToStringSerializer,输出字符串化与全站金额对齐;
frontend_status 回退 pending 待 mmg 按字符串重新集成,请求侧 Number 不变。
2026-08-26 23:53:04 +08:00
lc ca11783f72 Merge pull request 'fix(changelog): 按 TEST 代码修正行政区划契约 (#6438)' (#79) from docs/6438-region-changelog-code-sync into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 18:01:45 +08:00
共修改 6 个文件,包含 2678 行新增和 60 行删除
@@ -1,41 +1,53 @@
--- ---
schema: "hl-changelog/v2" schema: "hl-changelog/v2"
ticket: "6397" ticket: "6397"
title: "供应商注册合同聚合信息" title: "供应商注册合同聚合信息(已废弃)"
consumer: "admin" consumer: "admin"
author: "lc(GIT)" author: "lc(GIT)"
change_type: "修改接口" change_type: "修改接口"
backend_status: "deployed" backend_status: "deployed"
gateway_status: "verified" gateway_status: "verified"
frontend_status: "verified" frontend_status: "pending"
frontend_owner: "mmg" frontend_owner: "mmg"
frontend_ref: "f2a4200d" frontend_ref: "pending"
target_release: "" target_release: "v2.1"
verified_at: "2026-08-26" verified_at: "2026-08-29"
status_note: "PR #6405 已合并 dev-v3;Deploy Panel 任务 af3f205b 成功发布 Resource 双实例,合同接口已完成 23 项真实 Gateway 验收。TEST 工作副本存在服务器本地提交导致精确提交回读仍被阻塞,后端工单保持开启;本条只交接已经实测存在的接口契约。" status_note: "本条记录的 #6405 合同随供应商草稿/注册聚合写入语义已被业务纠正并废弃,不再是当前可联调契约。当前后端以 #6544 为准:add/update/submit 对旧 contracts 输入兼容忽略,合同只在已有 supplierId 后通过三个独立接口维护,不进入建档审批;详情继续只读返回 contracts。最终 Resource 已通过任务 ca2fe4d7 部署提交 c579c87014f56452fea2fce5075b31ae6fd12a35。旧前端 002475b2 仅作为历史记录,当前消费状态恢复 pending。"
updated_at: "2026-08-26" updated_at: "2026-08-29"
base: "dev-v3" base: "dev-v3"
--- ---
# 🔧 供应商注册合同聚合信息 # ⚠️ 供应商注册合同聚合信息(已废弃)
供应商创建草稿、提交注册和基础信息详情现统一支持合同完整快照。管理端应把“合同信息”放在“资质证照”之后、现有账户区域之前,并把原“初始账户”展示标题改为“结算信息”。 > **2026-08-29 纠正:本文以下聚合写入说明仅保留为历史,不得继续用于联调或实现。** 当前契约见 [#6544 供应商合同独立登记](../2026-08/29_6544_供应商合同独立登记-新增接口-管理后台.md):供应商草稿、资料更新和注册提交对旧 `contracts` 输入兼容接收但完全忽略;合同必须在取得 `supplierId` 后,通过独立 add/update/del 接口和独立事务维护,且不进入建档审批。`basic-info/view` 仍只读返回未软删合同,`initialAccounts` 字段保持不变。
原前端提交 `002475b2` 消费的是已撤销的聚合写语义,只作为历史事实保留,当前状态为待按独立接口重新接入。
展示名调整不改变接口字段:原请求字段仍为 `initialAccounts`,不得改成 `settlementInfo` 或其他名称。 展示名调整不改变接口字段:原请求字段仍为 `initialAccounts`,不得改成 `settlementInfo` 或其他名称。
## 变更接口清单 ## 二、变更接口清单(历史,禁止用于当前联调)
| # | 接口 | 方法 | 路径 | 变化 | | # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---| |---:|---|---|---|---|---|
| 1 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 请求可选增加 `contracts` 完整集合 | | 1 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 请求体新增可选字段 | 可选增加 `contracts` 完整集合 |
| 2 | 提交供应商注册 | POST | `/admin/supplier/items/{supplierId}/submit` | 请求可选增加 `contracts` 完整快照 | | 2 | 提交供应商注册 | POST | `/admin/supplier/items/{supplierId}/submit` | 请求体新增可选字段 | 可选增加 `contracts` 完整快照 |
| 3 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应增加 `contracts` 列表 | | 3 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应新增字段 | 响应增加 `contracts` 列表 |
统一响应均为 `Result<T>`。业务失败可能仍为 HTTP 200,调用方必须同时判断 `code`、`success`、`message` 和 `data`。 统一响应均为 `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 +65,7 @@ base: "dev-v3"
| `scanFileUrl` | String | 否 | 否 | 合同扫描件永久地址,最长 1000 字符 | | `scanFileUrl` | String | 否 | 否 | 合同扫描件永久地址,最长 1000 字符 |
| `remark` | String | 否 | 否 | 最长 500 字符 | | `remark` | String | 否 | 否 | 最长 500 字符 |
### 响应字段 `contracts[]` #### 响应字段 `contracts[]`
详情返回上述全部业务字段,并额外返回: 详情返回上述全部业务字段,并额外返回:
@@ -62,20 +74,40 @@ base: "dev-v3"
| `contractId` | String | 合同 ID,始终按字符串处理,不得转 JavaScript Number | | `contractId` | String | 合同 ID,始终按字符串处理,不得转 JavaScript Number |
| `updateTime` | String | 合同当前版本,格式 `yyyy-MM-dd HH:mm:ss` | | `updateTime` | String | 合同当前版本,格式 `yyyy-MM-dd HH:mm:ss` |
响应中的 `amount` 为 String(如 `"1200.50"`,PR #6450 起),请求中的 `amount` 仍按 Number 传。
历史数据可能返回只读状态 `TERMINATED`;创建和提交请求不得发送该状态。 历史数据可能返回只读状态 `TERMINATED`;创建和提交请求不得发送该状态。
## 1. 创建供应商注册草稿 ### 1. 创建供应商注册草稿 `POST /admin/supplier/items/add`
`POST /admin/supplier/items/add` **VO**: `SupplierDraftSaveReqVO`(请求;响应 data 字段见下表)
### 使用场景与边界 #### 使用场景
- `contracts` 可省略、为 `null` 或空数组,旧客户端行为不变。 供应商注册第一步:创建草稿。`contracts` 为本次新增的可选完整集合,随草稿与供应商主体、资质、`initialAccounts` 一起成功或一起失败;旧客户端省略该字段时行为完全不变。
- 非空时最多 100 项,合同与供应商主体、资质和 `initialAccounts` 一起成功或一起失败。
- 创建请求中的每个合同都是新合同,禁止携带 `contractId`。
- 写入仍要求现有供应商创建权限;仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有对应平台权限的身份可执行。
### 典型请求 #### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `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 ```http
POST /admin/supplier/items/add POST /admin/supplier/items/add
@@ -123,7 +155,7 @@ Content-Type: application/json
} }
``` ```
### 成功响应 #### 响应示例
```json ```json
{ {
@@ -141,7 +173,13 @@ Content-Type: application/json
} }
``` ```
### 失败响应:创建携带合同 ID #### 空数据 / 降级响应
`contracts` 省略、为 `null` 或空数组均可正常创建,旧客户端行为不变;创建响应不返回合同明细(合同通过详情接口回显)。草稿无结算账户时 `initialAccounts` 返回空数组 `[]`,不返回 `null`。
#### 错误响应
创建请求携带合同 ID(创建时每个合同都是新项,禁止 `contractId`):
```json ```json
{ {
@@ -154,20 +192,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`:本次不处理合同,保留草稿当前合同。 **VO**: `SupplierDraftSaveReqVO`(请求,含 `expectedUpdateTime` 版本;响应 data 字段见下表)
- `contracts: []`:明确清空当前全部合同。
- 非空数组:作为完整快照;带 `contractId` 的项覆盖当前合同,不带 ID 的项新增,当前已有但数组中遗漏的合同删除。
- 带 ID 的合同必须属于路径中的供应商;不属于当前供应商、重复 ID、非法枚举、负金额或日期逆序均失败。
- `expectedUpdateTime` 仍是供应商聚合并发版本;发生并发修改时调用方应刷新详情后重新组装完整表单。
- 合同快照会进入本次审批资料,但提交注册不会自动改写合同自身的 `status`。
### 典型请求 #### 使用场景
注册第二步:把草稿完整表单(含合同快照)提交审批。`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 ```http
POST /admin/supplier/items/2090300000000063970/submit POST /admin/supplier/items/2090300000000063970/submit
@@ -204,7 +266,7 @@ Content-Type: application/json
"signDate": "2026-08-26", "signDate": "2026-08-26",
"startDate": "2026-09-01", "startDate": "2026-09-01",
"endDate": "2027-08-31", "endDate": "2027-08-31",
"amount": 1200.50, "amount": "1200.50",
"pricingMode": "按团结算", "pricingMode": "按团结算",
"settleCycle": "MONTHLY", "settleCycle": "MONTHLY",
"status": "ACTIVE", "status": "ACTIVE",
@@ -217,7 +279,7 @@ Content-Type: application/json
} }
``` ```
### 成功响应 #### 响应示例
```json ```json
{ {
@@ -238,7 +300,13 @@ Content-Type: application/json
} }
``` ```
### 失败响应:合同不属于当前供应商 #### 空数据 / 降级响应
`contracts` 省略或为 `null` 时保留草稿当前合同,走既有审批流程,无降级差异;`contracts: []` 为明确清空(不是省略),会删除当前全部合同。草稿本身无合同时按无合同提交,不报错。
#### 错误响应
合同不属于当前供应商(或重复 ID、非法枚举、负金额、日期逆序等同族校验失败):
```json ```json
{ {
@@ -251,20 +319,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 ```http
GET /admin/supplier/items/2090300000000063970/basic-info/view GET /admin/supplier/items/2090300000000063970/basic-info/view
Authorization: Bearer <admin-token> Authorization: Bearer <admin-token>
``` ```
无请求体。读取继续要求可信读角色和 `supplier:view` 平台权限。 #### 响应示例
### 成功响应
```json ```json
{ {
@@ -322,7 +420,7 @@ Authorization: Bearer <admin-token>
"signDate": "2026-08-26", "signDate": "2026-08-26",
"startDate": "2026-09-01", "startDate": "2026-09-01",
"endDate": "2027-08-31", "endDate": "2027-08-31",
"amount": 1200.50, "amount": "1200.50",
"pricingMode": "按团结算", "pricingMode": "按团结算",
"settleCycle": "MONTHLY", "settleCycle": "MONTHLY",
"status": "DRAFT", "status": "DRAFT",
@@ -337,9 +435,22 @@ Authorization: Bearer <admin-token>
} }
``` ```
合同按 `contractId` 升序返回,只包含当前有效合同。没有合同时返回空数组 `[]`,不返回 `null`。 #### 空数据 / 降级响应
### 常见失败 没有合同时返回空数组 `"contracts": []`,不返回 `null`;合同按 `contractId` 升序返回,只包含当前有效合同。
#### 错误响应
常见失败(统一 `Result` 包装,HTTP 200):
```json
{
"code": 395001,
"message": "供应商不存在",
"data": null,
"success": false
}
```
| 场景 | `code` | 前端处理 | | 场景 | `code` | 前端处理 |
|---|---:|---| |---|---:|---|
@@ -347,17 +458,13 @@ Authorization: Bearer <admin-token>
| 可信角色或 `supplier:view` 平台权限不足 | `403` | 展示无权限状态 | | 可信角色或 `supplier:view` 平台权限不足 | `403` | 展示无权限状态 |
| 供应商不存在或已删除 | `395001` | 返回列表并刷新 | | 供应商不存在或已删除 | `395001` | 返回列表并刷新 |
## 修改前后对比 #### 业务边界
| 场景 | 修改前 | 修改后 | - 读取要求可信读角色(`ADMIN`/`FINANCE`/`SUPER_ADMIN`)和 `supplier:view` 平台权限。
|---|---|---| - 敏感字段(税号、证件号、手机号)一律脱敏返回,前端不得期待明文。
| 创建草稿 | 请求不能携带合同 | 可选携带完整 `contracts`,与草稿一起成功或失败 | - `updateTime` 是后续提交表单的并发版本,必须原样缓存回传。
| 提交注册 | 提交表单不能维护合同 | 可省略保留、空数组清空或提交完整合同快照 |
| 基础信息 | 不返回合同列表 | 返回完整 `contracts[]` 及字符串 ID、版本 |
| 账户区域标题 | 页面显示“初始账户” | 页面应显示“结算信息”,接口字段仍为 `initialAccounts` |
| 页面区块顺序 | 资质后直接进入账户区域 | 资质证照 → 合同信息 → 结算信息 |
## 兼容性与管理端接入事项 ## 四、契约约束与正确调用方式
1. 在“资质证照”区域之后新增“合同信息”表格,在合同之后显示原账户表格。 1. 在“资质证照”区域之后新增“合同信息”表格,在合同之后显示原账户表格。
2. 原账户表格标题改为“结算信息”;所有请求和响应继续使用 `initialAccounts`,不要改字段名。 2. 原账户表格标题改为“结算信息”;所有请求和响应继续使用 `initialAccounts`,不要改字段名。
@@ -367,15 +474,65 @@ Authorization: Bearer <admin-token>
6. 本次不新增接口路径、权限点或业务错误码;旧客户端省略 `contracts` 时继续可用。 6. 本次不新增接口路径、权限点或业务错误码;旧客户端省略 `contracts` 时继续可用。
7. 管理端源码不在本后端工单中修改,前端状态保持 `pending`,直至完成页签、标题和表格接入并提供前端引用。 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` 与差异检查通过。 - 自动化:供应商定向测试 115 项通过;`hl-resource-service` 全量 2,111 项,0 失败、0 错误,38 项条件跳过;`hl-verify` 与差异检查通过。
- 部署:Deploy Panel API 任务 `af3f205b` 终态 `success`、退出码 0、`has_build_error=false`,未发现 Maven、编译或滚动发布错误。 - 部署:Deploy Panel API 任务 `af3f205b` 终态 `success`、退出码 0、`has_build_error=false`,未发现 Maven、编译或滚动发布错误。
- 健康:`hl-resource-service` 的 8082、8182 双实例运行;Nacos `test` 命名空间两实例均 `healthy=true`、`enabled=true`。 - 健康:`hl-resource-service` 的 8082、8182 双实例运行;Nacos `test` 命名空间两实例均 `healthy=true`、`enabled=true`。
- 真实 Gateway:23 项断言通过,覆盖合同随草稿创建、详情完整回显、字符串 ID、创建携带 ID 失败、普通 ADMIN 越权、提交外部合同 ID 完整回滚、日期逆序零写入和旧客户端省略 `contracts`。 - 真实 Gateway:23 项断言通过,覆盖合同随草稿创建、详情完整回显、字符串 ID、创建携带 ID 失败、普通 ADMIN 越权、提交外部合同 ID 完整回滚、日期逆序零写入和旧客户端省略 `contracts`。
- 同日修正复验(PR #6450):`hl-resource-service` 于 2026-08-26 23:44 滚动发布双实例 UP,jar 构建时间戳与合并提交一致;供应商定向测试 330 项通过;空原因/超长原因的状态变更请求仍由请求校验层返回 400(对外契约不变)。
- 清理:两个临时 DRAFT 均通过业务删除接口软删除并回读为不存在;仅保留不可逆的 CREATE/DELETE 操作审计。 - 清理:两个临时 DRAFT 均通过业务删除接口软删除并回读为不存在;仅保留不可逆的 CREATE/DELETE 操作审计。
- 环境限制:部署前锁定 `origin/dev-v3=1a16a5aec7d0b95ec87e6fb222581060a3135984`,但面板 Git API 回读到服务器本地短提交 `6f7d3ca78`,Gitea 无法解析该对象。接口行为已真实验证,后端工单仍等待测试环境恢复精确远端提交后复验,不能据此宣称最终交付完成。 - 环境限制:部署前锁定 `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 合入。 1. 从最新 `dev-v3` 创建回退分支,执行 `git revert -m 1 --no-edit 1a16a5aec7d0b95ec87e6fb222581060a3135984`,经独立 PR 合入。
@@ -384,6 +541,12 @@ Authorization: Bearer <admin-token>
4. 已保存的合同资料保留,不做破坏性批量清理;回退后旧客户端继续按省略 `contracts` 的路径工作。 4. 已保存的合同资料保留,不做破坏性批量清理;回退后旧客户端继续按省略 `contracts` 的路径工作。
5. 经 Gateway 复测创建、提交、基础信息、未认证、越权、非法合同 ID、失败零写入和旧客户端兼容,并确认双实例与 Nacos 健康。 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) - **Issue**: [#6397](https://git.1814.love:8443/wx/HL/issues/6397)
@@ -0,0 +1,777 @@
---
schema: "hl-changelog/v2"
ticket: "6436"
title: "供应商敏感字段完整回显与主类型"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "5957c58c"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6478、补充 PR #6483/#6486 已合并 dev-v3,最终提交 aa735fb8 已由 Deploy Panel 任务 7a48028c 发布 TEST;真实 TEST 身份已验证完整值、权限投影、失败零写入及测试数据清理。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 🔧 供应商敏感字段完整回显与主类型
供应商管理接口不再用掩码替代已授权管理员需要处理的业务原值,并补齐唯一主类型和注册账户摘要。证明附件仍受独立权限保护,不能因为本次完整值调整而绕过授权或同步读取审计。
## 一、背景
此前详情、账户和审批记录混用了原值、掩码与历史密文回退,草稿类型也缺少稳定的主类型回显,导致管理端无法可靠编辑、审核或回填表单。本次统一以下消费口径:
- 通过既有供应商读取权限后,税号、法人证件、电话、证照号和银行账号返回完整业务值。
- `proofFileUrls` 继续要求 `supplier:account:proof:read`,且只有同步读取审计成功后才返回;无权限时字段不序列化。
- `types[].isPrimary` 与根对象 `primaryTypeCode`、`primaryTypeName` 成为主类型权威回显。
- `*Mask` 字段保留兼容但已废弃,兼容期内与对应完整值字段同值;新代码应使用无 `Mask` 的字段。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应修改 | 返回完整主体、联系人、资质字段及主类型 |
| 2 | 查询供应商账户信息 | GET | `/admin/supplier/items/{supplierId}/account-info/list` | 响应修改 | 返回可读账户完整账号,按权限决定附件字段 |
| 3 | 查询收款账户详情 | GET | `/admin/supplier/bank-accounts/{accountId}/view` | 响应修改 | 返回完整账号,按权限决定附件字段 |
| 4 | 查询供应商审批记录 | GET | `/admin/supplier/items/{supplierId}/approval-records/page` | 请求与响应修改 | 增加目标筛选及完整前后值可用性 |
| 5 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 响应修改 | `initialAccounts` 回显完整账号摘要 |
| 6 | 更新供应商 | PUT | `/admin/supplier/items/{supplierId}/update` | 响应修改 | 稳定回显主类型及完整初始账号摘要 |
| 7 | 提交供应商注册 | POST | `/admin/supplier/items/{supplierId}/submit` | 响应修改 | 审批结果增加完整初始账号摘要 |
## 三、接口详情
### 1. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
供应商详情和编辑表单初始化。管理端可直接使用完整字段,并用主类型字段初始化单选控件。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID |
| `tax_no` | String | 完整主体证件号,JSON 名保持既有口径 |
| `legalRepresentativeIdNo` | String/null | 完整法人居民身份证号 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | String/null | 完整永久文件地址 |
| `contactPhone` | String/null | 完整公司联系电话 |
| `contacts[].contactPhone` | String | 完整联系人电话 |
| `qualifications[].certNo` | String/null | 完整证照编号 |
| `types[].isPrimary` | Boolean | 当前类型是否为主类型 |
| `primaryTypeCode` / `primaryTypeName` | String/null | 主类型值和展示名称;无类型草稿为 null |
| `legalRepresentativeIdNoMask` / `contactPhoneMask` | String/null | 废弃兼容别名,与完整值字段同值 |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"fullName": "示例旅行服务有限公司",
"tax_no": "91350211M000100Y46",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdNoMask": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/front.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/back.jpg",
"contactPhone": "13800138000",
"contactPhoneMask": "13800138000",
"types": [{"typeCode": "HOTEL", "typeName": "酒店", "isPrimary": true}],
"primaryTypeCode": "HOTEL",
"primaryTypeName": "酒店",
"contacts": [{"contactName": "示例联系人", "contactPhone": "13900139000", "contactPhoneMask": "13900139000"}],
"qualifications": [{"qualType": "BUSINESS_LICENSE", "certNo": "LIC-2026-001", "certNoMask": "LIC-2026-001"}],
"updateTime": "2026-08-27 11:30:00"
}
}
```
#### 空数据 / 降级响应
无类型草稿返回 `types: []`、`primaryTypeCode: null`、`primaryTypeName: null`。联系人或资质为空时返回空数组;历史停用类型的名称无法从字典解析时,名称回退为类型值,不阻断详情。
#### 错误响应
```json
{
"code": 395001,
"message": "供应商不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 要求既有 `supplier:view` 和数据范围校验,登录态不能替代业务权限。
- 普通应用日志、异常和 Trace 不记录上述完整值。
- `*Mask` 仅用于旧客户端兼容,新代码不得继续依赖掩码语义。
### 2. 查询供应商账户信息 `GET /admin/supplier/items/{supplierId}/account-info/list`
**VO**: `SupplierAccountInfoRespVO`
#### 使用场景
在供应商结算信息区域展示已进入 PENDING、ACTIVE 或 DISABLED 的管理端可读账户。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID |
| `bankAccounts` | Array | 可读账户,默认账户优先 |
| `bankAccounts[].accountNo` | String | 完整银行账号 |
| `bankAccounts[].accountNoMask` | String | 废弃兼容别名,与 `accountNo` 同值 |
| `bankAccounts[].proofFileUrls` | Array | 有独立权限且审计成功时出现;无权限时整个字段省略 |
| `bankAccounts[].status` | String | `PENDING`、`ACTIVE` 或 `DISABLED` |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/account-info/list
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"bankAccounts": [{
"accountId": "2092800000000000011",
"accountName": "示例旅行服务有限公司",
"bankName": "示例银行",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"proofFileUrls": ["https://files.example.com/supplier/account-proof.pdf"],
"status": "ACTIVE",
"isDefault": "YES"
}],
"updateTime": "2026-08-27 11:31:00"
}
}
```
#### 空数据 / 降级响应
仅有 DRAFT 账户或没有账户时返回 `bankAccounts: []`。无 `supplier:account:proof:read` 时账号仍为完整值,但每个账户均省略 `proofFileUrls`,不是返回空数组。
#### 错误响应
证明附件同步审计不可用时失败关闭,不返回任何附件:
```json
{
"code": 395039,
"message": "暂时无法校验资源,请稍后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 要求 `supplier:view`;附件另需 `supplier:account:proof:read`。
- 每次获准的附件读取都会先同步写安全审计,失败时整个请求失败。
- DRAFT 初始账户不会出现在该读取接口中,应使用创建/更新响应的 `initialAccounts` 展示草稿摘要。
### 3. 查询收款账户详情 `GET /admin/supplier/bank-accounts/{accountId}/view`
**VO**: `SupplierBankAccountDetailRespVO`
#### 使用场景
查看单个已进入可读状态的收款账户完整信息。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `accountId` | Path | String | 是 | 正整数 ID 字符串 | 账户 ID |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `accountId` | String | 账户 ID |
| `accountName` | String | 收款户名 |
| `accountNo` | String | 完整银行账号 |
| `accountNoMask` | String | 废弃兼容别名,与完整账号同值 |
| `proofFileUrls` | Array | 有独立权限且同步审计成功时出现,否则省略 |
| `status` / `isDefault` | String | 账户状态与默认标记 |
#### 请求示例
```http
GET /admin/supplier/bank-accounts/2092800000000000011/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"accountId": "2092800000000000011",
"accountName": "示例旅行服务有限公司",
"accountType": "CORPORATE",
"bankName": "示例银行",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"proofFileUrls": ["https://files.example.com/supplier/account-proof.pdf"],
"settleMode": "PREPAY",
"invoiceType": "NONE",
"status": "ACTIVE",
"isDefault": "YES",
"updateTime": "2026-08-27 11:31:00"
}
}
```
#### 空数据 / 降级响应
无附件权限时响应省略 `proofFileUrls`;DRAFT、已删除或不存在的账户按不可读处理,不返回草稿详情。
#### 错误响应
```json
{
"code": 395001,
"message": "供应商不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 完整账号沿用基础查看权限,证明附件使用独立权限和同步审计。
- 账户必须属于未删除供应商且状态为 PENDING、ACTIVE 或 DISABLED。
- 无附件权限时后端读取阶段即排除附件字段,不是先读取后隐藏。
### 4. 查询供应商审批记录 `GET /admin/supplier/items/{supplierId}/approval-records/page`
**VO**: `SupplierApprovalRecordPageReqVO / PageResult<SupplierApprovalRecordRespVO>`
#### 使用场景
统一查看供应商主体与收款账户的变更历史,并区分完整新记录和不可恢复的历史掩码记录。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `page` / `limit` | Query | Integer | 否 | 正整数,`limit` 不超过 200 | 分页参数 |
| `approvalLogId` | Query | String | 否 | 正整数 ID 字符串 | 精确筛选审批 |
| `operationType` | Query | String | 否 | `CREATE/UPDATE/ENABLE/DISABLE/DELETE` | 操作类型 |
| `targetType` | Query | String | 否 | `SUPPLIER/ACCOUNT` | 本次增加的目标筛选 |
| `fieldName` / `status` | Query | String | 否 | 当前接口枚举 | 字段或状态筛选 |
| `from` / `to` | Query | String | 否 | 必须成对,`yyyy-MM-dd HH:mm:ss` | 时间范围 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `records[].targetType` | String | `SUPPLIER` 或 `ACCOUNT` |
| `records[].targetId` | String/null | 主体记录为 null,账户记录为账户 ID |
| `records[].oldValue` / `newValue` | String/null | 完整中文业务摘要,附件仍按独立权限投影 |
| `records[].valueAvailability` | String | `FULL` 或 `LEGACY_MASKED_UNRECOVERABLE` |
| `records[].oldValueMasked` / `newValueMasked` | String/null | 废弃兼容别名 |
| `total` / `page` / `pageSize` | Integer | 分页元数据 |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/approval-records/page?page=1&limit=20&targetType=ACCOUNT
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [{
"supplierId": "2092800000000000001",
"operationType": "ENABLE",
"targetType": "ACCOUNT",
"targetId": "2092800000000000011",
"oldValue": "账户状态:待审批;收款账号:6222021234567890",
"newValue": "账户状态:已生效;收款账号:6222021234567890",
"valueAvailability": "FULL",
"status": "已生效",
"operatorName": "测试管理员",
"operatorRole": "超级管理员",
"createTime": "2026-08-27 11:32:00"
}],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 降级响应
无匹配记录返回 `records: []` 和 `total: 0`。历史记录只保存掩码且无法恢复原值时,`valueAvailability` 返回 `LEGACY_MASKED_UNRECOVERABLE`,不会猜测或拼造完整值。
#### 错误响应
```json
{
"code": 400,
"message": "目标类型仅支持SUPPLIER或ACCOUNT",
"data": null,
"success": false
}
```
#### 业务边界
- 要求 `supplier:approval:read` 及供应商数据范围权限。
- 证明附件只有独立权限存在且同步审计成功时才进入完整摘要。
- 分页内主体记录和账户记录按创建时间、记录 ID 稳定排序。
### 5. 创建供应商注册草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO / SupplierWriteRespVO`
#### 使用场景
创建供应商草稿并可同时保存一个初始收款账户;成功响应直接回显该账户的完整账号摘要。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `fullName` | Body | String | 是 | 非空白,最长 500 字符 | 供应商全称 |
| `taxNo` | Body | String | 是 | 6 至 64 字符 | 主体证件号 |
| `types` | Body | Array | 否 | 草稿可为空,非空不得重复 | 供应商类型,首项成为主类型 |
| `mainCooperation` | Body | String | 是 | 非空白 | 主要合作内容 |
| `initialAccounts` | Body | Array | 否 | 最多 1 项 | 初始收款账户完整输入 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 新供应商 ID |
| `status` | String | `DRAFT` |
| `initialAccounts[].accountId` | String | 初始账户 ID |
| `initialAccounts[].accountNo` | String | 完整银行账号 |
| `initialAccounts[].accountNoMask` | String | 废弃兼容别名,与完整账号同值 |
| `initialAccounts[].status` | String | 创建时为 `DRAFT` |
| `updateTime` | String | 并发版本时间 |
#### 请求示例
```json
{
"fullName": "示例旅行服务有限公司",
"taxNo": "91350211M000100Y46",
"types": [{"typeCode": "HOTEL"}],
"mainCooperation": "酒店资源合作",
"initialAccounts": [{
"accountType": "CORPORATE",
"bankName": "示例银行",
"accountNo": "6222021234567890",
"proofFileUrls": ["https://files.example.com/supplier/account-proof.pdf"],
"settleMode": "PREPAY",
"invoiceType": "NONE"
}]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"supplierNo": null,
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [{
"accountId": "2092800000000000011",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"status": "DRAFT"
}],
"updateTime": "2026-08-27 11:30:00"
}
}
```
#### 空数据 / 降级响应
省略 `initialAccounts` 或传空数组时成功创建并返回 `initialAccounts: []`。草稿可传 `types: []`,此时详情的主类型字段为 null。
#### 错误响应
```json
{
"code": 395002,
"message": "无权执行该供应商写操作",
"data": null,
"success": false
}
```
#### 业务边界
- 仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有 `supplier:create` 的身份可写;`ADMIN` 明确拒绝。
- 初始账户最多一项,完整请求原子成功或失败,失败不留下主体或子项。
- 新写入和新审计使用完整业务值,但普通日志、异常、Trace 和跨服务消息不得携带这些值。
### 6. 更新供应商 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO / SupplierWriteRespVO`
#### 使用场景
增量更新供应商草稿或可变字段,并获得稳定的主类型与初始账户摘要回显。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `types` | Body | Array | 否 | 省略表示不改;空数组表示清空草稿类型 | 类型完整快照 |
| `changeReason` | Body | String | 是 | 非空白 | 变更原因 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 乐观并发版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` / `status` | String | 供应商 ID 和当前状态 |
| `initialAccounts[].accountNo` | String | 既有初始账户完整账号 |
| `initialAccounts[].accountNoMask` | String | 废弃兼容别名 |
| `updateTime` | String | 更新后的并发版本 |
#### 请求示例
```json
{
"types": [{"typeCode": "HOTEL", "isPrimary": true}],
"changeReason": "调整主合作类型",
"expectedUpdateTime": "2026-08-27 11:30:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [{
"accountId": "2092800000000000011",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"status": "DRAFT"
}],
"updateTime": "2026-08-27 11:35:00"
}
}
```
#### 空数据 / 降级响应
省略 `types` 保持现有类型;草稿传 `types: []` 会清空类型并在详情返回 null 主类型。响应没有初始账户时固定返回空数组。
#### 错误响应
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有 `supplier:update` 的身份可写。
- 非空类型集合稳定保留且恰有一个主类型;提交审批时仍要求至少一个类型。
- 并发版本、状态或权限不满足时失败且零写入。
### 7. 提交供应商注册 `POST /admin/supplier/items/{supplierId}/submit`
**VO**: `SupplierSubmitReqVO / SupplierApprovalCommandRespVO`
#### 使用场景
提交完整注册表单并进入审批;审批响应直接携带本次注册初始账户的完整摘要。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 草稿供应商 |
| `fullName` / `taxNo` | Body | String | 是 | 完整注册表单约束 | 主体信息 |
| `types` | Body | Array | 是 | 至少一项且不得重复 | 第一项为主类型 |
| `initialAccounts` | Body | Array | 否 | 最多一项;省略可保留既有草稿账户 | 初始账户完整快照 |
| `expectedUpdateTime` | Body | String | 是 | 必须等于当前版本 | 并发围栏 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `approvalLogId` / `requestNo` | String | 审批记录与幂等请求号 |
| `provider` / `approvalStatus` / `syncStatus` | String | 审批通道、结果与本地应用状态 |
| `initialAccounts[].accountId` | String | 本次注册关联账户 ID |
| `initialAccounts[].accountNo` | String | 完整银行账号 |
| `initialAccounts[].accountNoMask` | String | 废弃兼容别名 |
| `initialAccounts[].status` | String | 审批通过后为 `ACTIVE` |
#### 请求示例
```json
{
"fullName": "示例旅行服务有限公司",
"taxNo": "91350211M000100Y46",
"types": [{"typeCode": "HOTEL"}],
"mainCooperation": "酒店资源合作",
"licenseImageUrl": "https://files.example.com/supplier/license.jpg",
"qualifications": [{
"qualType": "BUSINESS_LICENSE",
"certNo": "LIC-2026-001",
"imageUrl": "https://files.example.com/supplier/license.jpg",
"permanentValid": true
}],
"expectedUpdateTime": "2026-08-27 11:35:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"approvalLogId": "2092800000000000021",
"requestNo": "SUP-REQ-EXAMPLE",
"provider": "LOCAL_AUTO",
"approvalStatus": "APPROVED",
"syncStatus": "APPLIED",
"initialAccounts": [{
"accountId": "2092800000000000011",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"status": "ACTIVE"
}],
"submittedAt": "2026-08-27 11:36:00",
"finishedAt": "2026-08-27 11:36:00"
}
}
```
#### 空数据 / 降级响应
没有初始账户时返回 `initialAccounts: []`。提交仍必须包含非空类型和当前完整注册资料,不因草稿阶段允许空类型而放宽。
#### 错误响应
```json
{
"code": 395008,
"message": "请至少选择一个类型并设置唯一主类型",
"data": null,
"success": false
}
```
#### 业务边界
- 仅可信 `FINANCE`、`SUPER_ADMIN` 且同时拥有更新和提交权限的身份可执行。
- 审批准备、结果应用、主体状态、账户状态和完整审计保持原有事务与幂等语义。
- 权限、状态、摘要或并发围栏失败时不允许部分写入。
## 四、契约约束与正确调用方式
| 场景 | 正确处理 |
|---|---|
| 主类型绑定 | 使用 `primaryTypeCode`;类型列表使用 `types[].isPrimary`,不要自行取第一项猜测 |
| 完整字段 | 使用 `tax_no`、`legalRepresentativeIdNo`、`contactPhone`、`certNo`、`accountNo` |
| 废弃别名 | `legalRepresentativeIdNoMask`、`contactPhoneMask`、`certNoMask`、`accountNoMask` 仅作旧客户端兼容 |
| 证明附件无权限 | `proofFileUrls` 字段不存在;不要把缺字段当接口异常或空附件 |
| 草稿账户 | 从写响应 `initialAccounts` 获取;账户读取接口只返回 PENDING/ACTIVE/DISABLED |
| 业务失败 | HTTP 状态之外必须检查 `code`、`success`、`message` 和 `data` |
管理端不得把业务请求体中的身份或角色作为授权依据,也不得缓存其他管理员读取到的完整敏感值供当前会话复用。
## 五、数据库行为
| 外部动作 | 可观察结果 |
|---|---|
| 创建/更新供应商 | 主体、联系人、资质、类型和初始账户在同一业务事务中成功或失败 |
| 注册提交 | 审批结果成功应用后主体和初始账户进入可读生效状态,响应返回完整账户摘要 |
| 新增或变更敏感值 | 后续授权读取返回与提交一致的完整业务值,唯一冲突或非法值整次失败 |
| 失败请求 | 未认证、无权、缺参、非法状态、并发冲突和审计失败均不留下部分业务写入 |
| 历史数据 | 可恢复历史值继续读取;只剩不可逆掩码的审批历史显式标记为不可恢复 |
这些是接口可观察行为;调用方不依赖具体存储结构,也不应自行维护明文/密文兼容状态。
## 六、边界行为
- 未登录经 Gateway 返回 `401`。
- `ADMIN` 可在既有查看权限与数据范围内读取完整业务值,但写入返回 `395002`。
- `FINANCE`、`SUPER_ADMIN` 仍需同时拥有对应平台权限才能写,角色名称本身不是唯一授权条件。
- 附件独立权限不足时字段省略;同步审计失败时请求失败,不降级泄露附件。
- 列表 `limit=201`、缺少必填字段或非法状态返回业务失败,并保持零写入。
- Snowflake ID 继续按字符串处理;时间格式继续为 `yyyy-MM-dd HH:mm:ss`。
## 六.5、枚举 / 数据字典
### `targetType` / `valueAvailability`
| 字段 | 值 | 中文与说明 |
|---|---|---|
| `targetType` | `SUPPLIER` | 供应商主体变更,`targetId` 为 null |
| `targetType` | `ACCOUNT` | 收款账户变更,`targetId` 为账户 ID |
| `valueAvailability` | `FULL` | 完整业务前后值可用 |
| `valueAvailability` | `LEGACY_MASKED_UNRECOVERABLE` | 历史只剩不可逆掩码,不推测原值 |
### 账户可读状态
| 值 | 中文 | 说明 |
|---|---|---|
| `PENDING` | 待审批 | 账户可在管理端读取 |
| `ACTIVE` | 已生效 | 正常可用账户 |
| `DISABLED` | 已停用 | 保留只读历史信息 |
| `DRAFT` | 草稿 | 不进入账户列表和账户详情 |
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| 税号、法人证件号、电话、证照号 | 主要返回掩码或混合口径 | 授权读取返回完整值 |
| `accountNo` | 可能为空或仅依赖 `accountNoMask` | 返回完整账号 |
| `*Mask` | 表示掩码 | 废弃兼容别名,暂与完整值同值 |
| `proofFileUrls` | 权限语义分散 | 独立权限 + 同步审计;无权时字段省略 |
| `types[].isPrimary` | 主类型回显不稳定 | 明确 Boolean 标记 |
| `primaryTypeCode/Name` | 不存在 | 根对象直接返回,空类型草稿为 null |
| `initialAccounts` | 写/提交响应摘要不完整 | 返回账户 ID、完整账号和状态 |
| 审批记录 | 主体与账户口径分散 | 统一目标类型、目标 ID、完整值和可用性 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 编辑页初始化 | 可能需要用掩码字段或猜测主类型 | 可直接绑定完整值与权威主类型 |
| 附件读取 | 容易把空值与无权混淆 | 无权省略字段,审计失败整体失败 |
| 草稿账户展示 | 读取接口与草稿状态语义不清 | 写响应展示草稿摘要,读取接口只展示可读状态 |
| 历史审批 | 无法区分完整值与不可逆掩码 | 通过 `valueAvailability` 明确区分 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。既有字段名保留,废弃别名在兼容窗口内继续返回。
- **前端是否必须同步上线**: 建议尽快。应切换到无 `Mask` 字段、接入主类型字段,并正确处理 `proofFileUrls` 缺失。
- **前端 workaround 清理点**: 删除自行猜主类型、对 `accountNoMask` 二次掩码、把附件缺字段强制转空数组等兼容逻辑。
- **敏感展示责任**: 完整值只在已授权业务页面按最小必要范围展示,不写入前端日志、埋点、错误上报或持久缓存。
## 七、不影响范围
- **仅影响**: 管理后台供应商基本信息、结算账户、注册提交与审批记录消费契约。
- **零影响**:
- 不新增或修改 Gateway 路由。
- 不修改角色、菜单、按钮或数据范围定义。
- 不修改小程序、C 端、订单、产品、车队接口。
- 不修改文件上传接口、Redis、MQ 或跨服务 DTO。
- 不允许 DRAFT 账户通过账户查询接口提前暴露。
## 八、测试环境已验证
最终后端提交 `aa735fb8531a3463b2b460965245e07ed8697775` 已通过 Deploy Panel 任务 `7a48028c` 发布 `hl-resource-service` 双实例,真实 TEST 身份经 Gateway 验证:
```text
历史兼容:完整值与数据库一致,税号关键字搜索命中 ✓
SUPER_ADMIN:创建草稿、详情完整值、注册审批、账号及附件审计读取 ✓
ADMIN:详情与完整账号可读,proofFileUrls 省略,写入拒绝 395002 ✓
未认证 401、缺参 400、limit=201 为 400、非法状态 400 ✓
新写主体/联系人/资质/账户及新审计均为完整值,失败请求零写入 ✓
合成供应商完成精确清理:17 张相关表检查、业务残留 0、失败标记残留 0 ✓
验收会话主动失效,敏感读取操作审计按审计规则保留 ✓
```
获批真实账号没有 `FINANCE` 角色,因此没有伪造该身份;`FINANCE` 与 `SUPER_ADMIN` 的服务端写权限同构由后端自动化测试覆盖,真实 TEST 写入使用 `SUPER_ADMIN`,并用真实 `ADMIN` 验证拒绝路径。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6478 | #6436 | 完整字段、主类型、权限、迁移与兼容读写 | ✅ 主交付 |
| #6483 | #6481 | 修复 TEST 排序规则下回填精确比较 | ✅ 补充修复 |
| #6486 | #6484 | 支持历史 Unicode 税号回填 | ✅ 补充修复 |
## 十、相关文档
- [主工单 #6436](https://git.1814.love:8443/wx/HL/issues/6436)
- [主 PR #6478](https://git.1814.love:8443/wx/HL/pulls/6478)
- [补充工单 #6481](https://git.1814.love:8443/wx/HL/issues/6481) / [PR #6483](https://git.1814.love:8443/wx/HL/pulls/6483)
- [补充工单 #6484](https://git.1814.love:8443/wx/HL/issues/6484) / [PR #6486](https://git.1814.love:8443/wx/HL/pulls/6486)
- 后端详细 API 说明:`docs/supplier/API-CHANGE-6436.html`
## 关联 / 联系人
### 链接
- **Issue**: [#6436](https://git.1814.love:8443/wx/HL/issues/6436)
- **PR**: [#6478](https://git.1814.love:8443/wx/HL/pulls/6478)
- **最终 TEST 提交**: [aa735fb8](https://git.1814.love:8443/wx/HL/commit/aa735fb8531a3463b2b460965245e07ed8697775)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,499 @@
---
schema: "hl-changelog/v2"
ticket: "6474"
title: "供应商详情分离变更记录与审批流水"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "199147de"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6519 已合并 dev-v3;补充缺陷 PR #6534 已恢复旧兼容查询。hl-resource-service 已由 Deploy Panel 任务 9e69fd2c 精确发布提交 aec1de2d 至 TEST,并以真实 SUPER_ADMIN、ADMIN 和受限 CUSTOMIZER 身份完成 Gateway 只读验收。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 供应商管理:详情分离变更记录与审批流水
供应商详情新增两个相互独立的只读分页接口:“变更记录”只返回供应商主体发生过的业务变化,“审批记录”只返回供应商主体审批事实。两个列表独立计数、独立筛选、独立排序,收款账户记录不会混入。
旧 `/admin/supplier/items/{supplierId}/approval-records/page` 继续保留,用于仍需主体与账户变更混合列表的兼容场景;本次不删除、不改名,也不要求现有调用方同步切换。
## 一、背景
旧审批记录接口承载的是主体与收款账户变更的兼容混合列表,不能同时满足详情页“业务变更”和“审批过程”两种独立展示语义。若管理端在本地拆分或二次计数,会出现分页总数不准确、账户记录混入主体历史、审批技术字段误展示等问题。
本次由后端直接提供两个稳定的业务白名单视图:
- “变更记录”展示操作类型、变更前后业务摘要、原因、生命周期状态、操作人、历史角色和发生时间。
- “审批记录”展示审批业务、申请人、状态、动作、审批人安全展示值、意见、提交时间和完成时间。
- 两个接口都在查询前校验可信管理身份、角色和 `supplier:approval:read` 权限。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 查询供应商主体变更记录 | GET | `/admin/supplier/items/{supplierId}/change-records/page` | 新增只读接口 | 独立分页返回主体变更业务摘要 |
| 2 | 查询供应商主体审批流水 | GET | `/admin/supplier/items/{supplierId}/approval-history/page` | 新增只读接口 | 独立分页返回主体审批过程与结果 |
## 三、接口详情
### 1. 查询供应商主体变更记录 `GET /admin/supplier/items/{supplierId}/change-records/page`
**VO**: `SupplierChangeRecordPageReqVO` / `SupplierChangeRecordRespVO`
#### 使用场景
管理端进入供应商详情的“变更记录”页签时调用。服务端完成主体记录筛选、分页和业务摘要投影;前端不要从旧混合列表中再次筛选主体记录,也不要自行拼接前后值摘要。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
| `page` | Query | Integer | 否 | 默认 `1`,最小 `1` | 当前页;兼容别名 `pageNo` |
| `pageSize` | Query | Integer | 否 | 默认 `20`,范围 `1..100` | 每页条数 |
| `operationType` | Query | String | 否 | `CREATE`、`UPDATE`、`ENABLE`、`DISABLE`、`DELETE` | 操作类型精确筛选 |
| `fieldName` | Query | String | 否 | 非空白,最长 64 字符 | 发生变化的业务字段精确筛选 |
| `status` | Query | String | 否 | 生命周期编码 | 按变更后的供应商状态筛选 |
| `from` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss` | 与 `to` 成对传入,按发生时间筛选,含边界 |
| `to` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss`,不得早于 `from` | 与 `from` 成对传入,含边界 |
| `sortBy` | Query | String | 否 | `occurredAt` 或 `changeLogId`,默认 `occurredAt` | 服务端白名单排序字段 |
| `sortDirection` | Query | String | 否 | `ASC` 或 `DESC`,不区分大小写,默认 `DESC` | 排序方向 |
#### 出参 `Result<PageResult<SupplierChangeRecordRespVO>>`
分页对象固定包含 `records`、`total`、`page`、`pageSize`。
| 字段 | 类型 | 说明 |
|---|---|---|
| `records` | Array | 当前页主体变更记录;无数据时为 `[]` |
| `total` | Integer | 符合筛选条件的主体变更总数,不包含账户记录 |
| `page` | Integer | 当前页码 |
| `pageSize` | Integer | 当前页容量 |
| `records[].operationType` | String | 操作类型编码 |
| `records[].oldValue` | String | 变更前中文业务摘要;空值使用明确占位 |
| `records[].newValue` | String | 变更后中文业务摘要;空值使用明确占位 |
| `records[].valueAvailability` | String | `FULL` 或 `LEGACY_MASKED_UNRECOVERABLE` |
| `records[].changeReason` | String | 业务变更原因或稳定占位 |
| `records[].status` | String/null | 变更后的供应商生命周期中文名 |
| `records[].operatorName` | String | 操作人展示名;系统操作为“系统”,依赖降级为“未知管理员” |
| `records[].operatorRole` | String | 操作发生时的角色快照中文名 |
| `records[].occurredAt` | String | 变更发生时间,格式 `yyyy-MM-dd HH:mm:ss` |
响应不会返回变更日志 ID、管理员内部 ID、原始审计 JSON、TraceId、账户 ID、证明附件或其他主体敏感快照字段。
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/change-records/page?page=1&pageSize=20&operationType=UPDATE&sortBy=occurredAt&sortDirection=DESC
Authorization: Bearer <admin-token>
```
GET 请求无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [
{
"operationType": "UPDATE",
"oldValue": "供应商全称:示例旅行服务公司",
"newValue": "供应商全称:示例旅行服务有限公司",
"valueAvailability": "FULL",
"changeReason": "修正供应商主体名称",
"status": "合作中",
"operatorName": "示例管理员",
"operatorRole": "超级管理员",
"occurredAt": "2026-08-27 15:20:00"
}
],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 降级响应
筛选结果为空仍返回成功分页,不回退到旧混合列表:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [],
"total": 0,
"page": 1,
"pageSize": 20
}
}
```
历史记录无法恢复完整原值时,仍返回已有安全摘要,并明确标记:
```json
{
"operationType": "UPDATE",
"oldValue": "138****8000",
"newValue": "139****9000",
"valueAvailability": "LEGACY_MASKED_UNRECOVERABLE",
"changeReason": "历史记录",
"status": "合作中",
"operatorName": "未知管理员",
"operatorRole": "管理员",
"occurredAt": "2026-07-01 10:00:00"
}
```
#### 错误响应
非法筛选、分页、排序或时间范围返回参数错误,例如只传 `from`:
```json
{
"code": 400,
"message": "from和to必须同时传入",
"success": false,
"data": null
}
```
供应商不存在或已删除:
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 要求 Gateway 登录态、可信管理员身份、允许的读角色以及 `supplier:approval:read` 平台权限;任一门禁失败时不查询历史数据。
- 代码角色矩阵沿用 `ADMIN`、`FINANCE`、`SUPER_ADMIN`;角色允许不等于拥有平台权限,两项必须同时满足。
- 只读取供应商主体变更,不包含收款账户变更或审批流水。
- `oldValue`、`newValue` 是后端形成的中文业务摘要,不是可回填编辑表单的结构化快照。
- User 姓名依赖异常只影响 `operatorName`,分页仍成功且不会以管理员内部 ID 降级。
- 接口只读,成功、空数据和失败场景均不修改供应商、审批、缓存或消息状态。
### 2. 查询供应商主体审批流水 `GET /admin/supplier/items/{supplierId}/approval-history/page`
**VO**: `SupplierApprovalHistoryPageReqVO` / `SupplierApprovalHistoryRespVO`
#### 使用场景
管理端进入供应商详情的“审批记录”页签时调用。该列表只表达主体审批申请、过程和结果,不包含主体字段变更摘要,也不包含收款账户审批。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
| `page` | Query | Integer | 否 | 默认 `1`,最小 `1` | 当前页;兼容别名 `pageNo` |
| `pageSize` | Query | Integer | 否 | 默认 `20`,范围 `1..100` | 每页条数 |
| `bizType` | Query | String | 否 | 大写字母开头,仅大写字母、数字、下划线,最长 32 字符 | 审批业务类型精确筛选 |
| `approvalStatus` | Query | String | 否 | 大写字母开头,仅大写字母、数字、下划线,最长 20 字符 | 审批状态精确筛选 |
| `action` | Query | String | 否 | 大写字母开头,仅大写字母、数字、下划线,最长 32 字符 | 审批动作或结果精确筛选 |
| `from` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss` | 与 `to` 成对传入,按提交时间筛选,含边界 |
| `to` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss`,不得早于 `from` | 与 `from` 成对传入,含边界 |
| `sortBy` | Query | String | 否 | `submittedAt` 或 `finishedAt`,默认 `submittedAt` | 服务端白名单排序字段 |
| `sortDirection` | Query | String | 否 | `ASC` 或 `DESC`,不区分大小写,默认 `DESC` | 排序方向 |
#### 出参 `Result<PageResult<SupplierApprovalHistoryRespVO>>`
分页对象固定包含 `records`、`total`、`page`、`pageSize`。
| 字段 | 类型 | 说明 |
|---|---|---|
| `records` | Array | 当前页主体审批记录;无数据时为 `[]` |
| `total` | Integer | 符合筛选条件的主体审批总数,不包含账户审批 |
| `page` | Integer | 当前页码 |
| `pageSize` | Integer | 当前页容量 |
| `records[].bizType` | String | 审批业务类型编码 |
| `records[].bizTypeName` | String | 审批业务类型中文名;未知编码显示“其他供应商审批” |
| `records[].applicantName` | String | 申请人展示名;系统申请为“系统”,依赖降级为“未知申请人” |
| `records[].approvalStatus` | String | 审批状态编码 |
| `records[].approvalStatusName` | String | 审批状态中文名;未知编码显示“未知状态” |
| `records[].action` | String/null | 审批动作或结果编码 |
| `records[].actionName` | String | 审批动作中文名;未知编码显示“其他动作” |
| `records[].approverName` | String | 安全展示值:“系统”“待审批”或“外部审批人” |
| `records[].opinion` | String | 审批意见;无意见时为 `—` |
| `records[].submittedAt` | String | 提交时间;历史缺失时使用该审批事实的创建时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `records[].finishedAt` | String/null | 完成时间;审批中为 `null`,格式 `yyyy-MM-dd HH:mm:ss` |
响应不会返回审批日志 ID、申请人内部 ID、requestNo、spNo、审批模板 ID、企微用户 ID、候选或详情摘要、候选或详情 JSON/密文、apply/sync 技术状态。
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/approval-history/page?page=1&pageSize=20&approvalStatus=APPROVED&sortBy=submittedAt&sortDirection=DESC
Authorization: Bearer <admin-token>
```
GET 请求无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [
{
"bizType": "PROFILE_CREATE",
"bizTypeName": "供应商建档审批",
"applicantName": "示例管理员",
"approvalStatus": "APPROVED",
"approvalStatusName": "已通过",
"action": "SYSTEM_AUTO_APPROVE",
"actionName": "系统自动通过",
"approverName": "系统",
"opinion": "—",
"submittedAt": "2026-08-27 14:00:00",
"finishedAt": "2026-08-27 14:00:01"
}
],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 降级响应
不存在符合条件的审批事实时返回成功空分页:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [],
"total": 0,
"page": 1,
"pageSize": 20
}
}
```
申请人姓名依赖不可用时,该记录仍正常返回,`applicantName` 为 `未知申请人`;不会回退为内部管理员 ID。
#### 错误响应
非法枚举、分页、排序或反向时间范围返回参数错误,例如:
```json
{
"code": 400,
"message": "from不能晚于to",
"success": false,
"data": null
}
```
无业务读取权限时返回拒绝结果,且不查询审批历史:
```json
{
"code": 403,
"message": "无权访问供应商数据",
"success": false,
"data": null
}
```
#### 业务边界
- 权限条件与变更记录接口相同:可信管理身份、`ADMIN`/`FINANCE`/`SUPER_ADMIN` 读角色和 `supplier:approval:read` 平台权限缺一不可。
- 只读取供应商主体审批,不包含收款账户审批或主体字段变更记录。
- 时间筛选基于提交时间;历史提交时间为空时使用该审批事实的创建时间。
- 申请人名称每页最多批量补全一次;User 服务异常、空响应或缺失用户时安全降级,整页不返回 500。
- 审批人只返回业务安全展示值,不暴露企微或外部审批系统标识。
- 接口只读,不推进审批状态,不触发审批回调,也不产生缓存、消息或配置副作用。
## 四、契约约束与正确调用方式
### 详情页接入映射
| 页面区域 | 正确接口 | 数据范围 |
|---|---|---|
| 变更记录 | `GET /admin/supplier/items/{supplierId}/change-records/page` | 仅主体变更 |
| 审批记录 | `GET /admin/supplier/items/{supplierId}/approval-history/page` | 仅主体审批 |
| 旧兼容混合列表 | `GET /admin/supplier/items/{supplierId}/approval-records/page` | 主体与账户变更,契约不变 |
### ✅ 正确 / ❌ 错误调用对照
| 场景 | 调用 / 结果 |
|---|---|
| ✅ 分别加载两个页签 | 两条新接口分别维护自己的 `page`、`pageSize`、筛选和 `total` |
| ✅ 查询完整时间区间 | 同时传 `from=2026-08-01 00:00:00` 与 `to=2026-08-31 23:59:59` |
| ✅ 兼容旧页面 | 继续调用旧 `approval-records/page`,无需因本次新增接口修改 |
| ❌ 在前端拆旧混合列表 | 分页后再过滤会得到错误总数,也不能生成审批流水 |
| ❌ 只传一侧时间 | 返回 `400`,不执行历史查询 |
| ❌ 把 `supplierId` 转为 Number | 可能丢失精度;ID 必须始终按 String 传输和比较 |
调用方必须同时检查 `code`、`message`、`success` 和 `data`,不能只用 HTTP 状态判断业务成功。
## 六、边界行为
- 未登录或登录态失效:统一结果业务码 `401`,不进入供应商查询。
- 已登录但角色或平台权限不满足:业务码 `403`,不读取历史。
- `supplierId<=0`、`page<1`、`pageSize` 不在 `1..100`、非法筛选或排序:业务码 `400`。
- 供应商不存在或已软删除:业务码 `395001`。
- `from`、`to` 必须同时传入,且 `from<=to`;时间边界包含起止时刻。
- 请求超过最后一页:返回原请求页码、空 `records` 和真实 `total`,不自动改页。
- 两个新接口的 `total` 分别统计自己的数据集,互不借用;账户变更和账户审批都不进入。
- 旧 `approval-records/page` 的路径、请求、响应及主体/账户混合语义保持兼容。
## 六.5、枚举 / 数据字典
### `operationType`(变更记录操作类型)
**所属字段**: `SupplierChangeRecordPageReqVO.operationType` / `SupplierChangeRecordRespVO.operationType` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `CREATE` | 创建 | 创建供应商主体 |
| `UPDATE` | 更新 | 更新主体业务字段 |
| `ENABLE` | 启用 | 恢复可用状态 |
| `DISABLE` | 停用 | 暂停或禁用合作 |
| `DELETE` | 删除 | 软删除业务事实 |
### `status`(变更后的供应商生命周期)
**所属字段**: `SupplierChangeRecordPageReqVO.status` | **类型**: `String`
| 值 | 中文展示 |
|---|---|
| `DRAFT` | 草稿 |
| `VETTING` | 注册审核中 |
| `ACTIVE` | 合作中 |
| `SUSPENDED` | 暂停合作 |
| `FROZEN` | 已冻结 |
| `BLACKLIST` | 黑名单 |
| `ARCHIVED` | 已归档 |
### `valueAvailability`(变更摘要可用性)
**所属字段**: `SupplierChangeRecordRespVO.valueAvailability` | **类型**: `String`
| 值 | 中文 | 调用方处理 |
|---|---|---|
| `FULL` | 完整业务摘要可用 | 正常展示 `oldValue` / `newValue` |
| `LEGACY_MASKED_UNRECOVERABLE` | 历史原值不可恢复 | 展示现有摘要,并标注其为历史脱敏值;不要提示用户重试 |
### `bizType`(主体审批业务类型)
**所属字段**: `SupplierApprovalHistoryPageReqVO.bizType` / `SupplierApprovalHistoryRespVO.bizType` | **类型**: `String`
| 值 | 中文展示 | 说明 |
|---|---|---|
| `PROFILE_CREATE` | 供应商建档审批 | 注册或建档审批 |
| `STATUS_CHANGE` | 供应商状态变更审批 | 生命周期状态变更审批 |
| 其他合法大写编码 | 其他供应商审批 | 为历史及后续业务保留兼容 |
### `approvalStatus`(主体审批状态)
**所属字段**: `SupplierApprovalHistoryPageReqVO.approvalStatus` / `SupplierApprovalHistoryRespVO.approvalStatus` | **类型**: `String`
| 值 | 中文展示 |
|---|---|
| `PENDING` | 审批中 |
| `APPROVED` | 已通过 |
| `REJECTED` | 已驳回 |
| `CANCELED` / `CANCELLED` | 已撤销 |
| `FAILED` | 失败 |
| 其他合法大写编码 | 未知状态 |
### `action`(主体审批动作或结果)
**所属字段**: `SupplierApprovalHistoryPageReqVO.action` / `SupplierApprovalHistoryRespVO.action` | **类型**: `String`
| 值 | 中文展示 |
|---|---|
| `SUBMIT` | 已提交 |
| `SYSTEM_AUTO_APPROVE` | 系统自动通过 |
| `APPLY_FAIL` | 结果应用失败 |
| `APPROVE` | 通过 |
| `REJECT` | 驳回 |
| `REVOKE` | 撤销 |
| `CANCEL` | 取消 |
| 其他合法大写编码 | 其他动作 |
## 七、不影响范围
- **仅新增**:供应商详情的两个管理端只读分页契约。
- **保持兼容**:旧 `approval-records/page` 继续返回主体与账户变更混合列表。
- **零影响**:供应商详情、创建、更新、提交、归档、暂停合作、拉黑、删除及收款账户管理接口。
- **零影响**:供应商写权限、数据范围、状态机、审批提交/结果应用、事务、锁、幂等、审计与软删除语义。
- **零影响**:数据库结构与历史数据;本次无 migration、数据回填或破坏性数据操作。
- **零影响**:Gateway 顶级路由、Nacos 配置、Redis、MQ 和跨服务写链路。
- 后端仓库未修改任何管理端前端源码;管理端接入状态保持 `pending`。
## 八、测试环境已验证
- 本地自动化:主功能定向测试 79 项零失败;补充空上下文兼容测试套件 51 项零失败;最终 `hl-resource-service` 全量 2158 项零失败、零错误(38 项条件跳过);`GatewayRouteAuditTest` 4 项零失败。
- 合并:主 PR #6519 合并提交 `3dc80ad69630127d91fa973a682051b2ef5c41d8`;验收发现旧兼容接口空上下文缺陷后,补充工单 #6532 / PR #6534 以最小修复合入,最新合并提交为 `aec1de2db2b4ed07d78877c1ccd4e532919bdaf3`。
- TEST 精确部署:Deploy Panel 任务 `9e69fd2c` 成功发布 `aec1de2db2b4ed07d78877c1ccd4e532919bdaf3`;构建退出码 0,任务期 10 次有效采样均至少 2 个运行进程、2 个健康启用 Nacos 实例,零不可用采样,部署窗日志无失败标记。
- 真实 Gateway:使用现有真实 `SUPER_ADMIN` 与同账号可切换的 `ADMIN` 身份,两条新接口的成功分页、字段白名单、独立总数、筛选、排序、边界参数和旧接口兼容均通过;`CUSTOMIZER` 返回 `403`,未认证返回 `401`。
- TEST 数据覆盖:目标供应商存在 18 条主体变更事实和 1 条主体审批事实;对应账户变更、账户审批均为 0,验证两个新接口没有跨域混页。审批样本为 `APPROVED` / `SYSTEM_AUTO_APPROVE`;环境没有外部审批人样本,未伪造业务数据。
- 身份说明:代码角色矩阵仍包含 `ADMIN`、`FINANCE`、`SUPER_ADMIN`。TEST 角色目录存在 `FINANCE`,但当前获批真实账号不能切换到该角色;按工单确认使用现有 `SUPER_ADMIN`(并覆盖 `ADMIN`)替代 FINANCE 实测,不创建、不修改、不伪造财务账号。
- 清理:验收全程只读,供应商数据指纹前后一致,业务测试数据创建数为 0;真实会话均已失效处理,无数据库、Redis、MQ 或临时配置需要清理。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6486 | #6436 | 冻结供应商授权完整值与历史摘要语义 | ✅ 有效,本次沿用 |
| #6519 | #6474 | 新增主体变更记录与主体审批流水独立分页 | ✅ 本次主功能 |
| #6534 | #6532 | 修复旧兼容接口投影空上下文时的空指针 | ✅ 有效,保障历史兼容 |
## 十、相关文档
- 关联 Issue:[#6474](https://git.1814.love:8443/wx/HL/issues/6474)
- 关联 PR:[#6519](https://git.1814.love:8443/wx/HL/pulls/6519)
- 补充缺陷 Issue:[#6532](https://git.1814.love:8443/wx/HL/issues/6532)
- 补充缺陷 PR:[#6534](https://git.1814.love:8443/wx/HL/pulls/6534)
- 管理端接入:将“变更记录”“审批记录”分别切换到两条新接口;前端引用待回填。
## 撤回
1. 管理端先停止请求两个新路径,并恢复使用旧 `approval-records/page`,避免代码回退窗口产生请求失败。
2. 从最新 `dev-v3` 创建独立回退分支,对主功能合并执行 `git revert -m 1 --no-edit 3dc80ad69630127d91fa973a682051b2ef5c41d8`,验证后经独立 PR 合入。
3. #6532 的空上下文修复可独立保留,它修复的是旧兼容接口且不依赖两个新路径。若明确要求连同该修复一起撤回,再按从新到旧顺序执行 `git revert -m 1 --no-edit aec1de2db2b4ed07d78877c1ccd4e532919bdaf3`;这样会重新引入旧兼容接口在特定记录上的 500 风险,不作为推荐方案。
4. 使用 Deploy Panel 两阶段客户端,仅滚动部署 `hl-resource-service`;本次无数据库、配置、Redis 或 MQ 恢复步骤,也无不可逆数据影响。
5. 撤回后经 Gateway 验证两个新路径不可用、旧兼容接口按选定回退范围正常,复测未认证和越权,并确认 Resource 双实例、Nacos、日志及零写入。
6. 同步发布本 Changelog 的撤回说明;不得仅改文件名表达状态。
## 关联 / 联系人
### 链接
- **Issue**: [#6474](https://git.1814.love:8443/wx/HL/issues/6474)
- **PR**: [#6519](https://git.1814.love:8443/wx/HL/pulls/6519)
- **Merge commit**: [`3dc80ad6`](https://git.1814.love:8443/wx/HL/commit/3dc80ad69630127d91fa973a682051b2ef5c41d8)
- **补充 PR**: [#6534](https://git.1814.love:8443/wx/HL/pulls/6534)
- **TEST 目标提交**: [`aec1de2d`](https://git.1814.love:8443/wx/HL/commit/aec1de2db2b4ed07d78877c1ccd4e532919bdaf3)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,347 @@
---
schema: "hl-changelog/v2"
ticket: "6476"
title: "供应商详情补齐地址备注并统一表单校验"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "6635a2ae"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6517 已合并 dev-v3(合并提交 a952a04c);hl-resource-service 已随 dev-v3 精确提交 b269e5fd 由 Deploy Panel 任务 938ca09e 发布 TEST。真实 TEST 身份已验证 address/remark 创建、详情、更新回显,创建/更新同口径校验、权限失败零写入及测试数据清理。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 供应商管理:详情补齐地址备注并统一表单校验
供应商详情接口现在完整返回主体的 `address` 和 `remark`,管理端可用同一份详情快照初始化查看页与编辑表单。创建、提交和更新原本已有的业务字段与校验未增加新的必填项;本工单用契约测试和真实 TEST 验收固定三条写链路的一致口径。
本次没有新增接口路径、权限点或业务错误码。
## 一、背景
创建和更新请求已经接受地址、备注等主体字段,但基础信息详情此前没有回传 `address`、`remark`,导致管理端打开编辑页时无法完整还原已保存表单。同时,创建、提交和更新分属不同请求模型,消费方需要一个明确、可验证的共同校验口径。
本次处理后:
- 详情响应补齐主体地址与内部备注。
- 创建、提交、更新对共同主体字段、类型、联系人、资质和合同继续执行同一业务规则。
- 更新只额外要求变更原因、主体并发版本,以及被修改子项的 ID/版本。
- #6436/#6499 已交付的授权完整值语义保持不变,不重新引入脱敏值或“留空表示保留旧敏感值”的旧约定。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应新增字段 | 根对象新增 `address`、`remark`,用于完整初始化查看与编辑表单 |
创建草稿、提交注册和更新资料的路径、请求字段及错误码没有结构性变化,因此不作为新增接口列入上表;其稳定调用规则在第四节集中说明。
## 三、接口详情
### 1. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
管理端进入供应商详情或编辑页时调用。响应是主体、类型、联系人、资质、合同及并发版本的完整快照;编辑页应直接使用其中的 `address`、`remark` 和 `updateTime`,不得用本地空值覆盖。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
#### 出参 `Result<SupplierBasicInfoRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID |
| `fullName` / `shortName` | String/null | 供应商全称与简称 |
| `tax_no` | String | 完整主体证件号;JSON 字段名保持 `tax_no` |
| `legalRepresentative` | String/null | 法定代表人姓名 |
| `legalRepresentativeIdNo` | String/null | 完整法人居民身份证号 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | String/null | 法人证件正反面永久文件地址 |
| `contactPhone` | String/null | 完整公司联系电话 |
| `establishDate` | String/null | 成立日期,格式 `yyyy-MM-dd` |
| `registeredCapital` / `businessScope` | String/null | 注册资本与经营范围 |
| `address` | String/null | 本次新增回显:注册地址或经营地址 |
| `staffScale` | String/null | 人员规模字典值 |
| `mainCooperation` | String | 主要合作内容 |
| `remark` | String/null | 本次新增回显:供应商主体内部备注 |
| `types` | Array | 类型完整集合;每项包含 `isPrimary` |
| `primaryTypeCode` / `primaryTypeName` | String/null | 当前主类型 |
| `contacts` / `qualifications` / `contracts` | Array | 联系人、资质和合同完整集合;现有项包含字符串 ID 与 `updateTime` |
| `status` | String | 当前供应商状态 |
| `updateTime` | String | 主体并发版本,格式 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"fullName": "示例旅行服务有限公司",
"shortName": "示例旅行",
"tax_no": "91350211M000100Y46",
"legalRepresentative": "示例法人",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdNoMask": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/id-front.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/id-back.jpg",
"contactPhone": "13800138000",
"contactPhoneMask": "13800138000",
"establishDate": "2020-01-01",
"registeredCapital": "100万元",
"businessScope": "境内旅游服务",
"address": "福建省厦门市示例路 1 号",
"staffScale": "LT50",
"mainCooperation": "酒店与景区资源合作",
"remark": "重点合作供应商",
"types": [{"typeCode": "HOTEL", "typeName": "酒店", "isPrimary": true}],
"primaryTypeCode": "HOTEL",
"primaryTypeName": "酒店",
"contacts": [],
"qualifications": [],
"contracts": [],
"status": "DRAFT",
"updateTime": "2026-08-27 15:30:00"
}
}
```
#### 空数据 / 降级响应
历史供应商未填写地址或备注时,字段明确返回 `null`;无类型、联系人、资质或合同时,相应集合返回空数组,不把缺失数据当成接口异常:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"address": null,
"remark": null,
"types": [],
"primaryTypeCode": null,
"primaryTypeName": null,
"contacts": [],
"qualifications": [],
"contracts": [],
"updateTime": "2026-08-27 15:30:00"
}
}
```
#### 错误响应
供应商不存在、已删除或超出数据范围时不返回空详情:
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 要求可信管理身份、`supplier:view` 平台权限和供应商数据范围;登录态不能代替业务权限。
- `address`、`remark` 属于响应的向后兼容增加;旧客户端忽略未知字段即可继续运行。
- `legalRepresentativeIdNoMask`、`contactPhoneMask` 等废弃兼容别名继续与完整值字段同值;新代码使用无 `Mask` 字段。
- Long ID 一律按字符串保存、比较和传输。
- 本接口只读,不修改主体版本、缓存或审批状态。
## 四、契约约束与正确调用方式
以下三个既有写接口没有新增路径或字段,但共同业务口径由本工单固定:
- 创建草稿:`POST /admin/supplier/items/add`
- 提交注册:`POST /admin/supplier/items/{supplierId}/submit`
- 更新资料:`PUT /admin/supplier/items/{supplierId}/update`
### 共同主体字段规则
| 字段 | 创建/提交 | 更新 | 统一约束 |
|---|---|---|---|
| `fullName` | 必填 | 可选 | 最长 500 字符 |
| `shortName` | 可选 | 可选 | 最长 300 字符 |
| `taxNo` | 必填 | 可选,且仅允许在状态规则内修改 | 6 至 64 位字母、数字或展示分隔符;身份证/统一社会信用代码还校验日期或校验位 |
| `legalRepresentative` | 可选 | 可选 | 最长 500 字符 |
| `legalRepresentativeIdNo` | 可选 | 可选 | 18 位合法居民身份证号;与正反面地址按完整三字段组合校验 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | 可选 | 可选 | 公网 HTTPS 永久文件地址,单项最长 1000 字符 |
| `contactPhone` | 可选 | 可选 | 7 至 20 位合法电话字符 |
| `establishDate` | 可选 | 可选 | `yyyy-MM-dd`,不得晚于当前日期 |
| `registeredCapital` | 可选 | 可选 | 最长 50 字符 |
| `businessScope` | 可选 | 可选 | 最长 500 字符 |
| `address` | 可选 | 可选 | 最长 500 字符;创建和更新同口径 |
| `staffScale` | 可选 | 可选 | `LT50`、`R50_200`、`R200_500`、`GT500` |
| `mainCooperation` | 必填 | 可选 | 创建/提交不能为空白;更新传值时按同一业务含义保存 |
| `licenseImageUrl` | 可选 | 可选 | 最长 500 字符,并与营业执照资质归一化 |
| `remark` | 可选 | 可选 | 供应商主体内部备注,不等同于审批意见或 `changeReason` |
集合上限与语义保持不变:`types` 最多 15 项;`contacts`、`qualifications`、`contracts` 各最多 100 项;`initialAccounts` 最多 1 项。非空类型必须来自生效字典且不得重复,非空联系人集合必须形成唯一默认联系人,资质和合同继续执行类型、日期、金额、归属和完整快照校验。
更新场景额外要求:
- `changeReason` 必填、非空白、最长 500 字符,并进入业务审计。
- `expectedUpdateTime` 必须等于详情最新 `updateTime`。
- 已有联系人、资质、合同随完整快照更新时,必须带回对应字符串 ID 和子项 `updateTime`。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload / 结果 |
|---|---|
| ✅ 更新地址和备注 | `{"address":"福建省厦门市示例路 2 号","remark":"地址已确认","changeReason":"更新地址和备注","expectedUpdateTime":"2026-08-27 15:30:00"}` |
| ✅ 只更新备注 | `{"remark":"新的内部备注","changeReason":"补充内部备注","expectedUpdateTime":"2026-08-27 15:30:00"}` |
| ❌ 地址超过 500 字符 | 创建、更新均返回 `400`,零写入 |
| ❌ 成立日期晚于当前日期 | 创建、更新均返回 `400`,零写入 |
| ❌ 更新缺少 `changeReason` | 返回 `400`,零写入 |
| ❌ 使用旧 `expectedUpdateTime` | 返回 `395014`,零写入;必须刷新详情后由用户确认 |
### 正确更新顺序
1. 先查询详情,保存字符串 ID、子项版本和根对象 `updateTime`。
2. 用户提交时只发送实际变更字段,并附非空 `changeReason`、最新 `expectedUpdateTime`。
3. 更新成功后采用响应里的新版本;需要完整表单时重新查询详情。
4. 遇到 `395014` 先刷新,不得自动用旧表单覆盖他人修改。
## 五、数据库行为
- 成功创建、提交或更新时,主体与本次携带的类型、联系人、资质、合同及业务审计按聚合事务一起成功。
- 地址和备注保存后,详情读取与变更审计使用同一业务值;`remark` 不会被提升为审批意见或变更原因。
- 请求校验、权限、状态、归属或并发版本失败时零写入,主体 `updateTime` 不变化。
- 本次没有数据库结构变更、Flyway migration 或历史数据回填;旧记录的地址/备注原值保持不变。
- 不新增 Redis、MQ、Feign 或跨服务副作用。
## 六、边界行为
- 未登录或 Token 失效:`401`,不把无认证响应当作正向验收。
- ADMIN 等无写权限角色调用创建、提交或更新:`395002`,零写入;已有详情仍按读取权限返回。
- 供应商不存在或已删除:`395001`。
- 并发版本过期:`395014`,调用方刷新详情后重提。
- 地址超过 500 字符、未来成立日期、必填字段缺失或组合不完整:`400`,零写入。
- 历史 `address`、`remark` 为空:详情返回 `null`,不报错、不猜测默认值。
- `ARCHIVED` 等不可修改状态继续由既有状态门禁拒绝更新。
- 业务失败可能仍使用 HTTP 200,调用方必须同时判断响应体 `code`、`success`、`data`。
## 六.5、枚举 / 数据字典
### `staffScale`(供应商人员规模稳定值)
**所属字段**: `SupplierDraftSaveReqVO.staffScale / SupplierUpdateReqVO.staffScale / SupplierBasicInfoRespVO.staffScale` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `LT50` | 50 人以下 | 小于 50 人 |
| `R50_200` | 50 至 200 人 | 50 至 200 人档 |
| `R200_500` | 200 至 500 人 | 200 至 500 人档 |
| `GT500` | 500 人以上 | 大于 500 人 |
`types[].typeCode`、`contacts[].contactRole`、`qualifications[].qualType` 继续从对应生效数据字典读取,不在客户端硬编码展示名。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| 详情根对象 `address` | 未返回,已保存值无法用于表单回填 | 返回保存的地址;未填写为 `null` |
| 详情根对象 `remark` | 未返回,已保存值无法用于表单回填 | 返回主体内部备注;未填写为 `null` |
| 创建/提交/更新请求字段 | 已存在 | 无新增字段、无新增必填项 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 打开编辑页 | 详情缺少地址和备注,表单初始化不完整 | 一次详情调用可完整初始化主体字段 |
| 共同字段校验 | 实现存在,但缺少跨创建/更新契约锁定 | 自动化和 TEST 验收固定创建/更新同口径 |
| 完整敏感值回显 | 按 #6436/#6499 返回授权完整值 | 保持不变 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。响应新增两个可空字段,旧客户端可忽略;请求无新增必填项。
- **前端是否必须同步上线**: 为闭合编辑体验需要接入 `address`、`remark`,但不构成旧客户端继续运行的硬门禁。
- **前端 workaround 清理点**: 删除编辑页对地址、备注的本地空值兜底,改为使用详情真实值。
- **QA 重点**: 新建后打开详情、更新后刷新详情、地址长度 500/501、今天/未来成立日期、ADMIN 越权、旧版本并发冲突。
## 七、不影响范围
- **仅影响**: 管理后台供应商详情/编辑表单的主体地址、内部备注回填,以及已有写接口校验口径的契约固定。
- **零影响**:
- Gateway 路由和认证级别。
- 供应商生命周期状态机、审批、收款账户、证明附件、资源关系及归档语义。
- 数据库结构、历史数据、Nacos 配置、Redis、MQ、Feign 和其他服务。
- 小程序、C 端、Web、H5、桌面端接口。
- #6436/#6499 的授权完整值和废弃 `*Mask` 兼容语义。
## 八、测试环境已验证
- 本地自动化:供应商定向测试 49 项通过;最新 `dev-v3` 上 `hl-resource-service` Reactor 全量 2,153 项,0 失败、0 错误、38 项条件跳过。
- 部署:Deploy Panel API 任务 `938ca09e` 终态 `success`;目标与实际提交均为 `b269e5fdca2e1d29e0336314a909885e9d1f9d16`,包含本工单合并提交 `a952a04c`。
- 采样:部署任务期 8 个有效采样;每个采样至少 2 个运行进程、2 个健康启用 Nacos 实例,零观测不可用采样。该证据只证明采样点,不代表采样间绝对连续。
- 真实 Gateway:SUPER_ADMIN 创建完整表单后,详情精确回显 `address`、`remark`、完整授权字段、主类型及联系人/资质/合同 ID 和版本;更新后 API、数据库主体与更新审计一致。
- 失败路径:创建/更新的地址超长均返回 `400`;创建/更新的未来成立日期均返回 `400`;ADMIN 更新返回 `395002`;所有失败均验证零写入、版本不变。
- 清理:临时供应商先经业务删除接口软删除,确认主体已删除且活动子项为 0;随后按本次唯一标记精确清理 8 行测试数据,17 张关联表回读,剩余供应商 0 行;测试会话已注销并验证失效。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6478 / #6483 / #6486 | #6436 | 供应商授权完整值、主类型及同日修正 | ✅ 有效,本次保持 |
| #6517 | #6476 | 详情补齐地址备注并固定表单校验契约 | ✅ 最新 |
## 十、相关文档
- 关联 Issue:[#6476](https://git.1814.love:8443/wx/HL/issues/6476)
- 关联 PR:[#6517](https://git.1814.love:8443/wx/HL/pulls/6517)
- 相关完整值契约:`changelogs-v2/2026-08/27_6436_供应商敏感字段完整回显与主类型-修改接口-管理后台.md`
- 管理端接入:读取详情 `address`、`remark`,更新时保留最新 `expectedUpdateTime`;前端引用待回填。
## 撤回
1. 从最新 `dev-v3` 创建独立回退分支,执行:
```bash
git revert -m 1 --no-edit a952a04cf80f24d6c50cbec4b992c82f8580beba
```
2. 运行供应商定向测试、`hl-resource-service` 全量测试和差异检查,经独立 PR 合入。
3. 使用同一 Deploy Panel 两阶段客户端,仅滚动部署 `hl-resource-service`,并绑定批准回退分支的精确提交。
4. 管理端停止依赖详情响应中的 `address`、`remark`,再撤回本 Changelog;既有请求字段和完整值契约保持不变。
5. 无数据库、配置、Redis 或 MQ 恢复步骤;已保存的地址和备注数据保留,不做破坏性回填或删除。
6. 撤回后经 Gateway 复测详情、创建、更新、未认证、越权、并发冲突和失败零写入,并确认 Resource 双实例与 Nacos 健康。
## 关联 / 联系人
### 链接
- **Issue**: [#6476](https://git.1814.love:8443/wx/HL/issues/6476)
- **PR**: [#6517](https://git.1814.love:8443/wx/HL/pulls/6517)
- **实现提交**: [`3cd07d27`](https://git.1814.love:8443/wx/HL/commit/3cd07d27dc876f17af1751ec7ef9de2ddbd71949)
- **Merge commit**: [`a952a04c`](https://git.1814.love:8443/wx/HL/commit/a952a04cf80f24d6c50cbec4b992c82f8580beba)
- **TEST 目标提交**: [`b269e5fd`](https://git.1814.love:8443/wx/HL/commit/b269e5fdca2e1d29e0336314a909885e9d1f9d16)
### 联系人
- **后端负责人**: @lc
- **管理端负责人**: @mmg(`frontend_status: pending`)
@@ -0,0 +1,308 @@
---
schema: "hl-changelog/v2"
ticket: "6518"
title: "供应商草稿创建即生成编号"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "d546912c"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6531 已合并 dev-v3(合并提交 66aed70a);hl-resource-service 已由 Deploy Panel 任务 e977bf7b 精确发布该提交。真实 TEST 身份完成 47 项 Gateway 断言,验证创建即返回 SUP{supplierId}、列表/详情/更新稳定回显、权限失败零写入、审计事实和业务清理。管理端需停止把新建草稿 supplierNo 当作空值或等待审批后再取。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 🔧 供应商管理:草稿创建即生成编号
`POST /admin/supplier/items/add` 成功后,响应中的 `supplierNo` 现在立即为非空字符串,格式固定为 `SUP{supplierId}`。该编号与本次创建的供应商绑定,后续列表、详情、更新、提交及审批流程均保持同一值。
请求结构、权限点、状态机和业务错误码没有新增或删除。
## 一、背景
此前供应商编号在审批通过时才补齐,因此新建草稿阶段的创建响应、列表和详情可能返回 `supplierNo: null`。管理端若需要展示或引用编号,只能等待审批或使用临时占位。
本次发布后:
- 新建草稿成功即取得正式编号,不再存在“先创建、后编号”的等待窗口。
- 编号格式为字符串 `SUP{supplierId}`;例如 `supplierId="2094000000000000001"` 对应 `supplierNo="SUP2094000000000000001"`。
- 编号在更新、提交、审批和状态变化中不重算、不覆盖。
- 草稿删除后原编号不再分配给其他供应商。
- 发布前已经存在且编号为空的历史记录仍可能返回 `null`;其审批通过时继续兼容补齐。调用方不能自行推导或写入编号。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 响应行为修改 | 成功响应的 `supplierNo` 从草稿期可空改为立即返回 `SUP{supplierId}` |
列表、详情、更新和审批相关响应中的 `supplierNo` 字段结构未变化;对于本次发布后创建的供应商,它们会稳定返回创建响应中的同一编号。
## 三、接口详情
### 1. 创建供应商注册草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO` → `Result<SupplierWriteRespVO>`
#### 使用场景
管理端首次保存供应商注册表单。创建成功后可立即展示、复制或继续传递后端返回的正式 `supplierNo`,无需等待提交审批。
#### 入参
本次没有新增请求字段,`supplierNo` 也不允许由客户端提交。
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `fullName` | Body | String | 是 | 非空,最长 500 | 供应商法定全称 |
| `shortName` | Body | String/null | 否 | 最长 300 | 供应商简称 |
| `taxNo` | Body | String | 是 | 6 至 64 位字母、数字或展示分隔符 | 主体证件号 |
| `types` | Body | Array/null | 否 | 最多 15 项;非空时类型值有效且不得重复 | 供应商类型完整集合 |
| `legalRepresentative` | Body | String/null | 否 | 最长 500 | 法定代表人 |
| `legalRepresentativeIdNo` | Body | String/null | 否 | 为空或合法 18 位居民身份证号 | 法人证件号 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | Body | String/null | 否 | 单项最长 1000 | 法人证件正反面永久地址 |
| `contactPhone` | Body | String/null | 否 | 7 至 20 位合法电话字符 | 公司联系电话 |
| `establishDate` | Body | String/null | 否 | `yyyy-MM-dd`,不得晚于当天 | 成立日期 |
| `registeredCapital` | Body | String/null | 否 | 最长 50 | 注册资本文本 |
| `businessScope` / `address` | Body | String/null | 否 | 单项最长 500 | 经营范围与地址 |
| `staffScale` | Body | String/null | 否 | `LT50`、`R50_200`、`R200_500`、`GT500` | 人员规模 |
| `mainCooperation` | Body | String | 是 | 非空 | 主要合作内容 |
| `licenseImageUrl` | Body | String/null | 否 | 最长 500 | 营业执照影像快捷字段 |
| `remark` | Body | String/null | 否 | - | 供应商内部备注 |
| `contacts` | Body | Array/null | 否 | 最多 100 项 | 联系人完整集合;非空时继续执行角色和唯一默认联系人规则 |
| `qualifications` | Body | Array/null | 否 | 最多 100 项 | 资质完整集合;继续执行类型、证号和有效期规则 |
| `contracts` | Body | Array/null | 否 | 最多 100 项 | 合同完整集合;继续执行日期、金额和状态规则 |
| `initialAccounts` | Body | Array/null | 否 | 最多 1 项 | 初始收款账户集合 |
| `duplicateConfirmToken` | Body | String/null | 否 | 最长 256,一次性使用 | 存在近似主体时按后端返回值二次确认 |
| `expectedUpdateTime` | Body | String/null | 否 | 创建草稿时不传 | 仅已有草稿提交审批时使用 |
#### 出参 `Result<SupplierWriteRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 新建供应商 ID;必须按字符串保存和传输 |
| `supplierNo` | String | 本次行为变化:创建成功立即返回 `SUP{supplierId}`,非空且全生命周期稳定 |
| `status` | String | 新建草稿固定为 `DRAFT` |
| `onboardingStage` | String | 新建草稿固定为 `PROFILE_DRAFT` |
| `initialAccounts` | Array | 初始收款账户摘要;未提交时通常为空数组 |
| `updateTime` | String | 当前并发版本,格式 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
POST /admin/supplier/items/add
Authorization: Bearer <admin-token>
Content-Type: application/json
```
```json
{
"fullName": "示例草原旅行服务有限公司",
"shortName": "示例草原旅行",
"taxNo": "91350211M000100Y46",
"mainCooperation": "酒店与景区资源合作",
"types": [],
"contacts": [],
"qualifications": [],
"contracts": [],
"initialAccounts": []
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2094000000000000001",
"supplierNo": "SUP2094000000000000001",
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [],
"updateTime": "2026-08-27 16:24:30"
}
}
```
#### 空数据 / 降级响应
创建接口没有“成功但 data 为空”的降级分支:创建成功必须返回完整 `SupplierWriteRespVO`,失败则返回明确业务错误。编号的历史空数据兼容只出现在发布前遗留记录的列表或详情读取中;这类尚未补齐的记录仍可能出现:
```json
{
"supplierId": "2089000000000000001",
"supplierNo": null,
"status": "DRAFT"
}
```
管理端可为这种历史空值显示 `—`,但不得本地生成或回写编号。
#### 错误响应
请求缺少必填字段或字段不合法时,业务失败且不创建草稿:
```json
{
"code": 400,
"message": "统一社会信用代码不能为空",
"success": false,
"data": null
}
```
真实身份没有 `supplier:create` 写权限时:
```json
{
"code": 395002,
"message": "无供应商写入权限",
"success": false,
"data": null
}
```
#### 业务边界
- 外部请求必须经过 Gateway;未认证返回业务码 `401`,不能把该负向结果当作正向验收。
- 写操作仅允许既有授权角色并再次校验 `supplier:create`;普通 `ADMIN` 被拒绝且零写入。
- `supplierNo` 只在整个创建成功后返回;任何字段校验、权限、重复主体确认或持久化失败都不会留下可见的半成品编号。
- 创建成功后,列表、详情和更新响应必须返回同一 `supplierNo`。
- 客户端只消费后端响应,不能提交、覆盖、重算、截断或把编号转成数字。
- 业务失败可能仍使用 HTTP 200,必须同时判断响应体 `code`、`success` 和 `data`。
## 四、契约约束与正确调用方式
### 正确消费顺序
1. 提交创建请求并确认 `code=200`、`success=true`。
2. 将 `data.supplierId`、`data.supplierNo`、`data.updateTime` 全部按字符串保存。
3. 页面立即展示 `data.supplierNo`;刷新列表或详情时核对同一字段,不再等待审批。
4. 后续更新继续传最新 `expectedUpdateTime`,但不得把 `supplierNo` 放进请求体。
### ✅ 正确 / ❌ 错误行为对照
| 场景 | 正确行为 |
|---|---|
| ✅ 新草稿创建成功 | 直接显示响应 `supplierNo`,例如 `SUP2094000000000000001` |
| ✅ 列表或详情刷新 | 使用接口返回的同一编号,不做本地拼接 |
| ✅ 历史草稿仍为空 | 显示 `—` 并等待后端兼容补齐,不写回猜测值 |
| ❌ 本地生成编号 | 禁止用时间、计数器或客户端缓存生成 |
| ❌ 把编号当数字 | 禁止移除 `SUP`、转数值或做算术运算 |
| ❌ 删除后复用 | 禁止把已删除供应商的旧编号分配给新供应商 |
## 五、数据库行为
- 创建成功时,供应商草稿、正式编号、可选子项和创建审计作为一个业务整体生效;响应中的编号与后续读取一致。
- 创建失败时,供应商和编号都不产生可见结果,不返回部分成功。
- 更新、提交或审批不改变已生成编号。
- 草稿删除后不再出现在正常列表和详情中,但其编号不会重新分配。
- 本次不要求调用方执行数据迁移,也不改变现有请求字段。
## 六、边界行为
- 未认证:业务码 `401`,零写入。
- 无写权限:业务码 `395002`,零写入。
- 必填字段缺失、格式或组合不合法:业务码 `400`,零写入。
- 发现近似主体且未完成二次确认:业务码 `395007`,按响应中的一次性确认信息继续既有流程。
- 发布后新草稿:`supplierNo` 必为非空 `SUP{supplierId}`。
- 发布前历史空编号:读取时仍允许 `null`;审批通过后按兼容规则补齐。
- 更新、提交、审批、暂停、拉黑等后续操作:编号保持不变。
- 软删除后再创建:新供应商获得新的 `supplierId` 和新的 `supplierNo`,不复用旧编号。
## 六.5、枚举 / 数据字典
本次没有新增枚举或数据字典。`status`、`onboardingStage`、供应商类型、联系人角色、资质类型等继续沿用现有取值;编号格式不是字典值。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| 创建响应 `data.supplierNo` | 新草稿通常为 `null`,审批通过后才补齐 | 创建成功立即返回非空 `SUP{supplierId}` |
| `supplierId`、`status`、`onboardingStage`、`initialAccounts`、`updateTime` | 已存在 | 字段和类型不变 |
| 创建请求 | 不接受 `supplierNo` | 仍不接受 `supplierNo`,请求无新增字段 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 新建后展示编号 | 需要空值占位或等待审批 | 创建成功即可展示正式编号 |
| 列表、详情、更新回显 | 草稿期可能为空 | 对发布后新建草稿稳定返回创建时编号 |
| 提交与审批 | 审批通过时生成编号 | 保留创建时编号;只为历史空值兼容补齐 |
| 删除后再次创建 | 无明确前端口径 | 新供应商取得新编号,旧编号不复用 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。字段已存在,只把发布后新草稿的值从 `null` 收紧为稳定非空字符串。
- **前端是否必须同步上线**: 需要适配体验。旧客户端仍可运行,但会继续把已经可用的编号显示为空或等待审批。
- **前端 workaround 清理点**: 删除“草稿编号为空”“审批通过后再刷新编号”的新建流程兜底,创建成功后直接使用 `data.supplierNo`。
- **历史兼容**: 仍保留对发布前空编号记录的 `null` 展示兼容,前端不要把所有历史数据强断言为非空。
- **QA 重点**: 创建响应、立即列表/详情、更新后详情、未认证、ADMIN 越权、非法请求零写入、软删除后新编号不复用。
## 七、不影响范围
- **仅影响**: 管理后台供应商草稿创建成功后的编号生成时点和后续稳定回显。
- **零影响**:
- 创建请求字段、供应商类型可选语义、联系人/资质/合同/初始账户规则。
- Gateway 路由、认证方式、权限码和数据范围。
- 供应商生命周期状态机、审批级数、暂停、拉黑、归档和资源关系行为。
- 小程序、C 端、Web、H5、桌面端接口。
- 其他服务接口、缓存和消息契约。
## 八、测试环境已验证
- 本地自动化:供应商编号、审批兼容、完整回显、事务边界、权限和迁移契约定向测试 61 项通过;`hl-resource-service` 全量 2,157 项零失败、零错误,38 项仓库既有条件跳过;Gateway 路由审计 4 项通过。
- 部署:Deploy Panel API 任务 `e977bf7b` 终态 `success`;目标与实际提交均为 `66aed70a2c9919046b9e4362b9db3219858ec6e1`。
- 可用性采样:任务期 6 个有效采样均至少有 2 个运行进程、2 个健康启用实例,零观测不可用采样;该证据只证明采样点,不代表采样间绝对连续。
- 真实 Gateway:SUPER_ADMIN 创建草稿后,响应立即满足 `supplierNo=SUP{supplierId}`;列表、详情、更新响应保持同一编号;独立变更记录同时存在 CREATE/UPDATE 事实。
- 失败路径:未认证请求被 Gateway 拒绝;真实 ADMIN 创建返回 `395002`;非法税号返回 `400`;两类失败均验证零可见写入。TEST 未配置可用 FINANCE 身份,其角色等价性由服务端权限测试覆盖,未伪造身份。
- 删除与不复用:首个草稿经业务 API 软删除并确认列表、详情不可见;随后创建的新草稿获得不同编号。
- 清理:两条临时草稿均经业务 API 软删除并确认不可见,测试角色已恢复,会话已注销;未执行物理删除、缓存或 MQ 操作,仅保留正常的创建、更新、删除审计事实。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6274 | #6273 | 审批记录响应增加 `supplierNo` 字段 | ✅ 有效,本次收紧新草稿取值时点 |
| #6344 | #6343 | 新建和修改允许供应商类型为空 | ✅ 有效,请求语义不变 |
| #6517 | #6476 | 详情补齐地址备注并统一表单校验 | ✅ 有效,详情继续稳定回显 |
| #6531 | #6518 | 草稿创建即生成唯一供应商编号 | ✅ 最新 |
## 十、相关文档
- 关联 Issue:[#6518](https://git.1814.love:8443/wx/HL/issues/6518)
- 关联 PR:[#6531](https://git.1814.love:8443/wx/HL/pulls/6531)
- 历史编号响应契约:`changelogs-v2/2026-08/24_6273_供应商审批记录补充供应商编号-修改接口-管理后台.md`
- 管理端接入:创建成功立即读取并展示 `data.supplierNo`;历史空值仍保留 `—` 兼容,前端引用待回填。
## 撤回
1. 管理端先恢复“新草稿编号可能为空”的兼容展示,不再依赖创建响应立即有值。
2. 后端从最新 `dev-v3` 创建独立回退分支,revert #6518 合并提交 `66aed70a2c9919046b9e4362b9db3219858ec6e1`,验证后经独立 PR 合入并重新发布 `hl-resource-service`。
3. 无接口字段删除或请求结构回退;发布窗口内已生成的编号默认保留,不执行批量清空或复用。
4. 撤回后经 Gateway 复测创建响应可空、历史审批补齐、列表/详情/更新兼容、未认证与越权零写入。
5. 以新的 Changelog 提交标记本契约撤回,不改写已发布提交历史。
## 关联 / 联系人
### 链接
- **Issue**: [#6518](https://git.1814.love:8443/wx/HL/issues/6518)
- **PR**: [#6531](https://git.1814.love:8443/wx/HL/pulls/6531)
- **实现提交**: [`69665d72`](https://git.1814.love:8443/wx/HL/commit/69665d7249b5d269e56c36abbfeafbdbbbe72d45)
- **Merge commit**: [`66aed70a`](https://git.1814.love:8443/wx/HL/commit/66aed70a2c9919046b9e4362b9db3219858ec6e1)
### 联系人
- **后端负责人**: @lc
- **管理端负责人**: 待认领(`frontend_status: pending`)
@@ -0,0 +1,524 @@
---
schema: "hl-changelog/v2"
ticket: "6544"
title: "供应商合同独立登记"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: "pending"
target_release: "v2.1"
verified_at: "2026-08-29"
status_note: "PR #6559、#6591、#6606 已合并 dev-v3;hl-resource-service 已通过 Deploy Panel 任务 ca2fe4d7 部署最终提交 c579c87014f56452fea2fce5075b31ae6fd12a35。#6591 的写后回读、395052–395058、金额 scale 等值与草稿合同存在门禁均已进入 TEST。#6544 基线曾完成独立合同写链路实证;本次最终提交因运行时身份缺少 supplier:update,按用户确认冻结权限依赖的真实写复测,未配置权限、未发业务写请求,不把缺失权限记为通过。"
updated_at: "2026-08-29"
base: "dev-v3"
---
# 供应商管理:合同独立登记
供应商合同从注册聚合中拆出,改为独立登记、独立更新、独立软删除的维护边界。合同不再随供应商建档审批提交,审批通过与否都不影响合同登记;已登记合同在详情回显中继续随供应商返回。
## 一、背景
旧版把合同作为供应商注册聚合的一部分随审批走(#6397),审批候选 v2 起不再携带 contracts。为支持签约后可随时登记线下合同、按业务状态独立更新/作废,本次新增三个独立合同写接口:登记(add)、完整替换更新(update)、软删除(del)。合同不参与供应商生命周期状态机,不受建档/审批/归档状态推进约束;删除草稿供应商时若仍有未删除合同会被拒绝(需先独立删合同)。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 独立登记供应商合同 | POST | `/admin/supplier/items/{supplierId}/contracts/add` | 新增写接口 | 在既有供应商下登记一份线下合同,不进入建档审批 |
| 2 | 独立更新供应商合同 | PUT | `/admin/supplier/items/{supplierId}/contracts/{contractId}/update` | 新增写接口 | 带乐观版本整份替换合同全部业务字段 |
| 3 | 独立删除供应商合同 | DELETE | `/admin/supplier/items/{supplierId}/contracts/{contractId}/del` | 新增写接口 | 带乐观版本软删除,审计原因必填 |
## 三、接口详情
### 1. 独立登记供应商合同 `POST /admin/supplier/items/{supplierId}/contracts/add`
**VO**: `SupplierContractCreateReqVO` / `SupplierContractRespVO`
#### 使用场景
管理端在供应商详情「合同」区域新增一条线下合同。合同登记与供应商审批解耦,登记成功立即在详情合同列表随时间返回 `updateTime` 并发版本。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
| `contractName` | Body | String | 是 | 最长 500 字符 | 合同名称 |
| `contractNo` | Body | String | 否 | 最长 100 字符 | 合同编号;可空字符串 |
| `contractType` | Body | String | 是 | `FRAME` / `SINGLE_TRIP` / `PURCHASE` | 合同类型 |
| `signDate` | Body | String | 否 | `yyyy-MM-dd` | 合同签署日期 |
| `startDate` | Body | String | 是 | `yyyy-MM-dd` | 有效期开始日期 |
| `endDate` | Body | String | 是 | `yyyy-MM-dd`,不得早于 `startDate` | 有效期结束日期 |
| `amount` | Body | String | 否 | 非负,最多 10 位整数 2 位小数 | 合同金额;按字符串传输与比较 |
| `pricingMode` | Body | String | 否 | 最长 100 字符 | 计价方式说明 |
| `settleCycle` | Body | String | 否 | 最长 32 字符 | 结算周期 |
| `status` | Body | String | 是 | `DRAFT` / `ACTIVE` / `EXPIRED` | 可维护的合同状态 |
| `scanFileUrl` | Body | String | 否 | 最长 1000 字符 | 合同扫描件永久文件地址 |
| `remark` | Body | String | 否 | 最长 500 字符 | 合同备注 |
| `changeReason` | Body | String | 是 | 最长 500 字符 | 本次登记原因,写入供应商变更审计 |
#### 出参 `Result<SupplierContractRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `contractId` | String | 合同 ID(雪花,保证转字符串传输) |
| `contractName` | String | 合同名称 |
| `contractNo` | String/null | 合同编号 |
| `contractType` | String | 合同类型 |
| `signDate` | String/null | 合同签署日期 |
| `startDate` | String | 有效期开始日期 |
| `endDate` | String | 有效期结束日期 |
| `amount` | String | 合同金额(保证转字符串传输,保留两位小数场景由后端规范) |
| `pricingMode` | String/null | 计价方式 |
| `settleCycle` | String/null | 结算周期 |
| `status` | String | 合同状态;历史终止合同可能返回 `TERMINATED` |
| `scanFileUrl` | String/null | 合同扫描件地址 |
| `remark` | String/null | 合同备注 |
| `updateTime` | String | 当前并发版本,格式 `yyyy-MM-dd HH:mm:ss`;请原样用于下一次 update/del |
#### 请求示例
```http
POST /admin/supplier/items/2091381911266967553/contracts/add
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"contractName": "2026年度框架合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "线下签署后登记",
"changeReason": "线下签署后登记"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2093378327078154241",
"contractName": "2026年度框架合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "线下签署后登记",
"updateTime": "2026-08-29 00:41:00"
},
"traceId": null
}
```
#### 空数据 / 降级响应
登记成功后 `data` 恒为完整合同对象(不会返回 null)。金额为空时 `amount` 返回 null,前端按无金额展示,不要把 null 当 `"0.00"`:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2093378327078154241",
"contractName": "无金额合同",
"contractNo": null,
"contractType": "PURCHASE",
"signDate": null,
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": null,
"pricingMode": null,
"settleCycle": null,
"status": "DRAFT",
"scanFileUrl": null,
"remark": null,
"updateTime": "2026-08-29 00:41:00"
}
}
```
#### 错误响应
供应商不存在或已软删除:
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
已归档供应商拒绝合同写入(不查询、不锁行、不落审计):
```json
{
"code": 395031,
"message": "已归档供应商仅允许查看",
"success": false,
"data": null
}
```
正常 HTTP 请求先经过 Bean Validation;缺必填字段或长度超限仍返回 `400` 和中文字段文案。`395052`–`395056` 是 Service/事务层防旁路校验的冻结错误码,不替代 Controller 的 `400`:
```json
{
"code": 400,
"message": "合同名称不能为空",
"success": false,
"data": null
}
```
#### 业务边界
- 权限:可信管理员身份 + `FINANCE`/`SUPER_ADMIN` 写角色 + `supplier:update` 平台权限;`ADMIN` 或任一平台权限门禁失败均不产生数据库写。
- 合同登记只写 `supplier_contract` 单表与一条 `CREATE` 审计事实,不推进供应商主体版本,不进审批。
- 幂等:同一管理员 + 同一供应商 + 同一规范化载荷 5 秒窗口内重复提交只执行一次;锁键按供应商分区。
- 事务内先锁供应商行(FOR UPDATE),改合同必在事务中完成,失败整体回滚。
- `supplierId`、`contractId`、`amount` 必须按字符串处理,禁止转 JavaScript Number。
### 2. 独立更新供应商合同 `PUT /admin/supplier/items/{supplierId}/contracts/{contractId}/update`
**VO**: `SupplierContractUpdateReqVO`(继承 Create 全字段 + `expectedUpdateTime`) / `SupplierContractRespVO`
#### 使用场景
管理端对已登记合同做整份替换更新。请求体必须携带目标合同当前 `updateTime` 作为乐观版本围栏;服务端版本不匹配时整体拒绝且不产生任何写。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 Number |
| `contractId` | Path | String | 是 | 正整数 ID 字符串 | 目标合同;不得转为 Number |
| *(Create 全部字段)* | Body | String | 同新增 | 同新增 | 整份替换所需全字段 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 目标合同当前并发版本,来自详情或上次写响应 |
#### 出参 `Result<SupplierContractRespVO>`
与新增相同,`updateTime` 返回新版本(严格晚于旧版本),请用该值继续后续围栏。
| 字段 | 类型 | 说明 |
|---|---|---|
| `contractId` | String | 合同 ID(字符串) |
| (其余字段) | - | 同新增出参,`updateTime` 为新并发版本 |
#### 请求示例
```http
PUT /admin/supplier/items/2091381911266967553/contracts/2093378327078154241/update
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"contractName": "2026年度框架合同(变更)",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-10-01",
"endDate": "2027-08-31",
"amount": "1500.00",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "续约调整",
"changeReason": "续约调整",
"expectedUpdateTime": "2026-08-29 00:41:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2093378327078154241",
"contractName": "2026年度框架合同(变更)",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-10-01",
"endDate": "2027-08-31",
"amount": "1500.00",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "续约调整",
"updateTime": "2026-08-29 00:41:01"
}
}
```
#### 空数据 / 降级响应
更新接口无空数据场景。请求体携带的整份字段(含空值)会覆盖原值:`contractNo`、`signDate`、`pricingMode`、`settleCycle`、`scanFileUrl`、`remark` 传 null 或空串会被清空;`amount` 传 null 会清除金额。清空能力只在更新接口生效,新增不触发。
#### 错误响应
版本不匹配/已过期(前端收到后请重新拉详情取最新 `updateTime` 再重试):
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"success": false,
"data": null
}
```
合同不存在、已删除或不属于路径供应商(跨供应商同码隐藏):
```json
{
"code": 395051,
"message": "供应商合同不存在",
"success": false,
"data": null
}
```
请求与当前内容完全一致(无实际变化,不推进版本):
```json
{
"code": 400,
"message": "未检测到实际变化",
"success": false,
"data": null
}
```
#### 业务边界
- 同一供应商下同一合同的 update 与 del 共享锁键,串行化后版本围栏在事务内校验。
- `payloadChanged` 用值比较(金额忽略小数位 scale),纯金额 `1200.5` 与 `1200.50` 等价,不会误判为变更。
- 更新推进合同自身 `updateTime`(秒级严格递增),不推进供应商主体版本。
- 5 秒幂等窗口按双 ID + 载荷摘要去重;锁键串行化同行写。
- 审计记录完整前后快照差异(`contracts` 字段组)。
### 3. 独立删除供应商合同 `DELETE /admin/supplier/items/{supplierId}/contracts/{contractId}/del`
**VO**: `SupplierContractDeleteReqVO` / 无返回体
#### 使用场景
对已登记合同作废软删除。删除后 `deletedAt` 写入当前时间,查询与详情不再返回;与同一合同 update 共享锁键和版本围栏。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `contractId` | Path | String | 是 | 正整数 ID 字符串 | 目标合同 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 目标合同当前并发版本 |
| `changeReason` | Body | String | 是 | 最长 500 字符 | 本次删除原因,写入审计 |
#### 出参 `Result<Void>`
成功时 `data` 为 null:
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | 恒为 `200` 表示成功 |
| `data` | null | 删除接口无返回体 |
#### 请求示例
```http
DELETE /admin/supplier/items/2091381911266967553/contracts/2093378327078154241/del
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"expectedUpdateTime": "2026-08-29 00:41:01",
"changeReason": "线下合同作废"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": null
}
```
#### 空数据 / 降级响应
删除成功恒返回上述结构;无空数据分支。删除后合同立即从查询/详情消失,如需保留展示请走更新改状态而非删除。
#### 错误响应
版本不匹配:
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"success": false,
"data": null
}
```
合同不存在或已删除:
```json
{
"code": 395051,
"message": "供应商合同不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 删除是软删除,不物理清理业务合同数据;删除与完整删除审计同事务提交或回滚。
- 删除草稿供应商时若存在未删除合同,档案删除会被拒绝(提示先独立删合同,错误码见「六、边界行为」)。
- 已删除合同的 `expectedUpdateTime` 不再有效,误传原版本返回合同不存在。
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | 调用 / 结果 |
|---|---|
| ✅ 新增后立即保存返回值 | 保存 `updateTime` 用于后续 update/del 版本围栏 |
| ✅ 更新时整份提交 | 传全字段;想清空的字段显式传 null 或空串 |
| ✅ 金额按字符串 | `"amount": "1200.50"`,`contractId`/`supplierId` 恒为字符串 |
| ✅ 并发被拒后重试 | 重新 GET 详情取最新 `updateTime`,再带新版本重试 |
| ❌ 把 ID/金额转 Number | 可能丢精度;必须按字符串传输与比较 |
| ❌ 只传变更字段 | update 是整份替换语义;缺字段会被清空 |
| ❌ 依赖审批候选带合同 | 审批候选 v2 起不携带 `contracts`;合同一律走独立接口维护 |
### 关键提示(当前 TEST 构建)
- 已登记的合同通过详情接口随供应商返回的 `contracts` 数组回显,前端不必重复维护列表状态。
- **B1 已修复并部署**:登记(add)插入后按合同 ID 回读数据库持久化实体,响应与 CREATE 审计使用同一真实秒级 `updateTime`;该值可直接用于下一次 update/del 版本围栏。
## 五、数据库行为
- 写操作仅影响 `supplier_contract` 一行(新增 insert / 更新 update / 删除软删),以及 `supplier_change_log` 一条对应合同审计事实(`fieldName=contract`,前后完整快照差异)。
- 三个写接口均不修改 `supplier_main`(供应商主体版本不变),不进审批流,不写缓存、MQ 或跨服务数据。
- 更新与删除仅在版本围栏通过后产生写;版本不匹配时不产生任何数据库副作用。
- 软删除使用统一 `deleted_at` 逻辑删除,物理行保留;查询与详情自动过滤。
## 六、边界行为
- 未登录/登录失效:业务码 `401`;角色或平台权限不足:`403`,不查询不写入。
- `supplierId`/`contractId` 非法(非数字、超长、<=0):`400`;供应商不存在或已软删除:`395001`。
- 已归档供应商:`395031`(写入一律拒绝)。
- 未知或非生命周期状态供应商:`395005`(状态机门禁先行失败)。
- 版本不匹配:`395014`;请求无实际变化:`395057`。
- 删除草稿供应商仍有未删除合同:`395058`「已有合同登记,请先独立删除合同」。
- Controller 的 Bean Validation/绑定失败仍返回 `400`;Service/事务层防旁路校验使用 `395052`–`395056`,其中缺失合同并发版本文案为「预期更新时间不能为空」。
## 六.5、枚举 / 数据字典
### `contractType`(合同类型)
**所属字段**: `SupplierContractCreateReqVO.contractType` / `SupplierContractRespVO.contractType` | **类型**: `String`
| 值 | 说明 |
|---|---|
| `FRAME` | 框架合同 |
| `SINGLE_TRIP` | 单团单合同 |
| `PURCHASE` | 采购合同 |
### `status`(合同状态)
**所属字段**: `SupplierContractCreateReqVO.status` / `SupplierContractRespVO.status` | **类型**: `String`
| 值 | 说明 |
|---|---|
| `DRAFT` | 草稿 |
| `ACTIVE` | 生效中(可维护) |
| `EXPIRED` | 已过期(可维护) |
| `TERMINATED` | 已终止(仅历史回显旧值,写接口不允许设置) |
## 七、不影响范围
- **仅新增**:三个独立合同写接口;合同登记与审批、状态机、归档完全解耦。
- **保持兼容**:供应商建档/更新/提交/审批/归档/暂停/拉黑/删除接口契约不变;详情回显 `contracts` 数组不变。
- **零影响**:审批候选 v2 起不含 `contracts`(v1 候选中的 contracts 字段被兼容忽略);不阻断既有审批流。
- **零影响**:收款账户、资质、资源关联、历史/审批流水等供应商子域。
- **零影响**:Gateway 路由(沿用 `/admin/supplier/**`)、数据库结构(无迁移)、Redis、MQ、Feign 契约。
## 八、测试环境已验证
- 本地自动化:合同命令/事务/校验器定向测试(SupplierContractTransactionServiceTest、SupplierContractValidatorTest、SupplierContractServiceTest、SupplierAggregateWriterTest、SupplierErrorCodeContractTest)51 项零失败;每日审查修复后 `hl-resource-service` 全量 2181 项零失败、零错误(38 项条件跳过)。
- 主 PR #6559、审查修复 PR #6591 与中文版本文案补充 PR #6606 均已合入 `dev-v3`;最终 Resource 合并提交为 `d7e455492648b085445691ccde4a13198d4cd6c6`。
- TEST 部署:Deploy Panel 任务 `ca2fe4d7` 成功,预期/实际提交均为 `c579c87014f56452fea2fce5075b31ae6fd12a35`;双进程、Nacos 两实例、6 个任务期采样和两类日志门禁通过,零观测不可用采样。
- 最终自动化:合同定向 73 项、供应商真实 MySQL Testcontainers 398 项、Resource 全量 2181 项均为 0 失败/0 错误;全量保留 38 项既有条件跳过。
- Gateway 边界:#6544 基线写链路已有真实 TEST 实证;最终部署回读身份为 `SUPER_ADMIN`,但当前平台权限不含 `supplier:update`。用户明确冻结该权限依赖的写复测,本轮没有配置/绕过权限,也没有发合同业务写请求;权限与事务成功/拒绝路径以最终自动化证据补充,不宣称运行时缺失权限已通过。
- 数据清理:本轮最终部署和冻结验收未创建供应商、合同、缓存、MQ 或审批合成数据;TEST 身份已登出。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6559 | #6544 | 独立合同登记三写接口 + 边界拆分 | ✅ 本次主功能 |
| #6591 | #6604 | 合同模块每日审查修复 7 项(写后读回/错误码/VO 文档/金额比较) | ✅ 已部署 |
| #6606 | #6604 | 合同并发版本校验中文文案与全局错误码审计修复 | ✅ 已部署 |
| #6519 | #6474 | 供应商变更/审批分离(前置演进) | ✅ 不受影响 |
## 十、相关文档
- 关联 Issue:[#6544](https://git.1814.love:8443/wx/HL/issues/6544)
- 关联 PR:[#6559](https://git.1814.love:8443/wx/HL/pulls/6559) / [#6591](https://git.1814.love:8443/wx/HL/pulls/6591) / [#6606](https://git.1814.love:8443/wx/HL/pulls/6606)
- 管理端接入:详情页「合同」区改为直接调 add/update/del 三接口;提交审批的候选不再携带 contracts。
## 撤回
1. 管理端停止请求三个 contract 写路径,并清除详情页合同编辑入口。
2. 从最新 `dev-v3` 建独立回退分支,按依赖逆序回退 `d7e455492648b085445691ccde4a13198d4cd6c6`、`9eb9c96c09975a3ccf4966a257cc77bdf9f743b9` 与 `be0fe8125197b759459c975d211319e10cad150c`,验证后经独立 PR 合入。
3. 仅需回退写接口时可先保留回显逻辑,只移除 add/update/del 路由与前端入口。
4. 使用 Deploy Panel 两阶段客户端仅滚动部署 `hl-resource-service`;本次无数据库、配置、Redis 或 MQ 恢复步骤。
5. 撤回后经 Gateway 验证三个写路径不可用、详情 `contracts` 回显按选定回退范围正常,并确认零写入。
6. 同步发布本 Changelog 的撤回说明;不得仅改文件名表达状态。
## 关联 / 联系人
### 链接
- **Issue**: [#6544](https://git.1814.love:8443/wx/HL/issues/6544)
- **PR**: [#6559](https://git.1814.love:8443/wx/HL/pulls/6559) / [#6591](https://git.1814.love:8443/wx/HL/pulls/6591) / [#6606](https://git.1814.love:8443/wx/HL/pulls/6606)
- **Merge commit**: [`be0fe8125`](https://git.1814.love:8443/wx/HL/commit/be0fe8125197b759459c975d211319e10cad150c) / [`9eb9c96c0`](https://git.1814.love:8443/wx/HL/commit/9eb9c96c09975a3ccf4966a257cc77bdf9f743b9) / [`d7e455492`](https://git.1814.love:8443/wx/HL/commit/d7e455492648b085445691ccde4a13198d4cd6c6)
### 联系人
- **后端负责人**: @lc