--- schema: "hl-changelog/v2" ticket: "6986" title: "团期物资支持从备品库选择配置" consumer: "admin" author: "jw(GIT)" change_type: "新增接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "4d82bdb5" target_release: "" verified_at: "2026-09-03" status_note: "PR #6987 已合入 dev-v3;2026-09-03 经测试环境网关实测,10 条正负向用例全部通过。前端尚未接入" updated_at: "2026-09-03" base: "dev-v3" --- # 团期物资: 新增备品库候选列表接口,并放宽 suppliesName 必填约束 > **服务**: hl-order-service-v3 + hl-resource-service(**跨两个服务**) > **PR**: #6987 > **Issue**: #6986 > **影响范围**: 管理后台「团期详情 → 物资清单 → 从备品库选择」 --- ## ⚠️ 关键变化 **新增物资行 `POST /v3/admin/order/group-batch/:groupBatchId/supplies` 的 `suppliesName` 不再无条件必填。** - 前端以前以为:`suppliesName` 是 `@NotBlank`,任何情况下都得填。 - 实际现在是:改为 `@AssertTrue` **二选一**——传了 `suppliesResourceId` 就可以不填名称 (名称、分类、计费方式从备品库取,**入参传了也会被库值覆盖**);不传 `suppliesResourceId` 时名称仍必填。 - 这解决的是原先「从备品库选一条加进来,却仍被迫手填名字」与「名称以库为准」自相矛盾的问题。 **部署要求**:本次跨 order-v3 与 resource-service 两个服务,**必须同时部署**。 只发 order-v3 会调不到资源域新增的 `/internal/supplies/list-available`,候选列表直接报 589518。 --- ## 一、背景 物资清单原先只能手工逐条录入,名称、分类、计费方式、单价全靠人填,既慢又容易与备品库口径不一致。 本次接入资源域备品库:团期管理员从库里勾选,基础信息由库带出。 **资源域改动**:新增 `GET /internal/supplies/list-available`(内部接口,不对外)。 order-v3 侧通过 Feign 调用,并带降级工厂。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 团期物资候选列表 | GET | `/v3/admin/order/group-batch/:groupBatchId/supplies/candidates` | **新增接口** | 从备品库拉可选备品,标记本团期已加入项 | | 2 | 新增团期物资行 | POST | `/v3/admin/order/group-batch/:groupBatchId/supplies` | **请求体校验放宽** | `suppliesName` 由必填改为与 `suppliesResourceId` 二选一 | --- ## 三、接口详情 ### 1. 团期物资候选列表 `GET /v3/admin/order/group-batch/:groupBatchId/supplies/candidates` **VO**: `SuppliesCandidateRespVO` #### 使用场景 「物资清单 → 从备品库选择」弹窗打开时调用,渲染可选备品列表。 `added=true` 的项应显示为已勾选,其 `batchSuppliesId` 即清单中对应行的 ID,可直接用于删除。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | `groupBatchId` | Path | Long | ✅ | — | 团期主订单 ID | | `categoryCode` | Query | String | ❌ | 字典 `supplies_category` | 分类筛选。传不存在的分类返回空数组,不报错 | | `keyword` | Query | String | ❌ | — | 关键字,匹配备品**名称或副标题**。中文需 URL 编码 | #### 出参 `Result>` | 字段 | 类型 | 说明 | |------|------|------| | `suppliesResourceId` | Long | 备品资源 ID。**加入清单时回传本值** | | `suppliesName` | String | 备品名称(来自库) | | `subtitle` | String | 副标题 | | `category` | String | 分类编码 | | `billingType` | String | 计费方式,**已归一为产品域词表** `PER_PERSON` / `PER_QUANTITY` | | `hasCost` | Boolean | 是否计费(资源域 `isCharged=1` 时为 true) | | `unitPrice` | BigDecimal | 基础价,加入清单时作为 `unitPrice` 默认值 | | `unit` | String | 计量单位 | | `added` | Boolean | 是否已加入本团期清单,**true 时前端应显示为已勾选** | | `batchSuppliesId` | Long | 已加入时对应的清单行 ID;未加入为 `null` | #### 请求示例 ```http GET /v3/admin/order/group-batch/1/supplies/candidates?categoryCode=personal_gear ``` #### 响应示例 ```json { "code": 200, "message": "成功", "success": true, "data": [ { "suppliesResourceId": "2023438566624866305", "suppliesName": "定制遮阳帽", "subtitle": "呼籁旅行专属防晒鸭舌帽", "category": "personal_gear", "billingType": "PER_PERSON", "hasCost": true, "unitPrice": 15.00, "unit": "顶", "added": false, "batchSuppliesId": null } ] } ``` #### 空数据 / 降级响应 备品库无匹配项(含分类不存在、关键字无命中)时返回空数组,不报错: ```json { "code": 200, "message": "成功", "success": true, "data": [] } ``` 资源域不可达时**不静默降级为空**,而是显式报 589518,避免前端误判为「库里没有备品」。 #### 错误响应 备品库查询失败(资源域未部署或不可达): ```json { "code": 589518, "message": "备品库查询失败,请稍后重试", "success": false, "data": null } ``` #### 业务边界 - 只读接口,**不产生任何写入**,可安全重复调用 - 资源域只返回可用备品,已下架的不在候选池 - `billingType` 在 order-v3 侧做过归一,前端**不必**再兼容资源域原始词表 - `added` 依据 `suppliesResourceId` 匹配;纯手填(无 resourceId)的清单行不会影响任何候选项的勾选态 - 同一备品在清单中重复存在时,`batchSuppliesId` 取首条 --- ### 2. 新增团期物资行 `POST /v3/admin/order/group-batch/:groupBatchId/supplies` **VO**: `AddSuppliesReqVO` #### 使用场景 两种来源共用本接口:从备品库勾选加入(传 `suppliesResourceId`),或手工录入临时物资(传 `suppliesName`)。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | `groupBatchId` | Path | Long | ✅ | — | 团期主订单 ID | | `suppliesResourceId` | Body | Long | ❌ | 与 `suppliesName` **二选一** | 备品资源 ID。传了则名称/分类/计费方式从库取 | | `suppliesName` | Body | String | ❌ | 与 `suppliesResourceId` **二选一** | **变更前** `@NotBlank` 无条件必填;**变更后**传了 resourceId 时可空,且**传了也会被库值覆盖** | | `quantity` | Body | Integer | ✅ | `@NotNull` `@Min(1)` | 数量 | | `category` | Body | String | ❌ | — | 分类,传 resourceId 时以库为准 | | `hasCost` | Body | Boolean | ❌ | — | 是否计费 | | `billingType` | Body | String | ❌ | — | 计费方式 | | `unitPrice` | Body | BigDecimal | ❌ | — | 单价,缺省取库基础价 | | `sortOrder` | Body | Integer | ❌ | — | 排序 | | `remark` | Body | String | ❌ | — | 备注 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | `data` | Long | 新建清单行 ID(`batchSuppliesId`),可直接用于删除或调整数量 | #### 请求示例 ```json { "suppliesResourceId": 2023438566624866305, "quantity": 2 } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "success": true, "data": "2095411540751519745" } ``` #### 空数据 / 降级响应 本接口必然返回行 ID 或错误,无空数据形态。校验失败时 `data` 为 `null` 且 `success=false`。 #### 错误响应 名称与备品库 ID 都没传(**本次新增的二选一校验**): ```json { "code": 400, "message": "备品名称不能为空(未指定备品库 ID 时必填)", "success": false, "data": null } ``` 所选备品不存在或已下架: ```json { "code": 589519, "message": "所选备品不存在或已下架,请重新选择", "success": false, "data": null } ``` #### 业务边界 - 二选一校验在 `@AssertTrue` 阶段完成,**失败时数据库无任何变更** - 传了 `suppliesResourceId` 时,入参里的 `suppliesName` / `category` / `billingType` **会被库值覆盖**,不是「以入参优先」 - `quantity` 最小为 1,传 0 或负数返回 400 - 同一备品可重复加入,接口不做去重 - 纯手填行(无 `suppliesResourceId`)不会出现在候选列表的 `added` 判定中 --- ## 四、契约约束与正确调用方式 - **加入备品库物资时,只需传 `suppliesResourceId` 和 `quantity`**。不要再从候选列表把名称回填进请求体—— 传了也会被库值覆盖,徒增不一致风险。 - **手工录入临时物资时,`suppliesName` 仍然必填**。二选一不等于两个都可以不传。 - **勾选态用 `added` 判断,删除用 `batchSuppliesId`**。候选列表已经把行 ID 回填好,无需再查一次清单。 - **`keyword` 含中文必须 URL 编码**,否则查询条件丢失(实测未编码时请求异常)。 - **589518 与「空数组」含义不同**:前者是资源域挂了,后者是库里确实没有匹配项。前端不应把 589518 展示成「暂无备品」。 - 589518 / 589519 是业务码,HTTP 状态仍为 200,判断成败要读响应体的 `code`。 --- ## 五、数据库行为 仅描述外部可观察行为: - 接口 1(GET candidates)**只读,不产生任何写入**。 - 接口 2(POST supplies)新增一条团期物资清单行并返回其 ID;传 `suppliesResourceId` 时, 名称、分类、计费方式、单价默认值取自备品库快照,落库后不再随备品库变动。 - 二选一校验与数量校验均发生在写入之前,**校验失败时数据库无任何变更**(已实测复核)。 - 本次变更**不涉及表结构调整**,也不对存量物资行做迁移或回填。 --- ## 六、边界行为 - 资源域未部署 / 不可达 → 589518,**不降级为空数组** - 备品库无匹配 → 返回 `[]`,不报错 - `categoryCode` 传不存在的分类 → 返回 `[]` - 备品库 ID 不存在或已下架 → 589519 - 既无 `suppliesName` 又无 `suppliesResourceId` → 400 - `quantity` 为 0、负数或缺失 → 400 - 未登录 → 401(网关拦截) --- ## 六.5、枚举 / 数据字典 ### `billingType`(已归一为产品域词表) | 取值 | 含义 | |------|------| | `PER_PERSON` | 按人计费 | | `PER_QUANTITY` | 按数量计费 | > order-v3 侧已对资源域原始词表做过归一,前端直接消费这两个值即可。 ### `category`(字典 `supplies_category`) 分类编码由字典维护,如 `personal_gear`(个人装备)、`camping_equipment`(露营装备)等。 本接口不校验分类是否存在,传未知值返回空数组。 --- ## 六.6、错误码 段位 `589500-589599`,owner `hl-order-service-v3`(`GroupBatchErrorCode`)。 | 码 | 符号 | 消息 | 触发 | |---|---|---|---| | `589518` | `SUPPLIES_LIBRARY_FETCH_FAILED` | 备品库查询失败,请稍后重试 | **本次新增**。资源域 `list-available` 返回失败或空结果对象 | | `589519` | `SUPPLIES_RESOURCE_NOT_AVAILABLE` | 所选备品不存在或已下架,请重新选择 | **本次新增**。`suppliesResourceId` 在库中查不到 | | `400` | — | 备品名称不能为空(未指定备品库 ID 时必填) | **本次新增**。二选一校验未通过 | --- ## 七、不影响范围 - **仅影响**:管理后台团期详情的「物资清单」模块 - **零影响**: - 已有的物资清单查询、调整数量、删除行、确认物料四个接口的出参 - 存量团期物资行数据(不迁移,新校验只在下次新增时触发) - 资源域备品库自身的管理接口(本次只新增一个内部只读端点) - 团期状态机与物料确认闸门 --- ## 八、测试环境已验证 ✅ **已验证。** 2026-09-03 于测试环境网关实测,真实鉴权(管理端 admin 账号)。 - 网关:`https://api.test.1814.love:9443`,分支 `dev-v3` - 前置确认:order-v3 与 resource-service **两侧均已部署**(候选列表能返回资源域真实数据即为证) | # | 用例 | 期望 | 实测 | |---|---|---|---| | 1 | GET candidates 无筛选 | 200,返回候选 | ✅ 200,15 条 | | 2 | `?categoryCode=personal_gear` | 200,按分类收窄 | ✅ 200,5 条 | | 3 | `?keyword=帽`(URL 编码) | 200,按名称匹配 | ✅ 200,1 条「定制遮阳帽」 | | 4 | `?categoryCode` 传不存在分类 | 200 空数组 | ✅ 200,0 条 | | 5 | GET candidates 无 Authorization | 401 | ✅ `401 缺少有效的 Authorization 头` | | 6 | POST 既无名称也无 resourceId | 400 | ✅ `400 备品名称不能为空(未指定备品库 ID 时必填)` | | 7 | POST `quantity=0` | 400 | ✅ `400 数量至少为 1` | | 8 | POST 缺 `quantity` | 400 | ✅ `400 数量不能为空` | | 9 | POST `suppliesResourceId` 不存在 | 589519 | ✅ `589519 所选备品不存在或已下架,请重新选择` | | 10 | POST 只传 resourceId 不传名称 | 放行,名称取库值 | ✅ 200,落库 `suppliesName="定制遮阳帽"` 与库一致,`unitPrice=15.00`、`billingType=PER_PERSON` | **行为变更核对**:用例 10 是本次核心——入参未传 `suppliesName` 仍成功建行,名称由备品库带出。 **联动核对**:加入后重查候选列表,该项 `added=true`、`batchSuppliesId` 回填为新建行 ID;删除后回到 `added=false`。 **未落库核对**:用例 6–9 执行后复查清单仍为 0 条,证明校验失败不产生写入。 **测试数据还原**:用例 10 建的行已 `DELETE` 删除,清单由测试前 0 条恢复为 0 条。 本地单元测试(提交信息记载):`GroupBatchSuppliesServiceTest` 新增 208 行用例。 --- ## 十、相关文档 - 团期需求文档:`docs/group/`(dev-v3 分支) - 实施单 AC-TD-13(`ad747212d` 已标记 #6986 落地并勘误) --- ## 关联 / 联系人 - **Issue**: #6986 - **PR**: #6987(合并提交 `bcf9332bd`,落入 `dev-v3`) - **后续修正**: `8806315e3`(备品去重,同时关联 #6950) - **服务**: hl-order-service-v3 + hl-resource-service - **后端**: jw - **前端**: 待认领(`frontend_status: pending`)