32 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 | 车型价格日历四个写接口补齐真实并发互斥,新增可重试冲突码 100503(接口路径/字段零增删) | admin | wx(GIT) | 修改接口 | deployed | not_required | not_required | mmg | PR #8035 已 squash 合并 dev-v3(合并提交 ee1937950),2026-09-20 16:04 已部署测试服 hl-fleet-service(滚动更新两实例,部署前 53c2ff2d1,部署后 10efbaddf,7 秒内起监听;git merge-base --is-ancestor ee1937950 10efbaddf 返回 ANCESTOR_YES,确认本次改动已在运行版本内)。gateway_status 保持 not_required:四个端点路径/方法零变化,hl-gateway 版本未动(4cbccc26b),本次与网关路由无关,不是漏验证。测试服 redis-cli MONITOR 实时抓包证实 12 并发 batchSetPrices 只命中同一把锁键 lock4j:fleet:pricing-calendar:write#fleet:pricing-calendar:write(改前坏形态即锁键含方法名一次未现),12 次并发被序列化成 12 组干净的 acquire→release,未观察到两个持有者同时持锁;71 次单独/批量 PUT/DELETE 回归全部 code=200。🔴 100503 未能复现:额外用 4/8/17/34 并发 × 365 天多轮尝试均返回 200,一次未触发 100503(推断原因:单车型 366 天 DB 往返本次优化后从 732 次降到个位数,临界区极短,测试服并发规模下累计排队时间仍远小于 acquireTimeout 默认 3000ms)。该错误码源码路径真实存在(acquireTimeout 超时 + LockFailureExceptionHandler 转 100503 + HTTP 200 均已读码确认),但测试服未能触发,不代表更长事务/更大载荷/生产数据量下不会触发,详见正文「八、测试环境已验证」与各接口错误响应说明。工单 #7980 P0-1:hl-fleet-service 车型价格日历四个写口(批量设价/多车型批量设价/批量改状态/清除价格日历)此前虽各自写了相同的 @Lock4j keys,但没写 name;lock4j 落 Redis 的锁键含方法名,name 缺省时退化成类全限定名+方法名,四处因此各自持有一把互不相干的锁——接口文档里一直写着的「四个价格写端点全互斥」此前并不成立。本次给四处补上同一个显式 name,互斥才真正生效。接口路径、请求/响应字段、既有 4 个业务错误码(600500-600503)均无变化。测试数据写在未使用的未来年份(2027 部分/2028/2031/2032/2033,共 71 组车型×年份,改前全为空),已用 71 次 DELETE 逐一还原并按年 overview 复核归零,真实业务数据(2026-09 丰田普拉多 24 条)抽查与改前一致。 前端实证维持 not_required(mmg 2026-09-20):fleet/pricing/index.vue 四个写操作(setPricingCalendar/batchSetPricing/setPricingStatus/clearPricingCalendar)catch 均注释「错误文案由统一响应拦截器提示」、finally 复位提交态,100503 命中时拦截器透后端 message「资源被占用,请稍后重试」message.error 展示——提示可重试/不静默/不当系统异常三条全满足,100502 与 100503 文案由后端各自下发天然区分,同 #7529 先例不建字典。 | 2026-09-20 | dev-v3 |
fleet: 车型价格日历四个写接口补齐真实并发互斥,新增可重试冲突码 100503
存放目录:
- 一期(v2,无
order-v3标签的工单)→changelogs/{YYYY-MM}/- 二期(v3,
order-v3标签的工单)→changelogs-v2/{YYYY-MM}/服务: hl-fleet-service (8087) PR: #8035 Issue: #7980 日期: 2026-09-20 影响范围: 车管控制台「价格日历」页四个写操作(批量设价 / 多车型批量设价 / 批量改状态 / 清除价格日历)
⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
- 这四个接口的 Swagger/接口文档一直写着「四个价格写端点全互斥」,但这句话此前是假的:四处只统一了
@Lock4j的keys,没统一name,而 lock4j 落 Redis 的锁键里含方法名,所以四个方法实际各自持一把互不相干的锁,可以并发互相踩踏(读改写丢更新)。 - 本次改动只是把文档承诺的行为补成真的——没有新增接口、没有增删字段。
- 副作用:现在这四个端点真的会互相排队。之前测试/联调时同时点两个价格写操作不会互相挡;补丁上线后会挡,抢锁超时(默认 3 秒)会返回新的业务失败码
100503,HTTP 状态码仍是 200——前端如果只看 HTTP 状态码会把这次失败误判为成功,必须显式判code。 - 🔴 两句话都要看,缺一句都会误判:
- 这条失败路径源码级真实存在——
acquireTimeout默认 3000ms、超时经LockFailureExceptionHandler转100503、@ResponseStatus(HttpStatus.OK)确认 HTTP 200 + body 携带业务码,这套链路已逐行读码确认,不是猜测。 - 但测试服 2026-09-20 部署后实测,用 4/8/17/34 并发 × 365 天多轮尝试,一次都没有触发 100503(原因见「八、测试环境已验证」:本次优化后单车型 366 天的 DB 往返从 732 次降到个位数,临界区极短,测试服并发规模下排队耗时远小于 3 秒)。这不代表它不会发生——更长事务、更大载荷、生产数据量都没测到;只是按当前观测,触发频率极低,前端仍应做防御性处理(识别
code=100503提示可重试)、但不必按高频交互设计。
- 这条失败路径源码级真实存在——
- ✅ 部署与互斥已实测确认:
hl-fleet-service测试服 2026-09-20 16:04 已部署含本次改动的版本(ee1937950是10efbaddf的祖先,git merge-base --is-ancestor确认);redis-cli MONITOR实时抓包证实 12 并发batchSetPrices全部命中同一把锁键,串行 acquire→release,未观察到两个持有者同时持锁——「全互斥」这句话现在是真的。
一、背景(选填)
PricingCalendarService 四个价格写方法此前各自加了 @Lock4j(keys = {"'fleet:pricing-calendar:write'"}, expire = ...),keys 字面量相同,容易被误读成"已经互斥"。但 lock4j 生成的 Redis key 实际是 <name>#<keys 求值>,name 缺省时退化为「方法所在类全限定名 + 方法名」,四个方法名不同 ⇒ 四把锁不同。本类没有任何第二道锁兜底(无 @Version、无 selectOneForUpdate、无 CAS),唯一的 uk_pricing_model_date 唯一索引只挡得住"同日两条 insert",挡不住"批量设价 ∥ 批量改状态"这类 select→内存改→updateBatch 的读改写并发丢更新。工单 #7980 P0-1 把四处 @Lock4j 补上同一个显式 name="fleet:pricing-calendar:write",锁真正统一。
二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 批量设置价格 | PUT | /admin/fleet/pricing-calendar/{vehicleModelId} |
新增可能返回的错误码 | 抢锁超时返 100503(原接口/字段不变) |
| 2 | 批量修改状态 | PUT | /admin/fleet/pricing-calendar/{vehicleModelId}/status |
新增可能返回的错误码 | 抢锁超时返 100503(原接口/字段不变) |
| 3 | 清除价格日历 | DELETE | /admin/fleet/pricing-calendar/{vehicleModelId} |
新增可能返回的错误码 | 抢锁超时返 100503(原接口/字段不变) |
| 4 | 多车型批量设价 | PUT | /admin/fleet/pricing-calendar/batch-set |
新增可能返回的错误码 | 抢锁超时返 100503(原接口/字段不变) |
三、接口详情
1. 批量设置价格 PUT /admin/fleet/pricing-calendar/{vehicleModelId}
VO: PricingCalendarSetReqVO → Void
使用场景
车管控制台「价格日历」页,运营在某个车型的日历视图上框选一段日期区间,按筛选规则(工作日/周末/仅节假日/指定星期/排除日)批量设置这段区间内每天的租赁价与可售状态。本次改动不涉及本接口的入参/出参字段,只新增一条"被另一个价格写请求占用锁时"的失败分支。
入参
路径参数 vehicleModelId(车型型号 ID,雪花 ID)+ Body:
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| vehicleModelId | Path | Long | ✅ | - | 车型型号 ID |
| startDate | Body | String(date) | ✅ | yyyy-MM-dd | 开始日期,示例 2026-03-01 |
| endDate | Body | String(date) | ✅ | yyyy-MM-dd | 结束日期,示例 2026-03-31 |
| adjustMode | Body | String | - | 枚举 ABS/DELTA/PERCENT,不传默认 ABS |
调价模式,见六.5 |
| dayPrice | Body | BigDecimal | 条件必填 | ≥0,整数最多10位、小数最多2位 | adjustMode=ABS(含不传)时必填生效 |
| adjustValue | Body | BigDecimal | 条件必填 | 整数最多10位、小数最多2位,可负 | adjustMode=DELTA/PERCENT 时必填;DELTA=加减金额,PERCENT=百分比(10=+10%) |
| status | Body | String | - | 枚举 AVAILABLE/CLOSED,不传默认 AVAILABLE |
日历状态,见六.5 |
| remark | Body | String | - | - | 备注 |
| weekdayOnly | Body | Boolean | - | 与 weekendOnly/holidayOnly 至多真一个 | 仅工作日 |
| weekendOnly | Body | Boolean | - | 与 weekdayOnly/holidayOnly 至多真一个 | 仅周末 |
| holidayOnly | Body | Boolean | - | 与 weekdayOnly/weekendOnly 至多真一个 | 仅节假日(真相源 Nacos fleet.pricing.holidays) |
| selectedWeekdays | Body | List<Integer> | - | 1=周一...7=周日 | 指定星期几才设置 |
| excludeDates | Body | List<String(date)> | - | yyyy-MM-dd 数组 | 区间内排除日不设置 |
出参 Result<Void>
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回数据,Result.data 恒为 null |
请求示例
{
"startDate": "2026-03-01",
"endDate": "2026-03-31",
"adjustMode": "ABS",
"dayPrice": 800.00,
"status": "AVAILABLE",
"remark": "旺季价格",
"weekdayOnly": false,
"weekendOnly": false,
"holidayOnly": false,
"selectedWeekdays": [1, 2, 3, 4, 5],
"excludeDates": ["2026-03-08"]
}
响应示例
{ "code": 200, "message": "成功", "data": null, "success": true }
空数据 / 降级响应
本接口无「空数据」概念(成功恒返回 data: null),也没有降级路径;筛选区间内 0 命中(比如全落在排除日)时同样返回 200 成功、只是没有任何行被写入。
错误响应
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
| code | 触发条件 |
|---|---|
| 400 | 参数校验失败(互斥筛选同时为真 / adjustMode 对应字段未填 / adjustMode·status 不在白名单) |
| 600500 | 日租价为负(ABS 模式 dayPrice<0 的防御兜底;正常场景已被 @DecimalMin 拦在参数层) |
| 600501 | 日期范围非法:开始日期晚于结束日期 |
| 600502 | 日期范围超过 366 天 |
| 600503 | 车型型号不存在 |
| 100503(本次新增) | 抢锁等待超过 3 秒(另一个价格写端点正持锁),可重试 |
| 401 | 未登录(网关拦截) |
实测说明(2026-09-20 测试服):100503 路径源码级真实存在,但 4/8/17/34 并发 × 365 天多轮测试均未触发(临界区极短,排队耗时远小于 3 秒超时);不代表更长事务/更大载荷下不会触发,前端仍应识别该码并提示可重试,无需按高频路径设计交互。
业务边界
- 幂等:
@Idempotent30 秒防重(键=车型+全入参摘要),同一请求 30 秒内重复提交直接按幂等拦截处理,不会走到锁竞争这一步。 - 抢锁失败(100503)时该次请求没有进入方法体,零写入——Lock4j 是方法级环绕拦截,拿不到锁直接抛异常,业务代码一行都不会执行。
DELTA/PERCENT逐日基于命中日现价计算,未设价日回退车型basePrice,两者都没有则该日跳过不写;计算结果小于 0 按 0 兜底。- 老数据兼容:
adjustMode不传按ABS处理,向后兼容旧的"只传固定价"请求。
2. 批量修改状态 PUT /admin/fleet/pricing-calendar/{vehicleModelId}/status
VO: PricingCalendarStatusReqVO → Void
使用场景
只改可售状态、不改价格的场景,例如运营临时把某段日期标记为"关闭"(不可售)但不想动价格。本次改动不涉及本接口的入参/出参字段,只新增一条抢锁超时的失败分支。
入参
路径参数 vehicleModelId + Body:
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| vehicleModelId | Path | Long | ✅ | - | 车型型号 ID |
| startDate | Body | String(date) | ✅ | yyyy-MM-dd | 开始日期 |
| endDate | Body | String(date) | ✅ | yyyy-MM-dd | 结束日期 |
| status | Body | String | ✅ | 枚举 AVAILABLE/CLOSED |
目标状态,见六.5 |
| remark | Body | String | - | - | 备注 |
出参 Result<Void>
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回数据,Result.data 恒为 null |
请求示例
{
"startDate": "2026-03-01",
"endDate": "2026-03-31",
"status": "CLOSED",
"remark": "临时停租"
}
响应示例
{ "code": 200, "message": "成功", "data": null, "success": true }
空数据 / 降级响应
无「空数据」概念;区间内没有任何已设价日时同样返回 200 成功(本接口只改状态,不要求区间内先有价格记录)。
错误响应
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
| code | 触发条件 |
|---|---|
| 400 | 参数校验失败(status 不在白名单等) |
| 600501 | 日期范围非法:开始日期晚于结束日期 |
| 600502 | 日期范围超过 366 天 |
| 600503 | 车型型号不存在 |
| 100503(本次新增) | 抢锁等待超过 3 秒(与全部价格写端点共用同一把锁),可重试 |
| 401 | 未登录(网关拦截) |
实测说明(2026-09-20 测试服):100503 路径源码级真实存在,但 4/8/17/34 并发 × 365 天多轮测试均未触发(临界区极短,排队耗时远小于 3 秒超时);不代表更长事务/更大载荷下不会触发,前端仍应识别该码并提示可重试,无需按高频路径设计交互。
业务边界
- 幂等:
@Idempotent3 秒防重(键=车型+全入参摘要)。 - 与「批量设置价格」「清除价格日历」「多车型批量设价」共用同一把全局写锁,本次改动之后四者才真正互斥;抢锁失败零写入,逻辑同上一节。
- 不影响价格字段,只改状态列。
3. 清除价格日历 DELETE /admin/fleet/pricing-calendar/{vehicleModelId}
VO: 无 ReqVO(query 参数直绑)→ Void
使用场景
删除指定日期区间内某车型的价格记录(软删除)。本次改动不涉及本接口的入参/出参字段,只新增一条抢锁超时的失败分支。
入参
路径参数 + Query 参数(无请求体):
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| vehicleModelId | Path | Long | ✅ | - | 车型型号 ID |
| startDate | Query | String(date) | ✅ | yyyy-MM-dd | 开始日期 |
| endDate | Query | String(date) | ✅ | yyyy-MM-dd | 结束日期 |
出参 Result<Void>
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回数据,Result.data 恒为 null |
请求示例
DELETE /admin/fleet/pricing-calendar/1234567890123456789?startDate=2026-03-01&endDate=2026-03-10
Authorization: Bearer {token}
响应示例
{ "code": 200, "message": "成功", "data": null, "success": true }
空数据 / 降级响应
区间内没有任何设价记录时同样返回 200 成功,删除行数为 0,不报错。
错误响应
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
| code | 触发条件 |
|---|---|
| 400 | 参数校验失败(日期格式非法等) |
| 600501 | 日期范围非法:开始日期晚于结束日期 |
| 600502 | 日期范围超过 366 天 |
| 600503 | 车型型号不存在 |
| 100503(本次新增) | 抢锁等待超过 3 秒(与全部价格写端点共用同一把锁),可重试 |
| 401 | 未登录(网关拦截) |
实测说明(2026-09-20 测试服):100503 路径源码级真实存在,但 4/8/17/34 并发 × 365 天多轮测试均未触发(临界区极短,排队耗时远小于 3 秒超时);不代表更长事务/更大载荷下不会触发,前端仍应识别该码并提示可重试,无需按高频路径设计交互。
业务边界
- 幂等:
@Idempotent3 秒防重(键=车型+起止日期)。 - 软删除:经
@TableLogic置删除标记,不是物理删除。 - 与其余三个价格写端点共用同一把全局写锁,抢锁失败零写入。
4. 多车型批量设价 PUT /admin/fleet/pricing-calendar/batch-set
VO: PricingCalendarBatchSetReqVO → Void
使用场景
车管控制台原型 BulkPriceSheet:一次对多个车型型号执行与「批量设置价格」完全相同的设价逻辑(筛选/调价模式/逐日 upsert 逐车型执行),整批一个事务。本次改动不涉及本接口的入参/出参字段,只新增一条抢锁超时的失败分支;本端点锁租约 300 秒(其余三处 30 秒),是持有者自己的 TTL、不是键的属性,不影响"是不是同一把锁"。
入参
Body(继承「批量设置价格」全部字段 + 新增 vehicleModelIds):
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| vehicleModelIds | Body | List<Long>(JSON 传字符串数组) | ✅ | 1-50 个,任一不存在(含已软删)整批拒绝 | 车型型号 ID 列表,重复自动去重 |
| startDate | Body | String(date) | ✅ | yyyy-MM-dd | 开始日期 |
| endDate | Body | String(date) | ✅ | yyyy-MM-dd | 结束日期 |
| adjustMode | Body | String | - | 枚举 ABS/DELTA/PERCENT,不传默认 ABS |
调价模式,同 §1 |
| dayPrice | Body | BigDecimal | 条件必填 | ≥0,同 §1 | adjustMode=ABS 时必填 |
| adjustValue | Body | BigDecimal | 条件必填 | 同 §1 | adjustMode=DELTA/PERCENT 时必填 |
| status | Body | String | - | 枚举 AVAILABLE/CLOSED |
不传默认 AVAILABLE |
| remark | Body | String | - | - | 备注 |
| weekdayOnly / weekendOnly / holidayOnly | Body | Boolean | - | 三者至多真一个 | 同 §1 |
| selectedWeekdays | Body | List<Integer> | - | 1-7 | 同 §1 |
| excludeDates | Body | List<String(date)> | - | yyyy-MM-dd 数组 | 同 §1 |
出参 Result<Void>
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回数据,Result.data 恒为 null |
请求示例
{
"vehicleModelIds": ["1234567890123456789", "1234567890123456790"],
"startDate": "2026-03-01",
"endDate": "2026-03-31",
"adjustMode": "PERCENT",
"adjustValue": 10,
"status": "AVAILABLE"
}
响应示例
{ "code": 200, "message": "成功", "data": null, "success": true }
空数据 / 降级响应
无「空数据」概念;筛选区间内 0 命中同样返回 200 成功、没有任何行被写入。
错误响应
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
| code | 触发条件 |
|---|---|
| 400 | 参数校验失败(互斥/条件必填/白名单/列表 1-50) |
| 600500 | 日租价为负(防御兜底) |
| 600501 | 日期范围非法 |
| 600502 | 日期范围超过 366 天 |
| 600503 | 任一车型不存在(整批拒绝,消息列出全部缺失 ID) |
| 100503(本次新增) | 抢锁等待超过 3 秒(与全部价格写端点共用同一把锁),可重试 |
| 401 | 未登录(网关拦截) |
实测说明(2026-09-20 测试服):本端点最重(最坏 50 车型 × 366 天),理应是最容易触发 100503 的一方,但同样在 4/8/17/34 并发多轮测试中未触发;不代表更大批量/生产数据量下不会触发,前端仍应识别该码并提示可重试。
业务边界
- 幂等:
@Idempotent300 秒防重(键=排序去重vehicleModelIds摘要+全入参摘要)。 - 事务:整批一个事务,任一车型失败全部回滚,不存在"部分车型成功、部分失败"。
- 本端点最重(最坏 50 车型 × 366 天),是最容易触发另外三个端点抢锁失败的一方;反过来它自己也会被其他三个端点先持锁而等待。
- 抢锁失败零写入,逻辑同 §1。
四、契约约束与正确调用方式(接口类必写)
本节只写后端接受/拒绝 payload 的规则,不写 UI 渲染建议。
并发冲突(100503)的正确处理方式
四个价格写端点(批量设价 / 批量改状态 / 清除价格日历 / 多车型批量设价)本次开始真正互斥:同一时刻只有一个能执行,其余排队等待,等待超过 3 秒(acquireTimeout 默认值)直接返回失败,不会无限排队。
| 场景 | 响应 |
|---|---|
| ✅ 单个请求,无并发 | 200 + 正常业务结果 |
| ✅ 两个请求先后到达,第二个在 3 秒内轮到锁 | 两个都 200(第二个排队等待,非立即失败) |
| ❌ 两个请求并发,第二个等待超过 3 秒未抢到锁 | { "code": 100503, "message": "资源被占用,请稍后重试", "success": false },HTTP 状态码仍是 200 |
前端必须做的事:判断响应体 code === 100503(不是判 HTTP 状态码),命中时提示"另一个价格操作正在进行,请稍后重试"并允许用户重新点击提交;不要当作系统异常(不要弹通用错误 toast、不要上报错误监控当 bug)、不要静默吞掉不提示。
关于触发频率(2026-09-20 测试服实测,务必两句一起看):这条路径源码级真实存在(acquireTimeout 默认 3000ms 超时 → 100503,HTTP 200 + body 携带业务码),但测试服用 4/8/17/34 并发 × 365 天多轮尝试均未触发——本次优化后单次临界区极短,几十并发的累计排队时间仍远小于 3 秒。这不等于它不会发生:更长事务、更大载荷、生产数据量下的表现没有测到。结论是前端仍按上面的方式做防御性处理,但不必按高频路径设计交互(不需要专门的排队动画/进度条,一个普通的失败提示+可重试即可)。
幂等与并发冲突的区别
- 请求体逐字节相同且在幂等窗口(3~300 秒,因端点而异)内重复提交 → 幂等拦截,返回
100502(请勿重复提交),这是另一个已存在的错误码,本次不变。 - 请求体不同或已过幂等窗口,但撞上另一个正在执行的价格写请求 → 本次新增的
100503。 两者都是 HTTP 200 + 业务失败码,前端处理方式类似(提示 + 允许重试),但文案应该区分("重复提交"vs"资源占用")。
五、数据库行为(涉及写操作时必写)
本次不改变任何一次成功写操作实际写入的行数或内容——四个端点各自原有的写入逻辑(upsert 设价行 / 更新状态列 / 软删除标记)逐字节不变。
变化的只是"谁能在同一时刻执行写入":
| 维度 | 改动前 | 改动后 |
|---|---|---|
| 四个端点是否互斥 | 否(各自持独立锁,可并发) | 是(同一把锁,串行执行) |
| 抢锁失败时是否有部分写入 | N/A(此前不会因为锁而失败) | 否,零写入(Lock4j 方法级环绕,拿不到锁直接抛异常,业务方法体不会被调用,不产生任何 SQL) |
六、边界行为
- 未登录 → 401(网关拦截)
- 车型不存在 → 600503
- 日期范围非法/超限 → 600501/600502
- 抢锁超时(本次新增)→ 100503,HTTP 200,可重试,零写入
- 幂等窗口内重复提交(请求体相同)→ 100502,与本次改动无关,行为不变
- 老数据兼容:
adjustMode/status不传时按各自默认值处理,行为不变
六.5、枚举 / 数据字典(接口出现枚举时必写)
以下两个枚举字段在四个接口里均未变化,仅为方便前端自包含联调随本次改动一并列出。
adjustMode(PricingAdjustMode,出现于 §1/§4)
所属字段: adjustMode | 类型: String
| 值 | 中文 | 说明 |
|---|---|---|
ABS(不传默认) |
设为固定价 | dayPrice 必填且生效 |
DELTA |
按金额调整 | 在各日现价上加减 adjustValue(¥,可负) |
PERCENT |
按百分比调整 | 在各日现价上按 adjustValue% 调整(10=+10%、-5=-5%) |
status(PricingCalendarStatus,出现于 §1/§2/§4)
所属字段: status | 类型: String
| 值 | 中文 | 说明 |
|---|---|---|
AVAILABLE(§1/§4 不传默认) |
可售 | 该日可被预订/占用 |
CLOSED |
关闭 | 该日不可售 |
§2「批量修改状态」的
status是必填字段,无默认值;§1/§4 的status不传时默认AVAILABLE。
六.6、修改前后对比(修改/删除类接口必写,新增跳过)
字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| (无) | 四个接口的请求字段、响应字段逐字节不变 | 同左 |
行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 四个价格写端点并发时 | 各自持独立锁,可同时执行(读改写可能互相覆盖,静默丢更新) | 同一把锁,串行执行,互斥真正生效 |
| 抢锁失败的响应 | 不存在这条路径(此前锁从不因"别的价格写端点占用"而失败) | 路径真实存在(返回 100503,HTTP 200,业务失败,可重试),但测试服 4/8/17/34 并发 × 365 天多轮实测均未触发,实际频率极低 |
| 接口文档"四个价格写端点全互斥"这句话 | 写在文档里但不成立 | 写在文档里且成立 |
| 请求/响应字段、既有 4 个错误码(600500-600503) | 不变 | 不变 |
六.7、影响评估(修改/删除类必写)
- 是否破坏向后兼容: 否——不改字段、不改既有错误码语义;但新增了一条此前不存在的失败路径(100503),前端若没有处理未知业务码的兜底逻辑,可能会把这次失败当成异常展示成不友好的提示(而不是"请重试")。测试服实测该路径触发频率极低(多轮高并发未复现),但源码路径真实存在,不能按"不会发生"处理。
- 前端是否必须同步上线: 建议同步,但不强制阻塞——触发频率低,短期不做也大概率不影响日常使用;不处理 100503 时,命中该分支会走前端现有的"未识别错误码"兜底路径(若前端有通用兜底,用户会看到较生硬的错误文案,而不是崩溃或数据错乱)。
- 前端 workaround 清理点: 无(本次是新增失败路径,不是清理旧 workaround)。
七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- 仅影响: 车管控制台「价格日历」页的四个写操作,抢锁失败时的响应体
- 零影响:
- 「查询价格日历」
GET /admin/fleet/pricing-calendar/{vehicleModelId}(只读,无锁) - 「价格日历聚合总览」
GET /admin/fleet/pricing-calendar/overview(只读,无锁) - 四个写接口的请求字段、响应字段结构
- 既有 4 个业务错误码 600500-600503 的触发条件与文案
- 幂等拦截(100502)的行为
- 车型基础信息、车队其余模块(派单/对账/司导预支等)
- 单个价格写请求在无并发时的行为(延迟、返回值、写入内容全部不变)
- 「查询价格日历」
八、测试环境已验证
部署
deploy-backend.sh hl-fleet-service 已执行,滚动更新两实例,7 秒内起监听,无异常。
- 部署前:
hl-fleet-service dev-v3 53c2ff2d1 5/Y ... ok - 部署后:
hl-fleet-service dev-v3 10efbaddf 0/N 2026-09-20 16:04:59 ... ok - 含本次改动判定:
git merge-base --is-ancestor ee1937950 10efbaddf→ ANCESTOR_YES(合并提交在运行版本祖先链内)。 hl-gateway未动(4cbccc26b):本次四个端点路径/方法零变化,与网关路由无关,不需要重新部署网关。
互斥已实测(真实 Redis 命令序列,非被动推断)
用 redis-cli -n 0 MONITOR 实时抓包,同时并发发起 12 个「批量设置价格」请求(覆盖 12 个不同车型,全部落在原本全空的 2033 年):
EVALSHA ... "lock4j:fleet:pricing-calendar:write#fleet:pricing-calendar:write" ... "30000"
lua "set" 同键 <token> "NX" "PX" "30000"
lua "get"/"del" 同键 <token>
...(12 组完整 SET→GET→DEL 循环,全部命中同一个键)
- 全过程只出现一个锁键
lock4j:fleet:pricing-calendar:write#fleet:pricing-calendar:write; - 改前的坏形态(键里含方法名,如
...PricingCalendarServicebatchSetPrices#...)一次未现; - 12 次并发被序列化成 12 组干净的 acquire→release,任一次 SET 之前都能看到上一持有者的 DEL,未观察到两个持有者同时持有。
回归:71 次单独/批量 PUT/DELETE 全部 code=200,加锁没有把正常路径弄坏。
🔴 100503 未能复现
额外用 4 / 8 / 17 / 34 并发 × 365 天大范围写做过多轮尝试,全部返回 200,一次都没打出 100503。
推断原因:本次优化后单次临界区极短(单车型 366 天 DB 往返从 732 次降到个位数),几十个并发请求累计排队时间仍远小于 acquireTimeout 默认的 3000ms。
结论必须两句一起读:
- 该错误码路径真实存在(源码级:
acquireTimeout默认 3000ms +LockFailureExceptionHandler转RESOURCE_LOCKED=100503,@ResponseStatus(HttpStatus.OK)确认 HTTP 200 + body code); - 但测试服上用 34 并发 × 365 天都没能触发,实测频率极低;
- ⇒ 前端仍应处理(防御性,识别
code=100503提示可重试),但不必按高频路径设计交互。真正会触发的场景(更长的事务、更大的载荷、生产数据量)本次没有测到,不能因为没测到就说它不会发生。
写数据与还原(证明取证干净)
写在未使用的未来年份(2027 部分 / 2028 / 2031 / 2032 / 2033),共 71 组「车型 × 年份」,改前全为空。
还原:71 次 DELETE 全部 code=200;逐年 overview 复核 2028/2031/2032/2033 归零;2027 只剩改前就存在的两条(坦克 300、丰田赛那,本次未碰)。额外抽查真实业务数据 2026-09 丰田普拉多 24 条 remark="#6050 测试9月价全车型",与改前完全一致。MONITOR 进程已停,临时日志已清。
代码层面证据(非测试服,作为补充)
PR #8035 新增 PricingCalendarWriteLockConcurrencyIntegrationTest(真 Redis 集成测试,带变异证明),验证四处 @Lock4j 补齐同一 name 后确实互斥、抢锁失败返回预期异常——这一条是本地/CI 环境的证据,上面「互斥已实测」一节才是测试服的独立证据。
十、相关文档
- 关联 Issue: wx/HL#7980
- 关联 PR: wx/HL#8035
关联 / 联系人
链接
联系人
- 后端负责人: @wx