docs(changelog): #7980 价格日历四个写接口补齐真实并发互斥 + 新增可重试冲突码 100503
changelog-filename-gate / validate (push) Failing after 2s
changelog-filename-gate / validate (push) Failing after 2s
hl-fleet-service 车型价格日历四个写口(批量设价 / 多车型批量设价 / 批量改状态 / 清除价格日历) 此前虽各自写了相同的 @Lock4j keys,但都没写 name;lock4j 落 Redis 的锁键含方法名, name 缺省时退化成「类全限定名 + 方法名」,四处因此各自持有一把互不相干的锁—— 接口文档里一直写着的「四个价格写端点全互斥」此前并不成立。本次补上同一个显式 name,互斥才真正生效。 接口路径、方法、请求/响应字段、既有 4 个业务错误码(600500-600503)均零增删。 唯一的契约变化是新增一条可能返回的错误码 100503(RESOURCE_LOCKED,「资源被占用,请稍后重试」), 且它以 HTTP 200 + body code 的形式返回——前端只看 HTTP 状态码会把它当成功。 关于 100503 的触发频率,正文两面都写,缺一面都会误导前端: - 该路径源码级真实存在(acquireTimeout 默认 3000ms + LockFailureExceptionHandler 转 100503 + @ResponseStatus(HttpStatus.OK),三处均已读码确认); - 但测试服用 4/8/17/34 并发 × 365 天多轮尝试均返回 200,一次未触发(推断原因:本次优化后 单车型 366 天的 DB 往返从 732 次降到个位数,临界区极短,累计排队远小于 3 秒)。 ⇒ 前端仍应防御性处理并允许重试,但不必按高频路径设计交互; 同时不能因为测试服没测到就当它不会发生——更长事务 / 更大载荷 / 生产数据量下的行为未知。 测试服证据(backend_status 因此才从 pending 转为 deployed,此前门禁 E_BACKEND_PENDING 正确拦下过一次): 部署前 53c2ff2d1 → 部署后 10efbaddf,merge-base --is-ancestor ee1937950 10efbaddf 返回 ANCESTOR_YES; redis-cli MONITOR 实时抓包证实 12 并发只命中同一把锁键 lock4j:fleet:pricing-calendar:write#fleet:pricing-calendar:write,改前那种含方法名的坏形态一次未现, 12 组干净的 acquire→release、未观察到两个持有者同时持锁;71 次回归调用全部 code=200。 测试数据写在原本全空的未来年份(2027 部分/2028/2031/2032/2033,71 组车型×年份), 已用 71 次 DELETE 逐一还原并按年复核归零,真实业务数据(2026-09 丰田普拉多 24 条)抽查与改前一致。 gateway_status 记 not_required:四端点路径/方法零变化、hl-gateway 版本未动(4cbccc26b), 是判定不是漏验证,判据已写进 status_note。 Refs #7980 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
@@ -0,0 +1,553 @@
|
||||
---
|
||||
schema: "hl-changelog/v2"
|
||||
ticket: "7980"
|
||||
title: "车型价格日历四个写接口补齐真实并发互斥,新增可重试冲突码 100503(接口路径/字段零增删)"
|
||||
consumer: "admin"
|
||||
author: "wx(GIT)"
|
||||
change_type: "修改接口"
|
||||
backend_status: "deployed"
|
||||
gateway_status: "not_required"
|
||||
frontend_status: "pending"
|
||||
frontend_owner: "mmg"
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
status_note: "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 条)抽查与改前一致。"
|
||||
updated_at: "2026-09-20"
|
||||
base: "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`。
|
||||
- 🔴 **两句话都要看,缺一句都会误判**:
|
||||
1. **这条失败路径源码级真实存在**——`acquireTimeout` 默认 3000ms、超时经 `LockFailureExceptionHandler` 转 `100503`、`@ResponseStatus(HttpStatus.OK)` 确认 HTTP 200 + body 携带业务码,这套链路已逐行读码确认,不是猜测。
|
||||
2. **但测试服 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` |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念(成功恒返回 `data: null`),也没有降级路径;筛选区间内 0 命中(比如全落在排除日)时同样返回 200 成功、只是没有任何行被写入。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "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 秒超时);不代表更长事务/更大载荷下不会触发,前端仍应识别该码并提示可重试,无需按高频路径设计交互。
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 30 秒防重(键=车型+全入参摘要),同一请求 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` |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"startDate": "2026-03-01",
|
||||
"endDate": "2026-03-31",
|
||||
"status": "CLOSED",
|
||||
"remark": "临时停租"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
无「空数据」概念;区间内没有任何已设价日时同样返回 200 成功(本接口只改状态,不要求区间内先有价格记录)。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "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 秒超时);不代表更长事务/更大载荷下不会触发,前端仍应识别该码并提示可重试,无需按高频路径设计交互。
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 3 秒防重(键=车型+全入参摘要)。
|
||||
- 与「批量设置价格」「清除价格日历」「多车型批量设价」共用**同一把**全局写锁,本次改动之后四者才真正互斥;抢锁失败零写入,逻辑同上一节。
|
||||
- 不影响价格字段,只改状态列。
|
||||
|
||||
---
|
||||
|
||||
### 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` |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```http
|
||||
DELETE /admin/fleet/pricing-calendar/1234567890123456789?startDate=2026-03-01&endDate=2026-03-10
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
区间内没有任何设价记录时同样返回 200 成功,删除行数为 0,不报错。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "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 秒超时);不代表更长事务/更大载荷下不会触发,前端仍应识别该码并提示可重试,无需按高频路径设计交互。
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 3 秒防重(键=车型+起止日期)。
|
||||
- 软删除:经 `@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` |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"vehicleModelIds": ["1234567890123456789", "1234567890123456790"],
|
||||
"startDate": "2026-03-01",
|
||||
"endDate": "2026-03-31",
|
||||
"adjustMode": "PERCENT",
|
||||
"adjustValue": 10,
|
||||
"status": "AVAILABLE"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
无「空数据」概念;筛选区间内 0 命中同样返回 200 成功、没有任何行被写入。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "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 并发多轮测试中未触发;不代表更大批量/生产数据量下不会触发,前端仍应识别该码并提示可重试。
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 300 秒防重(键=排序去重 `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。
|
||||
|
||||
**结论必须两句一起读**:
|
||||
|
||||
1. 该错误码路径**真实存在**(源码级:`acquireTimeout` 默认 3000ms + `LockFailureExceptionHandler` 转 `RESOURCE_LOCKED=100503`,`@ResponseStatus(HttpStatus.OK)` 确认 HTTP 200 + body code);
|
||||
2. **但测试服上用 34 并发 × 365 天都没能触发**,实测频率极低;
|
||||
3. ⇒ 前端**仍应处理**(防御性,识别 `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](https://git.1814.love:8443/wx/HL/issues/7980)
|
||||
- 关联 PR: [wx/HL#8035](https://git.1814.love:8443/wx/HL/pulls/8035)
|
||||
|
||||
## 关联 / 联系人
|
||||
|
||||
### 链接
|
||||
|
||||
- **Issue**: [#7980](https://git.1814.love:8443/wx/HL/issues/7980)
|
||||
- **PR**: [#8035](https://git.1814.love:8443/wx/HL/pulls/8035)
|
||||
- **Merge commit**: [ee1937950](https://git.1814.love:8443/wx/HL/commit/ee1937950ef95efd78e93994051bddff529ca7c9)
|
||||
|
||||
### 联系人
|
||||
|
||||
- **后端负责人**: @wx
|
||||
在新工单中引用
屏蔽一个用户