From bc07d07f20306a55f550d50aa47bc294d1ab5c4b Mon Sep 17 00:00:00 2001 From: API Changelog Bot Date: Mon, 21 Sep 2026 00:00:11 +0800 Subject: [PATCH] =?UTF-8?q?docs(changelog):=20#7980=20AC-6=20=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E9=99=A9=E5=B9=B4=E4=BF=9D=E5=8F=B0=E8=B4=A6=E4=B8=A4?= =?UTF-8?q?=E5=86=99=E5=8F=A3=E8=A1=A5=E5=90=8C=E5=90=8D=E9=94=81=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=96=B0=E5=A2=9E=20100503=EF=BC=88=E5=86=85=E9=83=A8?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8C=E6=97=A0=20C=20=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...£补同名锁新增100503冲突码-内部接口-修改接口-管理后台.md | 452 ++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 changelogs-v2/2026-09/21_7980_司机险年保台账upsert与作废两写口补同名锁新增100503冲突码-内部接口-修改接口-管理后台.md diff --git a/changelogs-v2/2026-09/21_7980_司机险年保台账upsert与作废两写口补同名锁新增100503冲突码-内部接口-修改接口-管理后台.md b/changelogs-v2/2026-09/21_7980_司机险年保台账upsert与作废两写口补同名锁新增100503冲突码-内部接口-修改接口-管理后台.md new file mode 100644 index 00000000..a020c32d --- /dev/null +++ b/changelogs-v2/2026-09/21_7980_司机险年保台账upsert与作废两写口补同名锁新增100503冲突码-内部接口-修改接口-管理后台.md @@ -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` + +| 字段 | 类型 | 说明 | +|------|------|------| +| 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` + +| 字段 | 类型 | 说明 | +|------|------|------| +| 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` + +| 字段 | 类型 | 说明 | +|------|------|------| +| 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\ | 被保人列表(司机险一人一单,通常单元素) | +| 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