19 KiB
schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
| schema | ticket | title | consumer | author | change_type | backend_status | gateway_status | frontend_status | frontend_owner | frontend_ref | target_release | verified_at | status_note | updated_at | base |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| hl-changelog/v2 | 7980 | 产品价格日历库存扣减/恢复两写口补同一把 @Lock4j name,新增可重试冲突码 100503,并订正 Swagger notes 里『乐观锁』的错误说法(接口路径/字段零增删) | internal | wx(GIT) | 修改接口 | deployed | not_required | not_required | 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 状态,可重试,不当系统异常),已记。 | 2026-09-20 | 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:121Swagger 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<Void>
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回数据,成功恒为 null |
请求示例
POST /internal/product/1234567890123456789/deduct-stock?date=2026-10-01&count=2
(跨服务 Feign 调用,无请求体,参数走 Query。)
响应示例
{ "code": 200, "message": "成功", "data": null, "success": true }
空数据 / 降级响应
本接口无「空数据」/降级概念(成功恒返回 data: null;库存不足时不是"空数据"而是显式失败,见错误响应)。
错误响应
{ "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<Void>
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回数据,成功恒为 null |
请求示例
POST /internal/product/1234567890123456789/restore-stock?date=2026-10-01&count=2
响应示例
{ "code": 200, "message": "成功", "data": null, "success": true }
空数据 / 降级响应
该产品该日期无价格日历记录时静默跳过(不抛异常,ProductPriceCalendarMapper#restoreStock 内部 record == null 时直接 return,无任何写入)。恢复数量超过已扣数量时 sold 按 0 兜底,不会变负数,同样不报错。
错误响应
{ "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(AC-8)
- 关联 PR: wx/HL#8047
关联 / 联系人
链接
联系人
- 后端负责人: @wx