docs(changelog): #7980 AC-6 司机险年保台账两写口补同名锁——新增 100503(内部接口,无 C 端)
changelog-filename-gate / validate (push) Failing after 1s
changelog-filename-gate / validate (push) Failing after 1s
PR #8072(合并提交 8bf9c520c)。三个端点都在 /v3/internal/insurance/**,只有 fleet 经 Feign 调,fleet 侧 Controller 全挂 /admin/fleet/drivers/**,无小程序 C 端路径。 🔴 本次只有两个端点新增 100503,不是三个: - driver-annual/upsert 与 driver-annual/{driverId}/invalidate 共用 name=driver-ins:ledger, 这两者之间新增互斥,因此新增 100503。 - driver-purchase 补的是自成一域的 name=driver-ins:purchase,锁语义与改前完全等价 (只是 Redis 键字符串从「类全名+方法名」变成显式 name),未新增任何错误码。 唯一可能传导到 hl-ui 的路径已逐行核实:DriverInsuranceService:352-353 投保后同步作废 旧 MANUAL 台账,失败在 :226 被 catch(AnnualBindingRollbackQueuedException) 捕获并统一 包装成既有错误码 600206,前端看到的仍是既有码,无需为 100503 新增处理。 backend_status=deployed:2026-09-20 23:43 order-v3 滚到 8bf9c520c(运行版本与合并提交 精确相等),两实例 12/13 秒起监听。「八、测试环境已验证」明写了没有做任何功能调用取证、 100503 在测试服上一次都没触发过——是「没测」不是「测过不会触发」,理由是端点不经网关且 手动触发会污染真实司机台账、触发真实保游扣费。 文件名日前缀用 21 是因为提交日已跨过零点;updated_at 同步为 2026-09-21。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
@@ -0,0 +1,452 @@
|
||||
---
|
||||
schema: "hl-changelog/v2"
|
||||
ticket: "7980"
|
||||
title: "司机险年保台账 upsert/作废两写口补同一把 @Lock4j name(driver-ins:ledger),新增可重试冲突码 100503;投保写口同键但刻意不互斥(driver-ins:purchase),未新增错误码"
|
||||
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 #8072 已 squash 合并 dev-v3(合并提交 8bf9c520c,#7980 AC-6,工单最后一条验收项)。✅ 已部署:2026-09-20 23:43 hl-order-service-v3 测试服滚动更新两实例,8186/8086 分别 12/13 秒起监听,deploy-backend.sh 报 rolling deploy complete;部署记录行 hl-order-service-v3 <- dev-v3 @ 8bf9c520c,即运行版本就是本次合并提交本身(非祖先链判据,是精确相等)。🔴 backend_status=deployed 但本文档没有做任何功能调用取证:没有实际调过 driver-annual/upsert、driver-annual/{driverId}/invalidate、driver-purchase 三个端点里的任何一个,100503 在测试服上一次都没有触发过——这是「没测」不是「测过不会触发」。理由:三个端点都是 /v3/internal/**,不经网关,唯一合法入口是 fleet 经 Feign 调用,手动触发需要真实写司机年保台账/投保行(台账 upsert 会更新或新插 insurance_order 行,投保会触发保游远程出单 HTTP 与真实扣费),本次评估认为不值得为了取一次 100503 读数去污染真实业务台账,故互斥本身只由代码层面的落库级并发 IT(Lock4jSharedNameConcurrencyIT)与按运行期锁键形状反查的契约用例(Lock4jSharedNameContractTest/Lock4jNameGroupGateTest)覆盖,这些是 PR 正文所载的本地/CI 证据,不是本 changelog 在测试服上做的验证。gateway_status=not_required:三个端点路径/方法零变化,且路径前缀 /v3/internal/,InternalInsuranceController 类注释自陈『不经过认证拦截器(/v3/internal/** 已排除)』,与网关路由无关。frontend_status=not_required,结论+依据:①本组无 C 端——全仓 grep driverInsuranceFeignClient 的 upsertAnnualPolicy/invalidateAnnualPolicy/driverPurchase 三个方法调用点,命中的调用方全部在 hl-fleet-service 且全部挂在 /admin/fleet/drivers 前缀下(DriverInsuranceController.java:33 RequestMapping、:56 purchase 端点、:80 insureAnnual 端点均在此前缀),没有任何 mp/小程序侧调用;②唯一可能把新失败码传导到 hl-ui 的路径是 DriverInsuranceService.java:352-353 那次同步 invalidateAnnualPolicy 调用(购买年险时若旧档案是 MANUAL 年保,出单成功后同步作废旧台账),但已逐行核实:该调用若失败(含本次新增的 100503),被 DriverInsuranceService.java:226 的 catch(AnnualBindingRollbackQueuedException) 捕获,统一重新包装成既有错误码 600206(DriverErrorCode.ANNUAL_BIND_FAILED,:241-242),即便触发的具体原因是 100503,hl-ui 侧看到的仍是它已经在处理的 600206,没有新码需要 hl-ui 适配。"
|
||||
updated_at: "2026-09-21"
|
||||
base: "dev-v3"
|
||||
---
|
||||
|
||||
# order-v3: 司机险年保台账 upsert/作废两写口补同一把 @Lock4j name,新增可重试冲突码 100503
|
||||
|
||||
> **存放目录**:
|
||||
> - 一期(v2,无 `order-v3` 标签的工单)→ `changelogs/{YYYY-MM}/`
|
||||
> - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/{YYYY-MM}/`
|
||||
>
|
||||
> **服务**: hl-order-service-v3(消费方为 hl-fleet-service,经 Feign 调入,均在 fleet `/admin/fleet/drivers/**` 之下,无 C 端)
|
||||
> **PR**: #8072
|
||||
> **Issue**: #7980(AC-6,最后一条验收项)
|
||||
> **日期**: 2026-09-20
|
||||
> **影响范围**: `/v3/internal/insurance/driver-annual/upsert`、`/v3/internal/insurance/driver-annual/{driverId}/invalidate` 两个 internal 端点新增可重试冲突码;`/v3/internal/insurance/driver-purchase` 端点锁的 Redis 键名变化但外部行为、错误码集合均未变化
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
|
||||
|
||||
- `InsuranceManageService.upsertDriverAnnualPolicy`、`InsuranceManageService.invalidateDriverAnnualPolicy`、`InsuranceCreateService.purchaseForDriver` 三个方法写着**逐字相同**的锁键 `'driver-ins:' + driverId`(三种不同的 SpEL 写法,字面上不容易看出是同一组),却都**没写 `name`**——lock4j 缺省 `name` 时用「类全限定名+方法名」拼进 Redis 键,于是三处此前各持一把互不相干的锁。
|
||||
- 🔴 **本次的结论与工单原表不同:不是把三处并成一把锁,而是拆成两个域**:
|
||||
- `upsertDriverAnnualPolicy` 与 `invalidateDriverAnnualPolicy` 补同一个显式 `name = "driver-ins:ledger"`——**这两者是真缺陷,必须互斥**:`upsert` 第 2 步 `updateById(existing)` 是全行盲写(`existing` 带着查询时的 `status=INSURED` 一起写回),`InsuranceOrder` 无 `@Version`、`insurance_order` 表无唯一索引;`upsert` 与 `invalidate` 交错执行会把刚作废的单静默复活成 `INSURED` 且不补任何状态流水——这个终态**任何合法串行序都产不出来**,打破的是"调用返回后该司机无活动 MANUAL 年险单"这条后置条件,而 fleet 正据此把司机档案的 `insurance_type` 切走。
|
||||
- `purchaseForDriver` 补的是**另一个**显式 `name = "driver-ins:purchase"`,**自成一域、刻意不与台账两口互斥**:它不写台账行,只在重叠校验里**读**台账行;而 `upsert` 本身不做保障期重叠校验(年保台账记的是司机在外面已买好的年险,系统没有立场拒绝登记),所以"年单与按天单保障期重叠"这个终态在合法串行序 `purchase → upsert` 下本来就可达,把 `purchase` 并进同一把锁**消不掉它**,只会把"`upsert` 并发成功"换成"`upsert` 抢锁超时拿 100503 后重试、终态一模一样",白白给一条 MQ 驱动的写口加一个失败码。
|
||||
- 🔴 **`purchaseForDriver` 没有新增任何可能返回的错误码**:它的锁语义与改前完全等价(同司机投保动作仍然自我串行化,因为默认 `name` 对单方法本来就够用),唯一变化是它在 Redis 里的键字符串从「类全名+方法名」变成显式的 `driver-ins:purchase`。**不要把「三个方法都补了 name」读成「三个端点都新增了 100503」**——只有 `upsert`/`invalidate` 这两口之间此前不互斥、本次补上后才有了新的失败路径。
|
||||
- **本组无 C 端**:三个端点都在 `/v3/internal/insurance/**`,只有 hl-fleet-service 经 Feign 调入,且 fleet 侧全部挂在 `/admin/fleet/drivers/**` 之下(详见 status_note 与「七、不影响范围」)。
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
`DriverInsuranceLockConstants`(本次新建)把设计意图写死成两条互相独立的判据:① `Lock4jSharedNameContractTest` 按运行期锁键形状反查全类,钉死三个方法成员与"台账两口同名、投保口异名"这条关系;② `Lock4jNameGroupGateTest` 的 `SPLIT_BY_DESIGN` 条目钉死这一串键上允许出现的 `name` 集合恰好是 `{driver-ins:ledger, driver-ins:purchase}`——第四个方法只要漏写 `name`,集合里就会冒出空串,门禁当场变红。这两条是 PR 正文所载的本地/CI 证据(`Lock4jSharedNameConcurrencyIT` 4 例、`Lock4jNameGroupGateTest` 7 例、`Lock4jSharedNameContractTest` 5 例),本 changelog 未在测试服上重跑,仅作背景引用。
|
||||
|
||||
PR 正文另确认了两条会动摇初始判断但不改变最终结论的事实:① fleet 侧另有一把按司机的锁 `fleet:insurance-task:lifecycle:{driverId}`(`DriverAnnualLedgerOutboxEffectService.java:42-49`、`DriverService.java:640`),台账 `upsert`/`invalidate` 在 fleet 侧本来就是串行触发的——但 order-v3 是数据的主人,`/v3/internal/insurance/driver-annual/**` 是对外契约端点,不能把不变量寄在调用方的自觉上;②测试服日志分母是 0(该组两个方法在采样窗口内一次都没被调用过),据此不能得出"没有告警=没有缺陷"的结论。
|
||||
|
||||
---
|
||||
|
||||
## 二、变更接口清单
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
|---|------|------|------|----------|------|
|
||||
| 1 | 司机年保台账 upsert(内部) | POST | `/v3/internal/insurance/driver-annual/upsert` | 新增可能返回的错误码 | 抢锁超时返 100503,与 §2 共用 `driver-ins:ledger` 锁 |
|
||||
| 2 | 司机年保台账作废(内部) | POST | `/v3/internal/insurance/driver-annual/{driverId}/invalidate` | 新增可能返回的错误码 | 与 §1 共用 `driver-ins:ledger` 锁 |
|
||||
| 3 | 司机险投保(内部) | POST | `/v3/internal/insurance/driver-purchase` | 无外部可观察变化 | 锁语义与改前完全等价,仅 Redis 键名从默认值改为显式 `driver-ins:purchase`,**未新增错误码** |
|
||||
|
||||
---
|
||||
|
||||
## 三、接口详情
|
||||
|
||||
### 1. 司机年保台账 upsert(内部) `POST /v3/internal/insurance/driver-annual/upsert`
|
||||
|
||||
**VO**: `DriverAnnualPolicyUpsertDTO → Long`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
fleet 车务在司机档案页录入/编辑「annual(年保)」保险类型时,afterCommit 联动调本端点把年保信息写进 order-v3 的台账:按 `(bizType=DRIVER, bizId=driverId, source=MANUAL, status=INSURED)` 查活动单,有则更新保单号/保费/起止/投保人,无则插入新单。本次改动不涉及入参/出参字段,只新增一条「被 §2 作废端点占用同一把锁」的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| driverId | Body | Long | ✅ | `@NotNull` | 司机 ID,落 `InsuranceOrder.bizId`(`bizType=DRIVER`) |
|
||||
| driverName | Body | String | - | - | 司机姓名,落 `policyHolderName` 投保人留痕 |
|
||||
| policyNo | Body | String | ✅ | `@NotBlank` | 司机自有年保单真实保单号,落 `extPolicyNo` |
|
||||
| annualPremium | Body | BigDecimal | ✅ | `@DecimalMin(0, inclusive=false)` | 年保险费(元),必须大于 0,落 `totalPremium` |
|
||||
| startDate | Body | LocalDate | ✅ | 不得晚于 endDate | 年保起始日,跨字段校验在提供方(非法返 540225) |
|
||||
| endDate | Body | LocalDate | ✅ | - | 年保结束日 |
|
||||
| adminId | Body | Long | - | fleet 透传,可空=系统 | 操作管理员 ID,审计留痕用 |
|
||||
|
||||
#### 出参 `Result<Long>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | Long(雪花 ID,超出 JS 安全整数范围时 JSON 序列化为字符串,本项目全局 Jackson 策略) | 台账单 ID(`insuranceOrderId`),fleet 回写 `fleet_driver.insurance_order_id` 用 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"driverId": "1934567890123456789",
|
||||
"driverName": "周师傅",
|
||||
"policyNo": "PICC-2026-77721",
|
||||
"annualPremium": "3800.00",
|
||||
"startDate": "2026-01-01",
|
||||
"endDate": "2026-12-31",
|
||||
"adminId": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": "1934567890200777", "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念(成功恒返回台账单 ID,有则更新既有单号,无则插入新单号)。无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 400 | 参数校验失败(`driverId`/`policyNo`/`annualPremium`/`startDate`/`endDate` 缺失或格式非法) |
|
||||
| 540225 | 年保起始日晚于结束日 |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(`acquireTimeout`,本组 `@Lock4j` 未显式设置,取 lock4j-core 注解默认值 3000ms;本次另一写口——§2 作废端点——正持有 `driver-ins:ledger` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 天然幂等,无 `@Idempotent`:多次以相同 `driverId` upsert 只更新同一张活动单,不重复开单。
|
||||
- 抢锁失败(100503)时该次请求未进入方法体,零写入——不会出现"读到旧快照后半途更新"的中间态。
|
||||
- 纯本地写库,事务内无同步 Feign/MQ(区别于 §3 投保端点持锁期间含远程出单 HTTP)。
|
||||
- 历史多张残留(脏数据)时只更新 `createTime` 最新一张,其余由 §2 全量兜底清理,本次未改此行为。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 2. 司机年保台账作废(内部) `POST /v3/internal/insurance/driver-annual/{driverId}/invalidate`
|
||||
|
||||
**VO**: `无 ReqVO(纯路径参数,无请求体) → Void`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
fleet 车务把司机档案的保险类型从「annual(年保)」切走时,afterCommit 联动调本端点把该司机全部 MANUAL 活动年险单置 `CANCELLED`(历史脏数据多张残留时全量兜底清理,正常路径只有一张)。无活动单时幂等返回成功。本次改动不涉及入参/出参,只新增一条「被 §1 upsert 端点占用同一把锁」的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| driverId | Path | Long | ✅ | - | 司机 ID |
|
||||
|
||||
#### 出参 `Result<Void>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | null | 无返回数据 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```http
|
||||
POST /v3/internal/insurance/driver-annual/1934567890123456789/invalidate
|
||||
```
|
||||
|
||||
(跨服务 Feign 调用,无请求体。)
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
该司机无活动 MANUAL 年险单时同样返回 200 成功(幂等短路,不报错,覆盖"保险类型反复切换"与"从未录入 annual"两种场景)。无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(`acquireTimeout` 默认 3000ms;§1 upsert 端点正持有 `driver-ins:ledger` 锁),**可重试** |
|
||||
|
||||
(本端点自身无其他业务校验分支——无活动单不算错误,是幂等成功。)
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 天然幂等,无 `@Idempotent`:重复触发/从未开单均不报错。
|
||||
- 抢锁失败(100503)时该次请求未进入方法体,零写入——这正是本次要补的缺口:改前 `upsert` 可能用陈旧快照把刚被本方法置 `CANCELLED` 的单全行盲写回 `INSURED` 且不留流水,改后二者不再能交错执行。
|
||||
- 不调保游 `CancelIns`(手工单无外部单号),纯本地翻态,与 `cancel`(按保单 PK 退保,走保游)是两条不同路径。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 3. 司机险投保(内部) `POST /v3/internal/insurance/driver-purchase`
|
||||
|
||||
**VO**: `DriverPurchaseInsuranceRequest → DriverInsurancePolicyDTO`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
fleet 车管在司机档案页点「投保」(或详情页「直接投保全年保险」),复用订单侧 `purchase` 内核出单,落 `bizType=DRIVER + bizId=driverId + orderId=NULL`。本次改动**只是把它自身的 `@Lock4j` 锁键从"类全名+方法名"改成显式字符串 `driver-ins:purchase`**——它此前对单方法自身就已经生效自互斥(lock4j 缺省 name 对单方法足够用),**外部可观察行为、错误码集合、并发语义全部零变化**,本节仅为完整性列出,不代表本接口新增了任何契约。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| driverId | Body | Long | ✅ | `@NotNull` | 司机 ID,落 `InsuranceOrder.bizId` |
|
||||
| planId | Body | Long | ✅ | `@NotNull`,`usageCategory` 须为 DRIVER/BOTH | 司机专用保险计划 ID |
|
||||
| coverageStartDate | Body | LocalDate | ✅ | `@NotNull` | 保障开始日期 |
|
||||
| coverageEndDate | Body | LocalDate | ✅ | `@NotNull` | 保障结束日期 |
|
||||
| insuredPerson | Body | InsuredPersonItemDTO | ✅ | `@NotNull @Valid` | 被保人(单对象,司机险一人一单) |
|
||||
| insuredPerson.name | Body | String | ✅ | `@NotNull` | 姓名 |
|
||||
| insuredPerson.idCardType | Body | String | ✅ | `@NotNull`,ID_CARD/PASSPORT/OTHER | 证件类型 |
|
||||
| insuredPerson.idCardNo | Body | String | ✅ | `@NotNull` | 证件号码(明文,内网 Feign 传输) |
|
||||
| insuredPerson.phone | Body | String | - | - | 手机号 |
|
||||
| insuredPerson.gender | Body | String | - | 1=男/2=女/0=未知 | 性别编码 |
|
||||
| insuredPerson.birthday | Body | LocalDate | - | 可空,引擎从身份证派生 | 出生日期 |
|
||||
| orderId | Body | Long | - | 仅形状对齐,必传 null | 司机投保不关联订单 |
|
||||
| remark | Body | String | - | - | 备注 |
|
||||
|
||||
#### 出参 `Result<DriverInsurancePolicyDTO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| insuranceOrderId | Long(雪花,超出 JS 安全整数范围时序列化为字符串) | 保单 ID |
|
||||
| policyNo | String | 本系统保单号(同 `extPolicyNo`) |
|
||||
| extPolicyNo | String | 保游网外部保单号(异步出单受理时为空,回调后回填) |
|
||||
| extOrderNo | String | 保游网外部订单号 |
|
||||
| totalPremium | BigDecimal(序列化为字符串) | 保单总保费 |
|
||||
| status | String | 保单状态:PENDING/INSURING/INSURED/CANCELLED/FAILED |
|
||||
| statusLabel | String | 保单状态中文标签 |
|
||||
| source | String | 保单来源:BAOYOU/MANUAL |
|
||||
| policyPdfUrl | String | 保单 PDF 地址(未生成/MANUAL 单为空) |
|
||||
| coverageStartDate | LocalDate | 保障起期 |
|
||||
| coverageEndDate | LocalDate | 保障止期 |
|
||||
| insuredPersons | List\<InsuredPersonBrief\> | 被保人列表(司机险一人一单,通常单元素) |
|
||||
| insuredPersons[].name | String | 被保人姓名 |
|
||||
| insuredPersons[].idCardNo | String | 证件号(脱敏,保前 3 尾 4) |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"driverId": "1934567890123456789",
|
||||
"planId": 300,
|
||||
"coverageStartDate": "2026-06-01",
|
||||
"coverageEndDate": "2027-05-31",
|
||||
"insuredPerson": {
|
||||
"name": "周师傅",
|
||||
"idCardType": "ID_CARD",
|
||||
"idCardNo": "150102198001011234",
|
||||
"phone": "13800138000"
|
||||
},
|
||||
"orderId": null,
|
||||
"remark": "司机险投保: 周师傅"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"insuranceOrderId": "1934567890200888",
|
||||
"policyNo": "BY-2026-0001",
|
||||
"extPolicyNo": "",
|
||||
"extOrderNo": "EXT20260601001",
|
||||
"totalPremium": "88.00",
|
||||
"status": "INSURING",
|
||||
"statusLabel": "出单中",
|
||||
"source": "BAOYOU",
|
||||
"policyPdfUrl": "",
|
||||
"coverageStartDate": "2026-06-01",
|
||||
"coverageEndDate": "2027-05-31",
|
||||
"insuredPersons": [ { "name": "周师傅", "idCardNo": "150**********1234" } ]
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念,无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 540032, "message": "该司机该保障期已有生效保单", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 540005 | 保险计划不存在 |
|
||||
| 540031 | 该保险计划未标注为司机可用(`usageCategory` 非 DRIVER/BOTH) |
|
||||
| 540032 | 该司机该保障期已有生效保单(锁内区间重叠互斥,含 MANUAL 年保台账单参与判定) |
|
||||
| 100502 | 5 秒幂等窗口内重复提交(键=driverId+保障起止) |
|
||||
|
||||
**🔴 本端点没有新增 100503**:以上错误码集合与改动前完全一致,本次改动不改变这张表的任何一行。
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent`(`keyPrefix=insurance:driver-purchase`,键=driverId+保障起止)5 秒窗口,仅作完全相同请求误重提防抖,不承担区间重叠去重职责(区间重叠由锁内 540032 校验负责)。
|
||||
- 🔴 **与 §1/§2 台账两口键虽同为 `'driver-ins:' + driverId`,但刻意不互斥**:本方法在重叠校验里只**读** MANUAL 台账单(含未提交完成的除外),不写台账行;`upsert` 不做重叠校验,所以"年单与按天单重叠"这个终态在合法串行序 `purchase → upsert` 下本来就可达,并锁消不掉它。
|
||||
- 持锁期间包含保游远程出单 HTTP(区别于 §1/§2 纯本地写库),这也是不把台账写口并进本锁的现实理由之一——台账是毫秒级操作,排在一笔外部 HTTP 后面等锁纯属浪费。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
## 四、契约约束与正确调用方式(接口类必写)
|
||||
|
||||
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
|
||||
|
||||
### 并发冲突(100503)只在 §1/§2 之间
|
||||
|
||||
| 场景 | 响应 |
|
||||
|------|------|
|
||||
| ✅ 单个 upsert 或 invalidate 请求,无并发 | 200 + 正常业务结果 |
|
||||
| ✅ 同司机 upsert 与 invalidate 先后到达,后者在 3 秒内轮到锁 | 两个都 200(排队等待,非立即失败) |
|
||||
| ❌ 同司机 upsert ∥ invalidate 并发,后到方等待超过 3 秒未抢到锁 | `{ "code": 100503, "message": "资源被占用,请稍后重试", "success": false }`,**HTTP 状态码仍是 200** |
|
||||
| ✅ 同司机 §3 投保 与 §1/§2 台账写口并发 | **不受影响、不会产生 100503**——两者不在同一把锁上,§3 的重叠拒绝(540032)与本次改动无关 |
|
||||
| ✅ 不同 driverId 并发 | 互不影响,各自独立加锁 |
|
||||
|
||||
**调用方(fleet)必须做的事**:判断响应体 `code === 100503`(不是判 HTTP 状态码),命中时按各自的失败处理策略重试(Outbox/MQ 驱动的 §1/§2 调用方本就有重投机制,见「六.6」)。
|
||||
|
||||
### 触发概率评估(结构性推理,非测试服实测)
|
||||
|
||||
fleet 侧对台账 upsert/invalidate 的两个真实调用点(`DriverAnnualLedgerOutboxEffectService.java:97/113/126`)本身已被 fleet 侧的 `fleet:insurance-task:lifecycle:{driverId}` 锁串行化(同一司机的多个事件不会并发发起 Feign 调用),真实竞争概率接近 0;且持锁段是纯本地写库、无 HTTP,正常耗时上界远小于 `acquireTimeout` 默认的 3000ms 超时阈值。**不构成类似 #8050 的重试风暴**。这是结构性推理,本文档未在测试服上实测验证这个判断。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库行为(涉及写操作时必写)
|
||||
|
||||
本次不改变任何一次**成功**写操作实际写入的行数或内容——三个端点各自原有的写入逻辑(台账 upsert/CANCELLED 翻态/投保出单)逐字节不变。变化的只是"谁能在同一时刻对同一 driverId 执行台账写入":
|
||||
|
||||
| 维度 | 改动前 | 改动后 |
|
||||
|------|--------|--------|
|
||||
| upsert ∥ invalidate 是否互斥 | 否(各自持独立锁,可交错执行) | 是(同一把锁,串行执行) |
|
||||
| 交错执行的后果(改前可复现,PR 变异证明) | 刚作废的单被 upsert 陈旧快照全行盲写复活成 INSURED,且不留状态流水(行=INSURED,末条流水 new_status=CANCELLED,两者矛盾) | 该终态不再可达 |
|
||||
| purchase 与台账两口的关系 | 各自独立锁,互不阻塞 | 仍然互不阻塞(刻意维持,非缺陷) |
|
||||
| 抢锁失败时是否有部分写入(upsert/invalidate) | N/A(此前不会因锁而失败) | 否,零写入 |
|
||||
|
||||
---
|
||||
|
||||
## 六、边界行为
|
||||
|
||||
- 司机不存在等前置校验:本三端点自身不做司机存在性校验(由 fleet 侧在调用前保证),保持改前行为
|
||||
- upsert 起止日期反转 → 540225
|
||||
- purchase 计划不存在/非司机可用/保障期重叠 → 540005/540031/540032,与本次改动无关,行为不变
|
||||
- invalidate 无活动单 → 幂等返回成功,不是错误
|
||||
- **抢锁超时(本次新增,仅 upsert/invalidate 之间)→ 100503,HTTP 200,可重试,零写入**
|
||||
- purchase 端点**不会**因本次改动新增任何失败分支
|
||||
- 老数据兼容:本次不涉及字段增删,存量台账数据无需迁移
|
||||
|
||||
---
|
||||
|
||||
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
|
||||
|
||||
### 保单状态(status,出现于 §3 出参)
|
||||
|
||||
**所属字段**: `DriverInsurancePolicyDTO.status` | **类型**: `String`(本次未变化,随改动一并列出便于自包含联调)
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `PENDING` | 待出单 | - |
|
||||
| `INSURING` | 出单中 | 异步受理期,`extPolicyNo` 可能为空 |
|
||||
| `INSURED` | 已承保 | - |
|
||||
| `CANCELLED` | 已退保 | - |
|
||||
| `FAILED` | 投保失败 | - |
|
||||
|
||||
### 保单来源(source,出现于 §3 出参)
|
||||
|
||||
**所属字段**: `DriverInsurancePolicyDTO.source` | **类型**: `String`
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `BAOYOU` | 保游网出单 | 有电子保单,可下载 |
|
||||
| `MANUAL` | 手工录入(司机年保档案联动) | §1/§2 两个端点操作的正是这类单;无保游网电子保单,前端应隐藏下载/重传按钮 |
|
||||
|
||||
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
|
||||
|
||||
### 字段级对比
|
||||
|
||||
| 字段 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| (无)| 三个接口的请求字段、响应字段逐字节不变 | 同左 |
|
||||
|
||||
### 行为级对比
|
||||
|
||||
| 行为 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| upsert ∥ invalidate 并发 | 各持独立锁,可交错执行,可复活已作废的单且不留流水 | 同一把锁 `driver-ins:ledger`,串行执行 |
|
||||
| purchase 与台账两口 | 各自独立锁,互不阻塞 | 刻意维持互不阻塞(`driver-ins:purchase` 自成一域),非本次要修的问题 |
|
||||
| 抢锁失败的响应(仅 upsert/invalidate) | 不存在这条路径 | 路径真实存在(返回 100503,HTTP 200,业务失败,可重试);已部署测试服但未做任何功能调用取证,无实测触发数据 |
|
||||
| `InsuranceManageService` 两处 javadoc(改前断言"与 purchaseForDriver 同键互斥") | 断言为假(三处均无 name 时逐字相同的键也不构成互斥) | 订正为准确描述:与台账另一口同名互斥,与 purchase 刻意不互斥 |
|
||||
| 请求/响应字段、既有业务错误码(540005/540031/540032/540225) | 不变 | 不变 |
|
||||
|
||||
## 六.7、影响评估(修改/删除类必写)
|
||||
|
||||
- **是否破坏向后兼容**: 否——三个接口的请求体/响应体字段、既有错误码全部零增删;仅 upsert/invalidate 之间新增一条此前不存在的失败路径(100503),purchase 端点零变化。
|
||||
- **前端是否必须同步上线**: 不适用——本组无 C 端、无 hl-ui 直接触达路径;唯一间接路径(§3 投保后同步作废旧 MANUAL 台账失败)已被 fleet 侧既有错误码 600206 吸收,hl-ui 无需为本次改动新增任何处理逻辑。
|
||||
- **前端 workaround 清理点**: 无。
|
||||
|
||||
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
|
||||
|
||||
- **仅影响**: `driver-annual/upsert`、`driver-annual/{driverId}/invalidate` 两个端点之间的并发失败分支
|
||||
- **零影响**:
|
||||
- `driver-purchase` 端点的请求/响应字段、错误码集合、并发语义(外部可观察行为零变化)
|
||||
- `InternalInsuranceController` 其余端点(自动投保、保险列表/覆盖查询、退保、分享保单、下载保单、司机保单分页/详情/按服务日查询等)
|
||||
- fleet 侧 `fleet:insurance-task:lifecycle:{driverId}` 锁及其覆盖的档案编辑/出单绑定/状态回调路径——那是另一个服务里的另一把锁,本次未改动
|
||||
- fleet 侧 `DriverInsuranceController`(`/admin/fleet/drivers/**`)的入参/出参/错误码——车管投保页面看到的契约零变化
|
||||
- hl-gateway 路由配置——`/v3/internal/**` 本就不对公网暴露,本次未新增任何路由
|
||||
|
||||
---
|
||||
|
||||
## 八、测试环境已验证
|
||||
|
||||
### 部署(已完成)
|
||||
|
||||
- `deploy-backend.sh` 已执行,`hl-order-service-v3` 滚动更新两实例,8186 端口 12 秒起监听、8086 端口 13 秒起监听,脚本报告 `rolling deploy complete`。
|
||||
- 部署记录行:`hl-order-service-v3 <- dev-v3 @ 8bf9c520c`——运行版本**就是**本次合并提交本身(精确相等,非祖先链判据)。
|
||||
|
||||
### 🔴 没有做任何功能调用取证
|
||||
|
||||
本文档**没有**调用过 `driver-annual/upsert`、`driver-annual/{driverId}/invalidate`、`driver-purchase` 三个端点中的任何一个;`100503` 在测试服上**一次都没有触发过**。
|
||||
|
||||
**必须两句一起读**:
|
||||
|
||||
1. 这不是"测过没触发",而是**没测**——三个端点都是 `/v3/internal/`,不经网关,无法用浏览器/Postman 直接打;唯一合法调用方是 fleet 经 Feign 调,要手动触发就必须让 fleet 真实写一条司机年保台账(`upsert`/`invalidate` 会改动/插入 `insurance_order` 行)或真实调一次保游远程出单接口并产生扣费(`purchase`),本次评估认为不值得为取一次 100503 读数去污染真实业务数据,故未执行。
|
||||
2. 互斥本身的证据来自 PR 正文所载的**代码层面证据(非测试服)**:`Lock4jSharedNameConcurrencyIT`(4 例落库级并发 IT,含变异证明——摘掉共用 `name` 后复现"刚作废的单被复活成 INSURED 且流水终点仍是 CANCELLED"这一矛盾态)与按运行期锁键形状反查的契约用例 `Lock4jSharedNameContractTest`/`Lock4jNameGroupGateTest`(钉死"台账两口同名、投保口异名"这条关系,防止日后有人漏写或错写 `name`)。这些是本地/CI 证据,不是本 changelog 在测试服上独立验证的结果。
|
||||
|
||||
---
|
||||
|
||||
## 十、相关文档
|
||||
|
||||
- 关联 Issue: [wx/HL#7980](https://git.1814.love:8443/wx/HL/issues/7980)(AC-6,最后一条验收项)
|
||||
- 关联 PR: [wx/HL#8072](https://git.1814.love:8443/wx/HL/pulls/8072)
|
||||
- 前序三份(同工单不同 AC,均为 backend_status=pending 待部署):`changelogs-v2/2026-09/20_7980_配房工作台七个写口补齐需求级互斥新增100503可重试冲突码-修改接口-管理后台.md`(AC-5,#8069)、`changelogs-v2/2026-09/20_7980_出行人删改与发票申请三组写口补同名锁新增100503冲突码触达C端-修改接口-管理后台.md`(AC-7,#8049)、`changelogs-v2/2026-09/20_7980_产品库存扣减恢复两写口补同名锁新增100503冲突码-修改接口-管理后台.md`(AC-8,#8047)
|
||||
|
||||
## 关联 / 联系人
|
||||
|
||||
### 链接
|
||||
|
||||
- **Issue**: [#7980](https://git.1814.love:8443/wx/HL/issues/7980)
|
||||
- **PR**: [#8072](https://git.1814.love:8443/wx/HL/pulls/8072)
|
||||
- **Merge commit**: [8bf9c520c](https://git.1814.love:8443/wx/HL/commit/8bf9c520c)
|
||||
|
||||
### 联系人
|
||||
|
||||
- **后端负责人**: @wx
|
||||
在新工单中引用
屏蔽一个用户