--- schema: "hl-changelog/v2" ticket: "7980" title: "产品价格日历库存扣减/恢复两写口补同一把 @Lock4j name,新增可重试冲突码 100503,并订正 Swagger notes 里『乐观锁』的错误说法(接口路径/字段零增删)" consumer: "internal" author: "wx(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "not_required" frontend_status: "not_required" frontend_owner: "" frontend_ref: "" target_release: "" verified_at: "" status_note: "PR #8047 已 squash 合并 dev-v3(合并提交 f72a7548c)。2026-09-20 23:20 已部署测试服 hl-product-service-v2:滚动更新两实例(8183 先起、7 秒 UP,再滚 8083、7 秒 UP),部署前 COMMIT=4cbccc26b(停在 2026-09-19,落后 93 个提交且这些提交触及本服务),部署后 COMMIT=ba8aab3ab;git merge-base --is-ancestor f72a7548c ba8aab3ab 返回 ANCESTOR_YES,确认本次改动已在运行版本内。同轮顺带把 #8011 的用例修复(4c2ec76a2)一并带上。📌 订正草稿里的一处理由错误:本仓是 monorepo、所有合并都进 dev-v3,f72a7548c 当然落在 dev-v3 祖先链内;此前用 order-v3 的部署点 5d14bc524 做 --is-ancestor 得到 NO,原因只是「那时还没滚到」,不是「属于另一条分支的独立提交」——错误理由若留着,会让人去找一条并不存在的分支线。部署前已核对 4cbccc26b..origin/dev-v3 的 hl-common 改动为纯新增(VehicleCategoryNameDTO +16 行、一个测试 +51 行),无破坏性契约变更,故单滚 product-v2 不需要连消费方一起滚。🔴 **未做任何功能调用取证,这是有意为之**:两个端点是 /internal/product/** 且当前零调用方,既不经网关、也没有任何 Feign 声明指向它们;直接打端口调用会真实扣减/恢复库存,副作用落在共享测试服上由其他会话承担,且 restore 不保证是 deduct 的精确逆操作。互斥本身由单测 ProductStockLockNameGuardTest(按效果反查两处 name 非空且相等)覆盖,这是 name 契约类改动的恰当取证层级。100503 在本服务上未做任何触发尝试,不要读成「测过不会触发」。gateway_status=not_required:两个端点路径/方法零变化,且 /internal/** 按 HL 架构约定不暴露至公网。frontend_status=not_required:全仓 grep 确认无任何 @FeignClient 声明指向 deduct-stock/restore-stock,order-v3 的 ProductFeignClient javadoc 亦明写这些接口「待后续 Issue 按需添加」,不存在任何前端或其他服务的触达路径,故本次互斥修复目前是预防性的。consumer 字段填 internal:这两个端点不属于 admin/mp 任何一端的直接接口。mmg 前端复核 2026-09-20:维持 not_required。/internal/product/** 不经网关、前端不可达;全仓无 @FeignClient 指向 deduct-stock/restore-stock(order-v3 ProductFeignClient javadoc 自标「待后续 Issue 按需添加」),当前零跨服务调用方,纯预防性闸口补齐,admin 端零触达路径零改动。若未来有服务接入 Feign 调用,100503 处理责任在调用方(判 body code 非 HTTP 状态,可重试,不当系统异常),已记。" updated_at: "2026-09-20" base: "dev-v3" --- # product-v2: 产品价格日历库存扣减/恢复两写口补同一把 @Lock4j name,新增可重试冲突码 100503 > **存放目录**: > - 一期(v2,无 `order-v3` 标签的工单)→ `changelogs/{YYYY-MM}/` > - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/{YYYY-MM}/` > > **服务**: hl-product-service-v2 > **PR**: #8047 > **Issue**: #7980(AC-8) > **日期**: 2026-09-20 > **影响范围**: `/internal/product/{productId}/deduct-stock`、`/internal/product/{productId}/restore-stock` 两个跨服务 internal 端点(**当前零跨服务调用方**,见业务边界) --- ## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写) - `ProductPriceCalendarService.deductStock` 与 `restoreStock` 的 `@Lock4j` 的 `keys` **逐字相同**,但**两处都没写 `name`**——lock4j 缺省 `name` 时把「包名+类名+方法名」拼进 Redis 键,两处因此**各持一把锁**,可以并发互相踩踏。而 Mapper 层的实现是 `select → 改 sold → updateById` 的读改写(本身不原子),正确性完全押在这把此前不生效的锁上。 - 本次给两处补上同一个显式 `name = "product:stock"`,互斥才真正生效。**没有新增接口、没有增删字段**,唯一契约变化是新增一个可能返回的失败码 **`100503`**,**HTTP 状态码仍是 200**。 - 🔴 **本次同时订正了 `InternalProductController:121` Swagger notes 里的错误说法**:改前写的是「乐观锁扣减价格日历库存」,但 `product_price_calendar` 表**无 `version` 列**、`ProductPriceCalendarDO` **无 `@Version`**,「乐观锁」这个说法是假的——真实机制是本次修复的这把分布式锁。前端/调用方若据旧 notes 认为这里有乐观锁重试语义,应以本文档为准撤销该认知。 - 🔴 **这把锁的覆盖边界(避免误读成"sold 并发已全面安全")**:同一行 `sold` 还有第三个写口 `ProductPriceCalendarMapper#batchUpsert`(管理端批量设价,经 `ProductPricingService`/`ProductSnapshotService` 调用),同样是 select→改 sold→updateById 的读改写,且**不取本锁**,表上无 `version`/CAS,唯一键 `uk_product_date_tier` 只约束行身份不约束 `sold`。所以「管理端批量设价 ∥ 扣库存/恢复库存」的丢更新依旧存在——那是另一个问题,本次这把锁挡不住,本 PR 未处理,不在本文档改动范围内。 - 🔴 **两个端点当前零跨服务调用方**:全仓 grep `*Feign*.java` 对 `deduct-stock`/`restore-stock` 零命中,没有任何其他微服务的 Feign 客户端声明指向这两个路径。补锁不是因为"现在正在出问题",而是补齐一条本该存在但一直缺失的互斥闸口——回归面可实证为零(不影响任何现存调用方),闸口本身仍要补齐,不能以"零调用方"为由不修。 --- ## 一、背景(选填) 工单 #7980 AC-8 原文写的是"零调用方",实测更准确的说法是「**零跨服务调用方**」——`InternalProductController:127/137` → `InternalProductService:590/600` → `deductStock`/`restoreStock` 这条链路在 `origin/dev-v3` 上本身是通的(Controller→Service→Mapper 三层代码完整、可编译、可被同服务内其他代码或未来的 Feign 客户端调用),只是当前没有任何其他微服务声明了指向这两个路径的 `@FeignClient` 方法。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 扣减库存(内部) | POST | `/internal/product/{productId}/deduct-stock` | 新增可能返回的错误码 | 抢锁超时返 100503(原接口/字段不变) | | 2 | 恢复库存(内部) | POST | `/internal/product/{productId}/restore-stock` | 新增可能返回的错误码 | 与 §1 共用同一把 `product:stock` 锁 | --- ## 三、接口详情 ### 1. 扣减库存(内部) `POST /internal/product/{productId}/deduct-stock` **VO**: `无 ReqVO(Query 参数直绑,无请求体) → Void` #### 使用场景 供其他微服务(如 order 系)通过 Feign 跨服务扣减产品价格日历库存(**当前无实际调用方**,见「⚠️ 关键变化」)。底层是 `select → 改 sold → updateById` 的读改写,靠 `@Lock4j(name=product:stock)` 分布式锁串行化;本表无 `version` 列、非乐观锁(Swagger notes 已随本次改动订正);本端点不做幂等,幂等由调用方保证。本次改动不涉及入参/出参,只新增一条「被 §2 恢复库存占用同一把锁」的失败分支。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | productId | Path | Long | ✅ | - | 产品 ID | | date | Query | String | ✅ | yyyy-MM-dd | 日期 | | count | Query | int | ✅ | `@Min(1)` | 扣减数量,至少为 1 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | data | null | 无返回数据,成功恒为 `null` | #### 请求示例 ```http POST /internal/product/1234567890123456789/deduct-stock?date=2026-10-01&count=2 ``` (跨服务 Feign 调用,无请求体,参数走 Query。) #### 响应示例 ```json { "code": 200, "message": "成功", "data": null, "success": true } ``` #### 空数据 / 降级响应 本接口无「空数据」/降级概念(成功恒返回 `data: null`;库存不足时不是"空数据"而是显式失败,见错误响应)。 #### 错误响应 ```json { "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 400 | 参数校验失败(`count < 1`) | | 480201 | 该日期库存不足或未开放(`sold + count` 超过 `dailyStock`,或该产品该日期无价格日历记录),请重新选择出发日期 | | **100503(本次新增)** | 抢锁等待超过 3 秒(与 §2 恢复库存共用同一把 `product:stock` 锁),**可重试** | #### 业务边界 - 无 `@Idempotent`——本端点不做幂等,幂等由调用方(order)保证,本次改动未涉及此设计。 - 抢锁失败(100503)时该次请求未进入方法体,零写入——`sold` 列不会被部分更新。 - 库存判定只按 `tier_seq=1`(默认档位)查询(`selectByProductIdAndDate` 硬编码),多档位产品的其余档位库存不受本端点影响——这是既有实现的既有边界,非本次改动引入,本 PR 未处理。 - 该锁**只覆盖本方法与 §2 恢复库存**,不覆盖 `batchUpsert`(管理端批量设价),见「⚠️ 关键变化」。 - 老数据兼容:无字段变化。 --- ### 2. 恢复库存(内部) `POST /internal/product/{productId}/restore-stock` **VO**: `无 ReqVO(Query 参数直绑,无请求体) → Void` #### 使用场景 供其他微服务在订单取消/过期时通过 Feign 跨服务恢复产品价格日历库存(**当前无实际调用方**,见「⚠️ 关键变化」)。底层同样是 `select → 改 sold → updateById` 的读改写,`sold` 不减到负数(`Math.max(sold-count, 0)`)。本次改动不涉及入参/出参,只新增一条「被 §1 扣减库存占用同一把锁」的失败分支。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | productId | Path | Long | ✅ | - | 产品 ID | | date | Query | String | ✅ | yyyy-MM-dd | 日期 | | count | Query | int | ✅ | `@Min(1)` | 恢复数量,至少为 1 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | data | null | 无返回数据,成功恒为 `null` | #### 请求示例 ```http POST /internal/product/1234567890123456789/restore-stock?date=2026-10-01&count=2 ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": null, "success": true } ``` #### 空数据 / 降级响应 该产品该日期无价格日历记录时静默跳过(不抛异常,`ProductPriceCalendarMapper#restoreStock` 内部 `record == null` 时直接 return,无任何写入)。恢复数量超过已扣数量时 `sold` 按 0 兜底,不会变负数,同样不报错。 #### 错误响应 ```json { "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 400 | 参数校验失败(`count < 1`) | | **100503(本次新增)** | 抢锁等待超过 3 秒(与 §1 扣减库存共用同一把 `product:stock` 锁),**可重试** | #### 业务边界 - 无 `@Idempotent`,同 §1。 - 抢锁失败(100503)时零写入。 - 本方法本身不会因"库存记录不存在"或"恢复超过已扣数量"抛错,均静默处理(这是既有行为,非本次改动引入)。 - 老数据兼容:无字段变化。 --- ## 四、契约约束与正确调用方式(接口类必写) > 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。 ### 并发冲突(100503)的正确处理方式 两个端点本次开始**真正互斥**(同一 `productId+date` 维度):同一时刻只有一个能执行,其余排队等待,等待超过 3 秒(`acquireTimeout`,本组 `@Lock4j` 未显式设置,取 lock4j-core 注解默认值 3000ms)直接返回失败。 | 场景 | 响应 | |------|------| | ✅ 单个请求,无并发 | 200 + 正常业务结果 | | ✅ 同 `productId+date` 两个请求先后到达,第二个在 3 秒内轮到锁 | 两个都 200(第二个排队等待) | | ❌ 同 `productId+date` 两个请求并发,第二个等待超过 3 秒未抢到锁 | `{ "code": 100503, "message": "资源被占用,请稍后重试", "success": false }`,**HTTP 状态码仍是 200** | | ✅ 不同 `productId` 或不同 `date` 并发 | 互不影响,各自独立加锁(锁键含 `productId` 与 `date`) | **调用方(若未来接入)必须做的事**:判断响应体 `code === 100503`(不是判 HTTP 状态码),命中时按业务语义重试或提示上游;**不要**当作系统异常。 ### Swagger notes 订正说明(供以此为契约的调用方核对) `InternalProductController:121` 扣减库存端点的 Swagger notes 已从「乐观锁扣减价格日历库存」改为「实现是 select→改 sold→updateById 的读改写,靠 @Lock4j(name=product:stock) 分布式锁串行化,本表无 version 列、非乐观锁」。任何据旧 notes 编写的调用方文档/注释若沿用了"乐观锁"这一表述,应据本次改动订正。 --- ## 五、数据库行为(涉及写操作时必写) 本次不改变任何一次**成功**写操作实际写入的行数或内容——两个端点各自原有的写入逻辑(`sold` 列增减)逐字节不变。变化的只是"谁能在同一时刻对同一 `productId+date` 执行写入": | 维度 | 改动前 | 改动后 | |------|--------|--------| | 两个端点是否互斥(同 productId+date) | 否(各自持独立锁,可并发) | 是(同一把锁,串行执行) | | 抢锁失败时是否有部分写入 | N/A(此前不会因锁而失败) | 否,零写入(`@Lock4j` 方法级环绕拦截,拿不到锁直接抛异常,不产生任何 SQL) | | `batchUpsert`(管理端批量设价)与本组两端点的并发关系 | 不受本锁保护,仍可能丢更新 | 不变——本次改动未处理,见「⚠️ 关键变化」 | --- ## 六、边界行为 - 参数校验失败(`count<1`)→ 400 - 库存不足或未开放(仅 `deduct-stock`)→ 480201 - **抢锁超时(本次新增)→ 100503,HTTP 200,可重试,零写入** - `restore-stock` 对不存在的价格日历记录/超额恢复 → 静默跳过或按 0 兜底,不报错,与本次改动无关 - 老数据兼容:本次不涉及字段增删,存量数据无需迁移 --- ## 六.5、枚举 / 数据字典(接口出现枚举时必写) 本组两个接口均为纯数值/日期参数,无枚举字段,本节不适用。 ## 六.6、修改前后对比(修改/删除类接口必写,新增跳过) ### 字段级对比 | 字段 | 改前 | 改后 | |------|------|------| | (无)| 两个接口的请求参数、响应结构逐字节不变 | 同左 | ### 行为级对比 | 行为 | 改前 | 改后 | |------|------|------| | 两个端点同 productId+date 并发时 | 各自持独立锁,可同时执行(读改写非原子,可能丢更新) | 同一把锁,串行执行,互斥真正生效 | | 抢锁失败的响应 | 不存在这条路径 | 路径真实存在(返回 100503,HTTP 200,业务失败,可重试);本次改动未部署测试服,无实测触发频率数据 | | `InternalProductController:121` Swagger notes | 「乐观锁扣减价格日历库存」(与表结构不符的错误描述) | 订正为读改写 + 分布式锁串行化的真实机制描述 | | 请求/响应字段、既有业务错误码(480201) | 不变 | 不变 | ## 六.7、影响评估(修改/删除类必写) - **是否破坏向后兼容**: 否——不改字段、不改既有错误码语义;新增的 100503 失败路径由于**当前零跨服务调用方**,对现存系统零影响面。 - **前端是否必须同步上线**: 不适用——两个端点没有任何前端或其他服务的直接调用方,本次改动不需要任何下游同步。若未来有服务接入 Feign 调用这两个端点,调用方需按「四、契约约束」处理 100503。 - **前端 workaround 清理点**: 无。 ## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面) - **仅影响**: `/internal/product/{productId}/deduct-stock`、`/internal/product/{productId}/restore-stock` 两个端点在**未来有并发调用方时**的失败分支;当前因零调用方,实际影响面为零 - **零影响**: - `ProductPriceCalendarMapper#batchUpsert`(管理端批量设价,经 `ProductPricingService`/`ProductSnapshotService` 调用)——不取本锁,行为未变,仍可能与本组两端点产生 `sold` 丢更新(既有问题,本次未处理) - 价格日历的其余读接口(按产品/月份/日期范围查询等) - `InternalProductController` 其余端点(产品详情、简单报价、班期信息/报价、报名入团等) - 两个端点的请求参数、响应结构 - 既有业务错误码 480201 的触发条件与文案 - hl-gateway 路由配置——`/internal/**` 本就不对公网暴露,本次未新增任何路由 --- ## 八、测试环境已验证 **部署读数(2026-09-20 23:20)**:hl-product-service-v2 滚动更新两实例,部署前 `4cbccc26b`(停在 2026-09-19,落后 93 个提交)→ 部署后 `ba8aab3ab`,8183 与 8083 各 7 秒内起监听,`deploy-backend.sh` 报 `rolling deploy complete`。`git merge-base --is-ancestor f72a7548c ba8aab3ab` 返回 **ANCESTOR_YES**,本次改动确在运行版本内。 🔴 **本节到此为止,没有功能调用读数,这是有意为之而不是遗漏**:两个端点是 `/internal/product/**`、当前**零调用方**,既不经网关也无任何 Feign 声明指向;直接打端口调用会**真实扣减/恢复库存**,副作用落在共享测试服上由其他会话承担,且 `restore-stock` 不保证是 `deduct-stock` 的精确逆操作(不可靠地撤回)。互斥本身由单测 `ProductStockLockNameGuardTest` 覆盖(按效果反查两处 `name` 非空且相等),这是 `name` 契约类改动的恰当取证层级。 ⚠️ **`100503` 在本服务上一次都没有触发过**——不是「测过、不会触发」,是**没测**。 --- ## 十、相关文档 - 关联 Issue: [wx/HL#7980](https://git.1814.love:8443/wx/HL/issues/7980)(AC-8) - 关联 PR: [wx/HL#8047](https://git.1814.love:8443/wx/HL/pulls/8047) ## 关联 / 联系人 ### 链接 - **Issue**: [#7980](https://git.1814.love:8443/wx/HL/issues/7980) - **PR**: [#8047](https://git.1814.love:8443/wx/HL/pulls/8047) - **Merge commit**: [f72a7548c](https://git.1814.love:8443/wx/HL/commit/f72a7548c)(合并后回填,backend_status 待部署后翻转) ### 联系人 - **后端负责人**: @wx