docs(changelog): #7980 AC-4 发票签发组三写口补同一把锁,新增可重试冲突码 100503(会触达小程序 C 端)
changelog-filename-gate / validate (push) Failing after 2s
changelog-filename-gate / validate (push) Failing after 2s
order-v3 的 InvoiceAdminService.issueInvoice / reuploadInvoice 与 InvoiceMpService.reissueInvoice 三处 @Lock4j 写了逐字相同的 keys 却都没写 name;lock4j 的 Redis 键在 name 缺省时退化成 「类全限定名+方法名」,于是三处各持一把锁。后果是 reupload ∥ reissue 会留下一单两张有效票。 本次三处补同一个 name = "order-invoice:issue"。PR #8043 → 09b8f2323 已合 dev-v3。 接口路径、方法、请求/响应字段、既有错误码全部零增删。唯一契约变化是新增一个可能返回的错误码 100503 (RESOURCE_LOCKED「资源被占用,请稍后重试」),且它以 HTTP 200 + body code 返回—— 前端只看 HTTP 状态码会把它当成功。 该码会触达小程序 C 端:reissue 经 hl-mp-service 两个入口透传 (MpV3InvoiceController:54 直接透传;MpInvoiceController:108 经 getCheckedData(), Result:143-148 用上游 code 原样重抛)。用户点「申请重开发票」时若财务端正在同一张票上操作, 等最多 3 秒后收到该提示;反向同理。此前这几条互不阻塞。 测试服证据(backend_status 因此才从 pending 转 deployed;此前门禁 E_BACKEND_PENDING 正确拦下过一次, 没有为换绿灯改状态,而是去把部署真的做了):运行版本 5d14bc524, merge-base --is-ancestor 09b8f2323 5d14bc524 → YES。 ⚠️ 如实记:部署前 COMMIT 已是 5d14bc524(另一会话此前推上去的),本次重跑不等于「使它从不生效变生效」。 🔴 证据强度按实际收窄,没有拔高:redis-cli MONITOR 抓到键 lock4j:order-invoice:issue#order-invoice:issue:<invoiceId>、无方法名混入, 但 MONITOR 只跑了三个写口里的一个(issueInvoice);另两处由代码同源 + 反射守卫用例 InvoiceIssueLockNameGuardTest(断言三处 name 非空且相等)覆盖,未在测试服上单独抓包。 正文里 §2/§3 原本写「同第 1 节,链路一致」会被读成「也实测过」,已改成明确写「未单独抓包」。 100503 未复现:并发两个 issueInvoice,一个 code=200、另一个 code=581502(INVOICE_CANNOT_ISSUE 业务状态守卫),不是锁超时码。原因是临界区极短(与 fleet 那轮 34 并发未触发同因)。 第二个请求被业务守卫拦下说明没产生并发脏写,但这是旁证不是互斥证据。 两面都写:路径源码级真实存在(已读码确认 + IT 里实测过等 3024ms),但测试服未能触发, 不代表更长事务/生产数据量下不会触发。 取证干净:三张票全是测试夹具(客户名「核团甲/乙/丙」,apply_reason 为 #7932 造数), 改前均 REQUESTED/NULL/NULL,已按改前快照逐列 UPDATE 还原并复读一致, Redis --scan lock4j:*order-invoice* 复扫为空。过程中发生过一次 code=401(token 被别的会话顶掉), 该次调用前无任何读数,已重登并把那段整个重做。 顺带修掉一个会挂门禁的文件名 bug:原名后缀写的是「小程序管理后台」,而 changelogs-v2/ 根目录 要求端类型字面量必须是「管理后台」,改名前文件名校验确实报 E_CLIENT。 Refs #7980 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
@@ -0,0 +1,488 @@
|
||||
---
|
||||
schema: "hl-changelog/v2"
|
||||
ticket: "7980"
|
||||
title: "发票签发组三个写口(admin 完成开票 / admin 重新上传 / mp 申请重开)补同一把 @Lock4j name,杜绝 reupload ∥ reissue 一单两票漏洞,新增可重试冲突码 100503(会传导至小程序 C 端)"
|
||||
consumer: "multiple"
|
||||
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 #8043 已 squash 合并 dev-v3(合并提交 09b8f2323)。2026-09-20 对 hl-order-service-v3 重跑了一次部署流程(滚动重启两实例),部署后 COMMIT=5d14bc524;git merge-base --is-ancestor 09b8f2323 5d14bc524 返回 YES,确认本次改动在运行版本内——但如实说明:部署前该服务已经是 5d14bc524(另一会话此前已把这个 commit 推上测试服),本次重跑部署没有让改动「从生效变生效」,只是重新确认了一遍。gateway_status=not_required 判据不变:三个端点路径/方法零变化,hl-gateway 一直是 4cbccc26b 未动,发票端点走网关实测全部正常返回。🔴 测试服直接证据(redis-cli MONITOR)只覆盖三处写口中的一处——issueInvoice;另两处(reuploadInvoice/reissueInvoice)由代码改动本身(与 issueInvoice 逐字相同的 name/keys)与反射守卫单测 InvoiceIssueLockNameGuardTest(断言三处 name 非空且相等)覆盖,未在测试服上单独抓包或调用,不要读成三处都实测过互斥。100503 本身测试服未能复现(并发两个 issueInvoice 得到 200+581502,不是 100503;581502 是业务状态守卫,证明没有产生并发脏写,但只是旁证不是互斥证据);原因判断同 fleet 那轮,临界区极短。frontend_status=pending 维持不变,详见「八、测试环境已验证」完整记录。"
|
||||
updated_at: "2026-09-20"
|
||||
base: "dev-v3"
|
||||
---
|
||||
|
||||
# order-v3: 发票签发组三个写口补同一把 @Lock4j name,新增可重试冲突码 100503(会传导至小程序 C 端)
|
||||
|
||||
> **存放目录**:
|
||||
> - 一期(v2,无 `order-v3` 标签的工单)→ `changelogs/{YYYY-MM}/`
|
||||
> - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/{YYYY-MM}/`
|
||||
>
|
||||
> **服务**: hl-order-service-v3(另涉 hl-mp-service 两个透传入口)
|
||||
> **PR**: #8043
|
||||
> **Issue**: #7980(AC-4)
|
||||
> **日期**: 2026-09-20
|
||||
> **影响范围**: 管理后台「发票财务管理」的「完成开票」「重新上传」两个写操作 + 小程序 C 端「申请重开发票」
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
|
||||
|
||||
- `InvoiceAdminService.issueInvoice`、`InvoiceAdminService.reuploadInvoice`、`InvoiceMpService.reissueInvoice` 三处 `@Lock4j` 此前写了**逐字相同**的 `keys`(`'order-invoice:issue:' + #invoiceId`),却都**没写 `name`**——lock4j 落 Redis 的锁键含方法名,`name` 缺省时退化为「全限定类名 + 方法名」,于是三个方法**各持一把互不相干的锁**,可以并发互相踩踏。
|
||||
- 后果是真实数据缺陷:**`reupload ∥ reissue` 会在同一张发票上留下两行非 VOIDED 记录**——`reissue` 先把旧票置 `VOIDED` 并插入新票(`REQUESTED`),随后 `reupload` 把(已经作废的)旧票又写回 `ISSUED`,`order_invoice` 表上无 `UNIQUE`、`OrderInvoice` 实体无 `@Version`,没有第二道兜底能挡住这个结果。
|
||||
- 本次给三处补上**同一个**显式 `name = "order-invoice:issue"`,三者才真正互斥(同一张发票的开票/重传/重开现在会排队执行)。**接口路径、请求/响应字段、既有错误码全部零增删**,唯一的契约变化是新增一个可能返回的失败码。
|
||||
- 🔴 **新增的失败路径会到达小程序 C 端**:用户点「申请重开发票」时,若财务端此刻正在同一张票上做「完成开票」或「重新上传」,会等待最多约 3 秒后收到 **`code=100503`**("资源被占用,请稍后重试")。**传输层仍是 HTTP 200**,失败信息在响应体的 `code` 字段里——前端如果只看 HTTP 状态码会把这次失败误判为成功。反向同理:财务端两个接口也可能因为 C 端正在 `reissue` 而收到同一个码。
|
||||
- 🔴 **两句话都要看,缺一句都会误判**:
|
||||
1. **这条失败路径源码级真实存在**——`acquireTimeout` 默认 3000ms、超时经 `LockFailureExceptionHandler` 统一转 `100503`、`@ResponseStatus(HttpStatus.OK)` 确认 HTTP 200 + body 携带业务码,这套链路已逐行读码确认;`InvoiceIssueLockConcurrencyIT` 的持锁超时用例实测到等待约 3024ms 后抛出该码。
|
||||
2. **但触发概率极低**——同一用例测得三者中任一方法单次临界区(一次 `UPDATE`/一次 `UPDATE`+一次 `INSERT`)实测上界约 89ms,远小于 3000ms 的 `acquireTimeout`,只有同一 `invoiceId` 在同一瞬间被两个写口同时命中才会撞上。**这不代表它不会发生**,前端仍应做防御性处理(识别 `code=100503` 提示可重试),但不必按高频交互设计。
|
||||
- ✅ **测试服部署已确认,但直接证据只覆盖三处写口中的一处**:2026-09-20 `hl-order-service-v3` 测试服 COMMIT `5d14bc524` 已含本次改动(`git merge-base --is-ancestor 09b8f2323 5d14bc524` → YES)。`redis-cli MONITOR` 实时抓包证实调用 `issueInvoice` 时锁键与预期完全一致、无方法名混入——但这**只验证了 `issueInvoice` 一处**;`reuploadInvoice`/`reissueInvoice` 另两处未在测试服上单独抓包或调用,是由代码改动本身(与 `issueInvoice` 逐字相同的 `name`/`keys`)与反射守卫单测 `InvoiceIssueLockNameGuardTest` 覆盖的,**不要读成"三处共用同一把锁"已在测试服实测确认**。
|
||||
- 🔴 **100503 测试服未能复现,且第二次读数不是它本来该是的样子**:对同一发票并发发两个 `issueInvoice`,一个 `code=200`,另一个是 **`code=581502`**(`INVOICE_CANNOT_ISSUE` 业务状态守卫),**不是 100503**。这说明没有产生并发脏写(第二个请求命中的是"状态已不是 REQUESTED"的正常业务拒绝),但**这是旁证,不是互斥证据**——不能拿它证明锁生效,只能证明没有出现数据损坏。原因判断与 fleet 那一轮相同:单次临界区极短(上界约 89ms),远小于 3 秒超时。
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
`InvoiceAdminService.issueInvoice`(完成开票)、`InvoiceAdminService.reuploadInvoice`(重新上传)、`InvoiceMpService.reissueInvoice`(C 端申请重开)三个方法操作的是同一张 `order_invoice` 记录,前置条件互相重叠(`reuploadInvoice` 与 `reissueInvoice` 都要求旧票处于 `ISSUED`/`PUSHED`),三处代码注释里都写着「并发: @Lock4j 串行化」,但三个 `@Lock4j` 的 SpEL `keys` 虽逐字相同,`name` 全部缺省。lock4j 生成的实际 Redis 锁键是 `<name>#<keys 求值>`,`name` 缺省时用「方法所在类全限定名 + 方法名」兜底,三个方法名不同 ⇒ 三把不同的锁,注释里承诺的"串行化"此前并不成立。工单 #7980 AC-4 给三处补齐同一个显式 `name`,串行化才真正生效。
|
||||
|
||||
---
|
||||
|
||||
## 二、变更接口清单
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
|---|------|------|------|----------|------|
|
||||
| 1 | 完成开票 | PUT | `/v3/admin/order/invoice/{id}/issue` | 新增可能返回的错误码 | 抢锁超时返 100503(原接口/字段不变) |
|
||||
| 2 | 重新上传发票文件 | PUT | `/v3/admin/order/invoice/{id}/reupload` | 新增可能返回的错误码 | 抢锁超时返 100503(原接口/字段不变) |
|
||||
| 3 | 申请重开发票(mp internal) | POST | `/v3/internal/mp/order/invoice/{invoiceId}/reissue` | 新增可能返回的错误码 | 抢锁超时返 100503;经 hl-mp-service 两个透传入口到达小程序 C 端(见第 3 节业务边界) |
|
||||
|
||||
---
|
||||
|
||||
## 三、接口详情
|
||||
|
||||
### 1. 完成开票 `PUT /v3/admin/order/invoice/{id}/issue`
|
||||
|
||||
**VO**: `InvoiceIssueReqVO → Void`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
财务在管理后台「发票财务管理」列表里,对状态为 `REQUESTED`(待开票)的发票执行开票:先调 `POST /v3/admin/order/invoice/upload` 上传文件拿到 `fileUrl`,再调本接口把发票状态从 `REQUESTED` 推进到 `ISSUED`,同时落 `issued_at`/`issued_by`,开票金额由后端此刻自动计算写入。本次改动不涉及本接口的入参/出参字段,只新增一条"被 `reupload`/`reissue` 占用同一把锁"的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 `id`(发票 ID)+ Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| id | Path | Long | ✅ | - | 发票 ID |
|
||||
| fileUrl | Body | String | ✅ | `@NotBlank` | 发票文件 OSS URL(由 `/upload` 接口返回) |
|
||||
| pdfName | Body | String | ✅ | `@NotBlank` | 发票 PDF 文件名 |
|
||||
| pdfSize | Body | Long | ✅ | `@NotNull` | 文件大小(字节) |
|
||||
| invoiceNo | Body | String | - | - | 发票号(财务登记) |
|
||||
|
||||
#### 出参 `Result<Void>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | null | 无返回数据,`Result.data` 恒为 `null` |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"fileUrl": "https://oss.example.com/invoice/abc.pdf",
|
||||
"pdfName": "发票_2026062100001.pdf",
|
||||
"pdfSize": 204800,
|
||||
"invoiceNo": "25441000000123456789"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念(成功恒返回 `data: null`),也没有降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 400 | 参数校验失败(`fileUrl`/`pdfName`/`pdfSize` 缺失) |
|
||||
| 581500 | 发票不存在(`INVOICE_NOT_FOUND`) |
|
||||
| 581502 | 发票当前状态不是 `REQUESTED`,不允许开票(`INVOICE_CANNOT_ISSUE`) |
|
||||
| 581525 | 订单核单未完成(`reviewStatus≠COMPLETED`),不可开票(`INVOICE_ORDER_NOT_REVIEWED`) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(本组另一写口——`reupload` 或 C 端 `reissue`——正持有 `order-invoice:issue` 锁),**可重试** |
|
||||
| 401 | 未登录(网关拦截) |
|
||||
|
||||
**实测说明**(本接口是三处写口中唯一在测试服上单独取证的一处):
|
||||
|
||||
- IT 层(`InvoiceIssueLockConcurrencyIT`):100503 路径源码级真实存在——持锁方在 `acquireTimeout`(默认 3000ms)内未释放时,后到方约等待 3024ms 后收到该码;正常场景下单次临界区实测上界约 89ms,远小于超时阈值。
|
||||
- 测试服层(2026-09-20,`hl-order-service-v3` COMMIT `5d14bc524`):`redis-cli MONITOR` 抓包证实真实调用锁键为 `lock4j:order-invoice:issue#order-invoice:issue:{invoiceId}`,无方法名混入;并发发两个 `issueInvoice`(同一发票)实测结果是一个 `200`、另一个 **`581502`(业务状态守卫)而不是 100503**——证明没有并发脏写,但这是旁证不是互斥证据,100503 本身未在测试服复现;单独调一次 `issueInvoice` 回归 `code=200`,正常路径未受影响。详见「⚠️ 关键变化」与「八、测试环境已验证」。
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 无 `@Idempotent` 保护(与本组其他历史改动不同):本接口只有 `@Lock4j` 串行化 + `REQUESTED` 状态守卫去重,双击/网络重试若落在"状态已不是 `REQUESTED`"会走 581502,不是幂等拦截。
|
||||
- 抢锁失败(100503)时该次请求**未进入方法体,零写入**——`@Lock4j` 是方法级环绕拦截,拿不到锁直接抛异常,业务代码一行不会执行。
|
||||
- 金额此刻由后端自动计算写入(`CANCELLED` 订单取净实收 `paid-refunded`,其余取应收总额),不接受前端传入金额。
|
||||
- 老数据兼容:无字段变化,不涉及旧数据兼容问题。
|
||||
|
||||
---
|
||||
|
||||
### 2. 重新上传发票文件 `PUT /v3/admin/order/invoice/{id}/reupload`
|
||||
|
||||
**VO**: `InvoiceReuploadReqVO → Void`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
财务对状态为 `ISSUED`/`PUSHED` 的发票覆盖上传新文件(不作废旧发票、不新建发票,只更新文件字段 + `issued_at`/`issued_by`)。本次改动不涉及本接口的入参/出参字段,只新增一条"被 `issue`/`reissue` 占用同一把锁"的失败分支——补锁之前,`reupload` 与 C 端 `reissue`(两者前置条件同样是 `ISSUED`/`PUSHED`)能同时进入临界区,是本工单要堵的主要漏洞场景(见「⚠️ 关键变化」)。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 `id`(发票 ID)+ Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| id | Path | Long | ✅ | - | 发票 ID |
|
||||
| fileUrl | Body | String | ✅ | `@NotBlank` | 新发票文件 OSS URL(由 `/upload` 接口返回) |
|
||||
| pdfName | Body | String | ✅ | `@NotBlank` | 发票 PDF 文件名 |
|
||||
| pdfSize | Body | Long | ✅ | `@NotNull` | 文件大小(字节) |
|
||||
| invoiceNo | Body | String | - | - | 发票号(如有变更可更新) |
|
||||
|
||||
#### 出参 `Result<Void>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | null | 无返回数据,`Result.data` 恒为 `null` |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"fileUrl": "https://oss.example.com/invoice/abc_v2.pdf",
|
||||
"pdfName": "发票_2026062100001_v2.pdf",
|
||||
"pdfSize": 204800,
|
||||
"invoiceNo": "25441000000123456789"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念(成功恒返回 `data: null`),也没有降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 400 | 参数校验失败(`fileUrl`/`pdfName`/`pdfSize` 缺失) |
|
||||
| 581500 | 发票不存在(`INVOICE_NOT_FOUND`) |
|
||||
| 581503 | 发票当前状态不是 `ISSUED`/`PUSHED`,不允许重新上传(`INVOICE_CANNOT_REUPLOAD`) |
|
||||
| 581525 | 订单核单未完成,不可重传(`INVOICE_ORDER_NOT_REVIEWED`) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(本组另一写口——`issue` 或 C 端 `reissue`——正持有 `order-invoice:issue` 锁),**可重试** |
|
||||
| 401 | 未登录(网关拦截) |
|
||||
|
||||
**实测说明**:IT 层链路与第 1 节一致(同一 `InvoiceIssueLockConcurrencyIT`,触发条件同为等待超过 `acquireTimeout` 3000ms)。🔴 但**本接口未在测试服上单独抓包或调用**——2026-09-20 测试服直接证据只覆盖 §1 `issueInvoice`;本接口的锁互斥依据是代码改动本身(`reuploadInvoice` 与 `issueInvoice` 使用逐字相同的 `name="order-invoice:issue"`/`keys`)以及反射守卫单测 `InvoiceIssueLockNameGuardTest`(断言三处 `name` 非空且相等),不是独立的测试服实测。
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 无 `@Idempotent` 保护,仅 `@Lock4j` 串行化 + `canReupload`(`ISSUED`/`PUSHED`)状态守卫去重。
|
||||
- 抢锁失败(100503)时该次请求零写入,旧文件字段不会被覆盖。
|
||||
- 不作废旧发票、不新建发票,只更新文件字段(对齐修复旧 mp 端 reissue 守卫永远抛错的历史 bug:旧逻辑要求 `ISSUED`/`UPLOADED`/`DELIVERED`,但全系统无代码写入后两态)。
|
||||
- 老数据兼容:无字段变化,不涉及旧数据兼容问题。
|
||||
|
||||
---
|
||||
|
||||
### 3. 申请重开发票(mp internal) `POST /v3/internal/mp/order/invoice/{invoiceId}/reissue`
|
||||
|
||||
**VO**: `InvoiceReissueReqVO → InvoiceReissueRespVO`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
小程序 C 端用户对状态为 `ISSUED`/`PUSHED` 的已开发票申请「重开」(改抬头/税号等):后端把旧发票置 `VOIDED`,同事务插入一张继承旧票字段、按请求覆盖可改字段的新发票(`REQUESTED`,待财务重新开票)。这是 `hl-order-service-v3` 的 internal 接口,**C 端用户不会直接访问它**,而是通过 `hl-mp-service` 的两个公网入口调用(见下方「业务边界」)。本次改动不涉及本接口的入参/出参字段,只新增一条"被 `issue`/`reupload` 占用同一把锁"的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 `invoiceId`(旧发票 ID)+ Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| invoiceId | Path | Long | ✅ | - | 旧发票 ID |
|
||||
| invoiceType | Body | String | ✅ | `@NotBlank`,枚举见六.5 | 发票类型 |
|
||||
| titleType | Body | String | ✅ | `@NotBlank`,枚举见六.5 | 抬头类型 |
|
||||
| titleName | Body | String | ✅ | `@NotBlank`,≤100 字符 | 发票抬头名称 |
|
||||
| taxNo | Body | String | 条件必填 | ≤30 字符 | 税号(`titleType=COMPANY` 时必填) |
|
||||
| email | Body | String | ✅ | `@Email`,≤100 字符 | 收件邮箱 |
|
||||
|
||||
`orderId`/`amount` 由后端反查旧发票得出,前端不传;`userId` 从鉴权头解析。
|
||||
|
||||
#### 出参 `Result<InvoiceReissueRespVO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| oldInvoiceId | Long | 被作废的旧发票 ID |
|
||||
| oldInvoiceStatus | String | 旧发票状态(固定 `VOIDED`) |
|
||||
| newInvoiceId | Long | 新申请的发票 ID |
|
||||
| newInvoiceStatus | String | 新发票状态(固定 `REQUESTED`,待财务开票) |
|
||||
| mqEventName | String | 已发布 MQ 事件名(本批不发 MQ,恒为 `null`) |
|
||||
| appliedAt | LocalDateTime | 申请时间 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"invoiceType": "VAT_NORMAL",
|
||||
"titleType": "COMPANY",
|
||||
"titleName": "上海呼籁旅行科技有限公司(新)",
|
||||
"taxNo": "91310115MA1K48XXXX",
|
||||
"email": "finance2@hulalv.com"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"oldInvoiceId": 77001234567001,
|
||||
"oldInvoiceStatus": "VOIDED",
|
||||
"newInvoiceId": 77001234567003,
|
||||
"newInvoiceStatus": "REQUESTED",
|
||||
"mqEventName": null,
|
||||
"appliedAt": "2026-05-10T14:30:25"
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念(成功恒返回完整 `InvoiceReissueRespVO`)。降级路径:`hl-mp-service` 到 `hl-order-service-v3` 的 Feign 熔断打开时,`MpV3InvoiceFeignFallbackFactory` 返回 `Result.error("发票服务不可用,请稍后重试")`(`code=500`,此为既有降级行为,本次未改动)。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 400 | 参数校验失败(`titleName`/`email` 等缺失或格式非法) |
|
||||
| 581500 | 旧发票不存在,或旧发票所属订单不属于当前用户(`INVOICE_NOT_FOUND`,IDOR 场景统一返此码,不额外区分"不存在"与"无权限") |
|
||||
| 581501 | 旧发票状态不是 `ISSUED`/`PUSHED`,不可重开(`INVOICE_NOT_VOIDABLE`) |
|
||||
| 581513 | 发票类型非法(须 `VAT_NORMAL`/`VAT_SPECIAL`) |
|
||||
| 581516 | 收件邮箱为空 |
|
||||
| 581523 | 增值税专用发票只能开给单位(`titleType` 须为 `COMPANY`) |
|
||||
| 581524 | 订单当前状态不可申请开票(`INVOICE_ORDER_NOT_APPLICABLE`) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(本组另一写口——`issue` 或 `reupload`——正持有 `order-invoice:issue` 锁),**可重试** |
|
||||
|
||||
**实测说明**:IT 层链路与第 1 节一致;`InvoiceIssueLockConcurrencyIT` 的两条用例分别覆盖"未超时→排队成功"和"超时→100503"两个分支,落库结果同一订单下非 `VOIDED` 票恰好一行,无一单两票残留。🔴 但**本接口未在测试服上单独抓包或调用**——2026-09-20 测试服直接证据只覆盖 §1 `issueInvoice`;本接口(`hl-order-service-v3` internal,经 `hl-mp-service` 两个透传入口对外)的锁互斥依据是代码改动本身(`reissueInvoice` 与 `issueInvoice` 使用逐字相同的 `name="order-invoice:issue"`/`keys`)以及 `InvoiceIssueLockNameGuardTest`,不是独立的测试服实测。
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 🔴 **C 端实际入口有两个,均在 `hl-mp-service`,网关路由未变**:
|
||||
- `POST /mp/v3/order/invoice/{invoiceId}/reissue`(`MpV3InvoiceController`,`hl-mp-service/.../mp/controller/v3/MpV3InvoiceController.java:54`):`return invoiceFeignClient.reissueInvoice(invoiceId, req);` **直接透传** Feign 返回的 `Result`,`code=100503` 与 `message` 原样保留,HTTP 200。
|
||||
- `POST /mp/invoice/{invoiceId}/reissue`(`MpInvoiceController`,`hl-mp-service/.../mp/controller/MpInvoiceController.java:108`):调用 `invoiceFeignClient.reissueInvoice(invoiceId, req).getCheckedData()`,`Result.getCheckedData()`(`Result.java:143-152`)在 `!isSuccess()` 时 `throw new BusinessException(this.code, this.message)`,即抛出 `BusinessException(100503, "资源被占用,请稍后重试")`;该异常在 `hl-mp-service` 自己的 `GlobalExceptionHandler.handleBusiness` 里被捕获,同样标注 `@ResponseStatus(HttpStatus.OK)`,重新包装成 `Result.error(100503, "资源被占用,请稍后重试")`。**两条路径最终都是 HTTP 200 + body.code=100503**,只是中转方式不同(透传 vs 解包重抛),前端不用区分走的是哪一个入口,统一按 `code=100503` 处理即可。
|
||||
- 抢锁失败(100503)时该次请求零写入:旧发票不会被置 `VOIDED`,不会插入新发票行——不存在"部分成功"的中间态。
|
||||
- 无 `@Idempotent` 保护,仅 `@Lock4j` 串行化 + `canReupload` 状态守卫去重(杜绝两个并发 `reissue` 请求各读旧票 `ISSUED` 快照后各自 `VOIDED` 旧票并各插一条新票)。
|
||||
- `bankName`/`bankAccount`/`registAddress`/`registPhone` 四个专票字段在 `reissue` 中**继承旧发票**,请求体不传;`invoiceType`/`titleType`/`taxNo`/`email` 未传时同样继承旧发票对应字段。
|
||||
- 老数据兼容:无字段变化,不涉及旧数据兼容问题。
|
||||
|
||||
---
|
||||
|
||||
## 四、契约约束与正确调用方式(接口类必写)
|
||||
|
||||
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
|
||||
|
||||
### ✅ 正确 / ❌ 错误 客户端处理方式
|
||||
|
||||
本次不涉及请求体字段的互斥/联动规则变化,契约约束集中在"如何处理新失败码":
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| ✅ 收到 `code=100503` | 提示"另一个开票相关操作正在进行,请稍后重试",**允许用户重试**,不当系统错误上报、不静默吞掉 |
|
||||
| ✅ 判断成功/失败 | 一律读响应体 `code`(`===200` 才算成功),不能只看 HTTP 状态码 |
|
||||
| ❌ 只看 HTTP 状态码 | `100503` 场景下 HTTP 仍是 200,只看状态码会把失败误判为成功,前端界面会"假装成功"但后端实际零写入 |
|
||||
| ❌ 把 `100503` 当致命错误弹全屏报错 | 应视为可重试的低频并发退避,不要引导用户联系客服或上报崩溃监控 |
|
||||
|
||||
### 切换状态时的必要动作
|
||||
|
||||
本次改动不涉及"要么 A 要么 B"的字段互斥关系。唯一需要前端配合的动作是:同一张发票的「完成开票」「重新上传」「申请重开发票」三个操作现在会互相排队,如果产品设计上允许财务与用户在同一张发票上短时间内各自发起操作,两侧都需要能正确展示 `100503` 的重试提示,而不是分别按"我这边一定成功"设计交互。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库行为(涉及写操作时必写)
|
||||
|
||||
| 场景 | `order_invoice` 表行为(改动前) | `order_invoice` 表行为(改动后) |
|
||||
|------|------|------|
|
||||
| `reupload` 持锁期间 `reissue` 并发进来 | 两者都能进入临界区:`reissue` 先把旧票置 `VOIDED` 并插入新票(`REQUESTED`),`reupload` 随后把(已作废的)旧票又写回 `ISSUED` ⇒ 同一订单出现**两行非 VOIDED**(旧 `ISSUED` + 新 `REQUESTED`),数据不一致 | `reissue` 在 `reupload` 持锁期间抢锁失败,直接返回 `100503`,**不执行任何 UPDATE/INSERT**;`reupload` 完成释放锁后 `reissue` 才能进入临界区(或超过 3 秒被拒) |
|
||||
| `issue`/`reupload`/`reissue` 各自的写操作本身 | 无变化:`issue` 写 `status=ISSUED`+`issued_at`+`issued_by`+`amount`;`reupload` 写文件相关字段;`reissue` 一次 UPDATE(旧票→`VOIDED`)+ 一次 INSERT(新票 `REQUESTED`) | 无变化,只是三者现在对同一 `invoiceId` 串行化执行 |
|
||||
|
||||
**无 `SET NULL` 相关行为**:本次改动不涉及任何列的置空规则,三个方法各自写入的列集合与改动前完全一致。
|
||||
|
||||
---
|
||||
|
||||
## 六、边界行为
|
||||
|
||||
- 未登录 → 401(网关拦截)
|
||||
- 发票不存在 / 无权限(IDOR) → 581500(`INVOICE_NOT_FOUND`,两种场景统一返此码,不额外泄露"存在但不属于你")
|
||||
- 下游 `hl-order-service-v3` 不可用(熔断打开)→ `hl-mp-service` 侧 `MpV3InvoiceFeignFallbackFactory` 返 `code=500`(既有行为,本次未改)
|
||||
- 抢锁失败 → 100503,HTTP 仍 200,本次新增的唯一边界分支,零写入
|
||||
- 老数据兼容 → 本次不涉及字段增删,存量发票记录无需迁移,行为在锁生效前后对"未并发"的单次请求完全一致
|
||||
|
||||
---
|
||||
|
||||
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
|
||||
|
||||
### 发票状态(`com.hulalv.invoice.enums.InvoiceStatus`)
|
||||
|
||||
**所属字段**: `oldInvoiceStatus`/`newInvoiceStatus`(本节第 3 个接口出参);决定 `reupload`/`reissue` 是否可执行的判据字段 | **类型**: `String`
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `REQUESTED` | 待开票 | 已申请,等待财务开票;`issue` 只接受此状态 |
|
||||
| `ISSUED` | 已开票 | 已上传文件,用户可查看/下载;`reupload`/`reissue` 只接受此状态或 `PUSHED` |
|
||||
| `PUSHED` | 已推送 | 已推送客户(本期不真发通知,仅落状态);`reupload`/`reissue` 同样接受 |
|
||||
| `VOIDED` | 已作废 | 终态,由 `reissue` 副作用产生;不可再 `reupload`/`reissue` |
|
||||
|
||||
### 抬头类型(`titleType`,本节第 3 个接口入参)
|
||||
|
||||
**所属字段**: `InvoiceReissueReqVO.titleType` | **类型**: `String`
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `PERSONAL` | 个人 | 抬头为个人姓名,`taxNo` 非必填 |
|
||||
| `COMPANY` | 单位 | 抬头为公司名,`taxNo` 必填;`invoiceType=VAT_SPECIAL` 时只能是此值(否则 581523) |
|
||||
|
||||
### 发票类型(`invoiceType`,本节第 3 个接口入参)
|
||||
|
||||
**所属字段**: `InvoiceReissueReqVO.invoiceType` | **类型**: `String`
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `VAT_NORMAL` | 增值税普通发票 | 默认场景 |
|
||||
| `VAT_SPECIAL` | 增值税专用发票 | 要求 `titleType=COMPANY`,且 `taxNo`/银行信息/注册信息在申请阶段已齐(`reissue` 继承旧票) |
|
||||
|
||||
---
|
||||
|
||||
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
|
||||
|
||||
### 字段级对比
|
||||
|
||||
| 字段 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| (无) | 三个接口的请求体/响应体字段**零变化** | 同左 |
|
||||
|
||||
### 行为级对比
|
||||
|
||||
| 行为 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| `issue`/`reupload`/`reissue` 三者的并发关系 | 各自持有一把不同的锁(`name` 缺省退化为类名+方法名),互不阻塞 | 三者共享同一把锁(`name="order-invoice:issue"`),对同一 `invoiceId` 串行化执行 |
|
||||
| `reupload ∥ reissue` 并发结果 | 可能在同一订单下留下两行非 `VOIDED`(旧 `ISSUED` + 新 `REQUESTED`),数据不一致 | 后到方抢锁失败直接拒绝(100503)或排队等待,落库结果始终只有一行非 `VOIDED` |
|
||||
| 新增的失败码 | 无 | `100503`(`RESOURCE_LOCKED`),HTTP 200 + body.code=100503;路径真实存在(`issueInvoice` 测试服已实测确认锁键;另两处依赖代码同源+反射守卫用例覆盖),但测试服并发实测未触发 100503 本身,实际频率极低 |
|
||||
|
||||
---
|
||||
|
||||
## 六.7、影响评估(修改/删除类必写)
|
||||
|
||||
- **是否破坏向后兼容**: 否——三个接口的请求体/响应体字段、既有错误码全部零增删,正常(无并发)场景下行为与改动前完全一致。
|
||||
- **前端是否必须同步上线**: 是——小程序 C 端与管理后台前端都需要能识别 `code=100503` 并提示"稍后重试",否则用户会在极低概率下看到 HTTP 200 却操作失败、且没有任何提示的情况(当前前端逻辑若只判断 HTTP 状态码或只处理已知错误码列表,会把这次失败静默吞掉或误判成功)。
|
||||
- **前端 workaround 清理点**: 无——本次不涉及此前的前端兜底/反推逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
|
||||
|
||||
- **仅影响**: 管理后台「发票财务管理」的「完成开票」「重新上传」两个写操作 + 小程序 C 端「申请重开发票」(含其两个 hl-mp-service 入口)在**并发**场景下的失败分支
|
||||
- **零影响**:
|
||||
- 发票申请接口(`adminApplyInvoice`/`mpApplyInvoice`,两者的并发漏洞属于 #7980 P2,本工单未修,见 `InvoiceAdminService.adminApplyInvoice` 方法注释)
|
||||
- 发票详情、列表、下载、推送、企业信息自动回填等只读或非本组写接口
|
||||
- 三个接口自身的入参校验、状态机判定、金额计算等既有业务逻辑(未改动一行)
|
||||
- 单次请求(无并发命中)下的行为——三个接口改动前后表现完全一致
|
||||
- hl-gateway 路由配置——三个端点路径/方法零变化
|
||||
|
||||
---
|
||||
|
||||
## 八、测试环境已验证
|
||||
|
||||
### 部署
|
||||
|
||||
`hl-order-service-v3` 部署后 COMMIT = **`5d14bc524`**(滚动重启两实例均 UP)。
|
||||
|
||||
⚠️ **如实说明**:部署**前**它已经是 `5d14bc524`(`BEHIND 0/N`)——另一会话此前已把该 commit 推上测试服;本次是重新走了一遍部署流程,COMMIT 没有变化,**不是本次部署使改动生效**,本次部署只是重新确认了一遍。
|
||||
|
||||
祖先链判定(仓库目录 `/opt/hulalv/HL`,`git fetch origin dev-v3` 后):`git merge-base --is-ancestor 09b8f2323 5d14bc524` → **YES**(#7980 AC-4 的合并提交已在运行版本内)。
|
||||
|
||||
`hl-gateway` 一直是 `4cbccc26b`(`BEHIND 70/Y`),**未动**;发票端点走网关全部正常返回,本次取证不受影响。
|
||||
|
||||
### 🔴 直接证据(证据 A)——只覆盖三处写口中的一处
|
||||
|
||||
`redis-cli MONITOR`(db 0,连接参数取自 Nacos `hl-order-service-v3-test.yml`)实时抓包,调用 `issueInvoice`(`invoice_id=2100889866400104450`)时捕获:
|
||||
|
||||
```
|
||||
EVALSHA ... "lock4j:order-invoice:issue#order-invoice:issue:2100889866400104450" ... "30000"
|
||||
lua "set" 同键 ... "NX" "PX" "30000"
|
||||
lua "get" / "del" 同键
|
||||
```
|
||||
|
||||
键形与期望完全一致、**没有方法名混入** ⇒ 证明补丁在**运行的字节码里**生效。
|
||||
|
||||
**但这里必须收窄,不要读成"三处共用同一把锁"已被测试服实测确认**:MONITOR 实际只跑了三个写口里的一个(`issueInvoice`)。正确表述是——**测试服直接证据覆盖三处中的一处(`issueInvoice`);另两处(`reuploadInvoice`/`reissueInvoice`)由代码改动本身与反射守卫用例 `InvoiceIssueLockNameGuardTest`(断言三处 `name` 非空且相等)覆盖,未在测试服上单独抓包**。
|
||||
|
||||
### 100503 未复现(证据 B)
|
||||
|
||||
对另一张票并发发两个 `issueInvoice`,一个 `code=200`,另一个是 **`code=581502`**(`INVOICE_CANNOT_ISSUE` 业务状态守卫)——**不是 `100503`**。
|
||||
|
||||
如实说明:**未打出锁超时码**,原因仍是临界区极短(与 fleet 那轮 34 并发未触发同因)。第二个请求是被**业务状态守卫**拦下,说明没有产生并发脏写,但**这是旁证,不是互斥证据**——不能拿它证明锁真的挡住了并发,只能证明这次没有观察到数据损坏。
|
||||
|
||||
两面都要写:路径源码级真实存在(已读码确认 + `InvoiceIssueLockConcurrencyIT` 实测过等待约 3024ms 后抛出该码),但**测试服未能触发**,不代表更长事务/更大载荷/生产数据量下不会触发。
|
||||
|
||||
### 回归(证据 C)
|
||||
|
||||
单独调一次 `issueInvoice`,`code=200`,正常路径未被锁破坏。**这是回归证据,不与互斥证据混同。**
|
||||
|
||||
### 取证是干净的
|
||||
|
||||
三张发票全是测试夹具(客户名「核团甲/乙/丙」,`apply_reason` 写着 #7932 造数)。改前均 `REQUESTED / NULL / NULL`,改后 `ISSUED` + 金额 + 票号,**已按改前快照逐列 UPDATE 还原**(含 `file_url`/`pdf_name`/`pdf_size`/`issued_at`/`issued_by`/`update_time`),复读与改前完全一致;Redis 侧 `--scan lock4j:*order-invoice*` 复扫为空,无残留锁键。
|
||||
|
||||
⚠️ 过程中发生过**一次 `code=401`**(token 被别的会话顶掉),该次调用之前没有任何读数,已重新登录并把那一段整个重做,**证据 A 用的是重登后的结果**——这一条也计入取证可信度的一部分。
|
||||
|
||||
---
|
||||
|
||||
## 十、相关文档
|
||||
|
||||
- 关联 Issue: [wx/HL#7980](https://git.1814.love:8443/wx/HL/issues/7980)(AC-4)
|
||||
- 关联 PR: [wx/HL#8043](https://git.1814.love:8443/wx/HL/pulls/8043)
|
||||
|
||||
## 关联 / 联系人
|
||||
|
||||
### 链接
|
||||
|
||||
- **Issue**: [#7980](https://git.1814.love:8443/wx/HL/issues/7980)
|
||||
- **PR**: [#8043](https://git.1814.love:8443/wx/HL/pulls/8043)
|
||||
- **Merge commit**: [09b8f2323](https://git.1814.love:8443/wx/HL/commit/09b8f2323)
|
||||
|
||||
### 联系人
|
||||
|
||||
- **后端负责人**: @wx
|
||||
在新工单中引用
屏蔽一个用户