feat(order): 重构退款全流程并新增申请退款中状态

- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
This commit is contained in:
2026-09-16 03:44:09 +08:00
parent 89b59c7b91
commit b94dd6dffb
29 changed files with 2141 additions and 158 deletions
@@ -0,0 +1,93 @@
---
name: 会员额度不叠加
overview: 修复月度会员包与年度会员包赠送额度叠加的问题:会员包来源(source=1)的额度凭证不再求和,只取其中 amount 最大的一份计入有效额度;Daily 额度与 Cos 永久额度均适用,含扣减/回退/剩余计算全链路;其他来源(加量包/广告/体验)维持叠加;存量数据无需迁移。
todos:
- id: quota-select-core
content: 新增会员包生效份选择纯函数,改造 getEffectiveQuota 聚合(Cos 与每日额度)
status: completed
- id: cos-quota-chain
content: 改造 Cos 扣减、剩余与回退链路复用选择规则,未生效份不参与扣减与回退
status: completed
dependencies:
- quota-select-core
- id: refund-expire-consistency
content: 退款回收流水改为吊销前后重算差值记账,核对到期失效补记逻辑
status: completed
dependencies:
- quota-select-core
- id: quota-tests
content: 补充选择/聚合/扣减/回退/退款单元测试并跑通构建与全量回归
status: completed
dependencies:
- cos-quota-chain
- refund-expire-consistency
- id: docs-sync
content: 更新接口文档与 README 的额度叠加规则说明
status: completed
dependencies:
- quota-select-core
---
## 用户需求
修复"购买的月度会员与年度会员额度会叠加"的问题:额度规则调整为年度会员与月度会员额度相同、不叠加。
## 产品概述
用户同时持有多份会员包(如月度包 + 年度包)时,同类额度不再多份相加,只按"一份"生效;该规则即时生效,历史数据无需迁移。
## 核心功能
- 额度聚合调整:会员包来源(source=1)的同类额度只取 amount 最大的一份计入总额;月/年额度相同时即等价于只有一份
- 全链路一致:额度概览、额度校验、消费扣减、失败回退、退款回收、到期记账统一按单份口径计算
- 范围限定:仅会员包来源不叠加;加量包、广告激励、体验会员等其他来源仍按原逻辑叠加
- 存量兼容:历史凭证保留,查询/消费时按新聚合规则即时生效,无需数据修复
- 用完顺延:选中份额度用尽后自动启用下一份有效会员包凭证,已购额度不浪费,且任一时刻展示额度不翻倍(如 200 变 100)
## 技术栈
沿用现有 Go + Gin + GORM(MySQL)+ Redis 架构;不改表结构、不引入新依赖、不做数据迁移。
## 实现思路
在 service 层引入"会员包生效份选择"纯函数,替换现有对全部凭证直接 SUM 的聚合方式:
- **选择规则(仅 source=1 会员包)**:从有效凭证中排除已用尽(amount-used<=0)的份,按 `amount 降序 → used 降序(保持正在使用的一份,避免剩余展示抖动)→ id 降序(新购优先)` 取第一条;无候选则会员包贡献为 0。
- **其他来源(source=2/3/4)**:维持 SUM 叠加,行为不变。
- **改造点**:
1. `getEffectiveQuota`:总额 = 免费额度 + 选中份 amount + 其他来源 SUM;Cos 已用 = 免费已用 + 选中份 used + 其他来源 used;每日额度仅改 Total(已用为当日全局计数)。额度概览 `InfoGetBySelf` 自动继承。
2. `consumeCosQuota`:事务内仍按"先到期先用"扣减其他来源凭证,会员包组只扣选中份,再扣免费额度;未生效份不参与扣减。
3. `cosRemainTx`:免费剩余 + 其他来源剩余 + 选中份剩余,与展示口径一致。
4. `refundCosQuotaTx`:先退免费,再退凭证;其他来源按原顺序回退,会员包仅回退选中份。
5. `buildQuotaRevokeFlows`:改为"吊销前剩余 vs 排除该订单凭证后重算剩余"的差值记账(diff=0 不写流水),保证退款回收与实额一致;`quota_expire.go` 到期补记自动适配,核对即可。
- **关键决策与权衡**:选择为纯内存 O(n)(n 为用户凭证数,通常个位数),无新增 DB 往返;选择在每处自治重算,无需持久化状态;已用尽自动切换下一位,兼顾"不叠加展示"与"已购额度不浪费"。
- **安全与可靠性**:仍依赖凭证行的条件更新(used+n<=amount)防并发超扣;聚合失败保持既有降级告警策略,不新增泄密日志。
## 关键代码结构
```
// pickEffectiveMemberGrant 在有效凭证中选出会员包来源(source=1)的生效份:
// 排除已用尽,按 amount 降序 → used 降序 → id 降序取一份;无候选返回 nil。
// 纯函数:供 getEffectiveQuota / consumeCosQuota / cosRemainTx / refundCosQuotaTx 复用。
func pickEffectiveMemberGrant(grants []mysqlmodel.QuotaGrant) *mysqlmodel.QuotaGrant
```
## 目录结构
```
zt-closet-backend/
├── internal/service/
│ ├── service.go # [MODIFY] 新增 pickEffectiveMemberGrant 纯函数;改造 getEffectiveQuota、consumeCosQuota、cosRemainTx、refundCosQuotaTx,统一复用选择规则并更新注释
│ ├── order.go # [MODIFY] buildQuotaRevokeFlows 改为吊销前后重算差值记账(排除订单凭证后按同一规则聚合)
│ ├── quota_select_test.go # [NEW] 选择与聚合纯函数单测:月+年同额只取一份、取最大、used 稳定性、用尽顺延、其他来源仍叠加
│ └── cos_quota_test.go # [NEW] sqlmock 集成测试:Cos 扣减只落选中份、回退、剩余计算、退款回收流水差值
├── 接口文档.md # [MODIFY] 更新额度规则(629-653 行附近):会员包来源不叠加,其他来源仍叠加
└── README.md # [MODIFY] 同步额度叠加规则说明
```
## 测试与验证
- 覆盖:月度+年度同时持有、amount 不同取最大、选中份用尽顺延、加量包/体验/广告仍叠加、Cos 消费/回退/退款回收、每日额度 Total 不翻倍。
- 回归:`go build ./...`、`go vet ./...`、`go test ./...` 全量通过,确保现有发放幂等与补偿测试不受影响。
@@ -0,0 +1,156 @@
---
name: 虚拟支付退款推送字段修复与查单兜底
overview: 修复微信虚拟支付退款推送 xpay_refund_notify 报文字段解析错误(应用 MchOrderId/MchRefundId/RetCode,而非 OutTradeNo/RefundOrderId/RefundStatus)导致的订单永久卡在「6 申请退款中」问题;解析不到关键字段时不再静默应答成功,并新增退款结果查单兜底定时任务(微信侧已退款自动订正为已退款,仍未退款则保持状态并告警人工)。
todos:
- id: fix-refund-push-parse
content: 用 [skill:lsp-code-analysis] 核对引用点后,按官方字段重写退款推送解析与 RetCode 判定,补 pkg/wechat 单测
status: completed
- id: harden-refund-notify
content: 改造 handleRefundNotify 与 verifyXPayRefund:字段驱动流转、失败重试应答、流水与原始报文回写
status: completed
dependencies:
- fix-refund-push-parse
- id: add-refund-reconcile-job
content: 用 [skill:lsp-code-analysis] 核对命名后,新增退款查单兜底任务与 DAO/Redis key 并注册到 main.go
status: completed
dependencies:
- harden-refund-notify
- id: add-reconcile-tests
content: 补 DAO 扫描与解析判定单测,跑通 go build、go vet 与全量 go test 回归
status: completed
dependencies:
- add-refund-reconcile-job
- id: sync-refund-docs
content: 同步接口文档与 README:虚拟支付退款推送字段、失败重试语义与查单兜底说明
status: completed
dependencies:
- harden-refund-notify
- add-refund-reconcile-job
---
## 产品概述
用户对虚拟支付订单(pay_channel=2)申请退款后,订单进入「6 申请退款中」;微信侧退款完成后会推送 `xpay_refund_notify`。当前该推送的报文字段解析与官方不一致:官方用 `MchOrderId`(原支付单商户单号)、`MchRefundId`、`RetCode`(0=成功、非 0=失败)、`RetMsg`,而代码读的是 `OutTradeNo`、`RefundOrderId`、`RefundStatus`,导致取不到订单号,既没有把订单置为「5 已退款」,又向微信应答「处理成功」使其不再重试,订单永久停留在「申请退款中」。
## 核心功能
- 按官方报文正确解析退款推送:`MchOrderId`、`MchRefundId`、`WxRefundId`、`RefundFee`、`RetCode`、`RetMsg`、`RetryTimes`;同时兼容历史版式字段(`OutTradeNo`/`RefundOrderId`/`RefundStatus`),避免微信侧版本差异
- 关键字段缺失或退款结果无法判定时,向微信返回「处理失败」,触发其重试(间隔 2/4/8/16…,最多 15 次),不再静默应答成功
- 退款成功:订单「申请退款中(6)」流转为「已退款(5)」,回收该单已发放权益(会员天数按快照回退、赠送额度失效未用部分,已消耗部分不追回),发送退款成功站内通知,退款流水置成功并记录微信退款单号与原始报文
- 退款失败(RetCode 非 0):订单回退为「已支付(2)」,用户可重新申请,退款流水置失败并记录微信失败原因
- 兜底查单:定时反查长期停留在「申请退款中」的虚拟支付订单;微信侧已退款的自动订正为「已退款」并回收权益;微信侧仍未退款的保持状态不变并输出告警日志交人工处理
- 幂等保障:重复推送、推送与兜底任务并发时,权益只回收一次、状态只流转一次
- 前端可见效果:订单列表与详情中该单状态由「申请退款中」变为「已退款」(失败则回到「已支付」),退款时间与退款金额同步更新,用户与运营不再看到长期卡单
## 技术栈
- 语言与框架:Go + Gin + GORM(沿用现有分层 routers → service → mysqldao → mysqlmodel,不引入新框架)
- 存储:MySQL(订单/退款流水,无需新增列);Redis(分布式锁与查询频率负缓存,复用现有 `redisdao` 封装)
- 测试:go-sqlmock(沿用 `newQuotaTestDAO` 注入范式);虚拟支付日志沿用 `slog.*Context`
- 依赖:无新增第三方依赖,复用 `pkg/wechat` 既有 `ParseNotify`/`BuildNotifyResponse`/`QueryOrder`/`IsOrderRefundedStatus`
## 实现方案
### 核心思路
分三层修复:解析层按官方字段重写退款推送结构并提供语义化访问器;处理层用解析结果驱动订单流转,并让「无法判定」场景 fail-closed 返回失败触发微信重试;兜底层新增查单对账定时任务,保证推送丢失/解析失败时订单仍能收敛到终态,与官方「推送 + 轮询双保险」建议一致。
```mermaid
flowchart LR
A[微信退款推送 xpay_refund_notify] --> B[解析 MchOrderId/MchRefundId/RetCode]
B -->|字段缺失或不可判定| C[应答 ErrCode:1 触发重试]
B -->|退款成功| D[反查 query_order 校验真实退款]
D --> E[订单 6 转 5 + 回收权益 + 通知 + 流水回写]
B -->|退款失败| F[订单 6 转 2 + 流水置失败]
G[兜底任务 每分钟扫描 order_status=6] --> H[query_order 反查]
H -->|微信侧已退款| E
H -->|仍未退款| I[保持状态 + 24h 告警人工]
```
### 关键决策与权衡
1. **官方字段为主、旧字段兼容**:`MchOrderId` 优先、`OutTradeNo` 兜底;`MchRefundId` 优先、`RefundOrderId`/`WxRefundId` 兜底;成功判定 `RetCode` 优先、`RefundStatus`(大小写不敏感 SUCCESS)兜底。既修正线上行为,又避免微信侧版式差异再次导致静默失败。
2. **`RetCode` 用指针类型区分「未携带」与「0」**:直接使用 `*int`,避免字段缺失被误判为「退款成功」而错误回收用户权益(资损方向)。
3. **不可判定一律应答失败**:推送无法定位订单或无法判定结果时返回 `ErrCode:1`,让微信按 2/4/8/16 秒重试最多 15 次;叠加兜底任务,做到「推送失败也不会永久卡单」。
4. **兜底不自动回退订单**(已与用户确认):反查到微信侧仍未退款时保持「申请退款中」并 Error 告警,避免运营处理中的退款被误判回退造成重复退款风险。
5. **复用既有补偿范式**:新任务完全对齐 `virtualpay_compensate.go` 的 Redis 锁、批量上限、负缓存、并发 worker 与调度注册方式,避免新增架构模式。
### 性能与可靠性
- 推送路径无新增外部调用(成功路径已有一次 `query_order` 反查),内存与耗时与原实现同级;兜底任务每轮限制批量(100 单),并用负缓存把单个订单的微信接口查询频率压到每 10 分钟最多一次,24 小时告警去重避免刷日志
- 状态流转全部使用条件更新与行锁(`RefundSettleWithRevokeTask` 仅允许 2/3/6 流转到 5),推送与兜底并发天然幂等,不依赖额外分布式锁;兜底任务自身以 Redis 锁保证多实例单跑
- 失败路径:查询失败不写负缓存,下一轮立即重试;写库失败仅记录告警,不影响微信应答语义,由重试或兜底收敛
## 目录结构
```
zt-closet-backend/
├── pkg/wechat/
│ ├── virtualpay.go # [MODIFY] 重写 XPayRefundNotify 为官方字段 + 兼容字段,新增 OrderNo/RefundNo/Succeeded 访问器与 XPayRefundRetCodeSuccess 常量;XPayIOSRefundQueryNotify 增加 MchOrderId 兼容字段与 OrderNo(不改变 OutTradeNo 优先级)
│ └── virtualpay_test.go # [MODIFY] 补退款推送解析与判定单测:官方报文(RetCode=0)、失败报文(RetCode 非 0 + RetMsg)、旧版式(OutTradeNo/RefundStatus)、关键字段缺失四类
├── internal/service/
│ ├── virtualpay.go # [MODIFY] handleRefundNotify 改为字段驱动:订单号为空/结果不可判定返回 errcode.OrderRealityCheckFailed 触发微信重试;成功分支用订单号查询并 settleRefundSuccess;失败分支回退 6→2、fail_reason 取 RetMsg;verifyXPayRefund 反查改用 order.OrderNo、openid 缺失时回退用户表 WxOpenID;syncRefundFlowSuccess 扩展回写 wechat_refund_no 与 callback_raw;handleIOSRefundQuery 改用兼容 OrderNo
│ ├── virtual_refund_confirm.go # [NEW] 退款查单兜底定时任务:常量(批量 100、锁 TTL、扫描宽限 10 分钟、24h 告警、查询负缓存 10 分钟)、StartVirtualRefundConfirmScheduler、runVirtualRefundConfirm、单笔 reconcile(反查已退款则结算并同步流水,未退款则按阈值告警)
│ └── refund_apply_test.go # [MODIFY] 视需要补充服务层用例(沿用 setupOrderQuotaTest + sqlmock)
├── internal/mysqldao/
│ ├── order.go # [MODIFY] 新增 OrderListVirtualRefunding(pay_channel=2 且 order_status=6 且 updated_at 小于扫描水位,按 id 升序限量),写法对齐 OrderListVirtualUnconfirmed
│ └── order_refund_test.go # [MODIFY] 补 OrderListVirtualRefunding 的 sqlmock 条件与返回断言
├── internal/redismodel/
│ ├── lock.go # [MODIFY] 新增 KeyLockVirtualRefundConfirm(兜底任务全局单实例锁)
│ └── virtualpay.go # [MODIFY] 新增 KeyVirtualRefundChecked(查询负缓存)与 KeyVirtualRefundStuckAlert(24h 告警去重)
├── internal/redisdao/
│ └── virtualpay.go # [MODIFY] 新增 VirtualRefundCheckedSetNX、VirtualRefundStuckAlertSetNX(SET NX EX 语义,写法对齐 TryLock/VirtualPayUnpaidSet)
├── main.go # [MODIFY] 注册 service.StartVirtualRefundConfirmScheduler()
├── 接口文档.md # [MODIFY] 5.12 与 /xpay/notify 相关章节、退款业务逻辑、FAQ:补虚拟支付退款推送字段(MchOrderId/MchRefundId/RetCode/RetMsg)、失败会重试、查单兜底与人工告警口径
└── README.md # [MODIFY] 「5. 交易与权益」退款条目补虚拟支付推送字段与查单兜底说明
```
## 关键代码结构
```
// pkg/wechat/virtualpay.go
type XPayRefundNotify struct {
XPayNotifyBase
WxRefundID string `json:"WxRefundId"` // 微信退款单号
MchRefundID string `json:"MchRefundId"` // 商户退款单号
WxOrderID string `json:"WxOrderId"` // 退款单对应支付单的微信单号
MchOrderID string `json:"MchOrderId"` // 退款单对应支付单的商户单号(= 本地 order_no)
RefundFee int64 `json:"RefundFee"` // 退款金额(分)
RetCode *int `json:"RetCode"` // 0=退款成功,非 0=失败;nil 表示报文未携带
RetMsg string `json:"RetMsg"` // 失败原因
// 其余官方字段(时间戳/交易单号/RetryTimes/Attach 等)与旧版式兼容字段从略
}
// OrderNo 取商户订单号:MchOrderId 优先,旧版式回退 OutTradeNo。
func (n *XPayRefundNotify) OrderNo() string
// RefundNo 取退款单号:MchRefundId 优先,依次回退 RefundOrderId、WxRefundId。
func (n *XPayRefundNotify) RefundNo() string
// Succeeded 判定退款结果;determinable=false 表示报文未携带任何可判定字段(调用方应应答失败触发重试)。
func (n *XPayRefundNotify) Succeeded() (ok bool, determinable bool)
```
```
// internal/mysqldao/order.go
// OrderListVirtualRefunding 查询长期停留在「申请退款中」的虚拟支付订单(订单已申请退款但结果未回流)。
func (d *OrderDao) OrderListVirtualRefunding(ctx context.Context, beforeUpdatedAt int64, limit int) ([]mysqlmodel.Order, error)
// internal/service/virtual_refund_confirm.go
func StartVirtualRefundConfirmScheduler() // 每分钟一轮,Redis 锁防重,recover 兜底
func runVirtualRefundConfirm() // 扫描 + 负缓存过滤 + 反查 + 结算/告警
```
## 实现要点
- 应答语义:仅在确认处理完成后返回成功应答;字段缺失、订单不存在、查询失败、金额不一致一律返回失败,交由微信重试与兜底任务收敛;Redis 锁不可用的既有语义(不重复处理)保持不变
- 日志:沿用 `slog.*Context`,推送分支记录 `order_no/mch_refund_id/wx_refund_id/ret_code/ret_msg/retry_times`,兜底任务记录 `order_no/user_id/stuck_seconds`;原始报文仅写入退款流水 `callback_raw` 字段(截断),不打印敏感信息
- 安全性:退款成功仍强制反查 `query_order`(HMAC 服务端接口)确认微信侧真实退款与金额一致,防伪造推送回收权益;判定不确定时不回收、不流转
- 兼容性:仅新增字段与方法,不改变 `ParseNotify`/`BuildNotifyResponse`/`IsOrderRefundedStatus` 等既有契约;`handleGoodsDeliver` 链路零改动
- 边界(不做):不改为后端主动调用 `refund_order`(仍为运营在虚拟支付后台手动退款);普通微信支付(pay_channel=1)退款推送字段正确,不在本次范围;存量单笔订单不做人工订正
## Agent Extensions
### Skill
- **lsp-code-analysis**
- Purpose: 在改动 `XPayRefundNotify`/`XPayIOSRefundQueryNotify` 前,用 LSP 语义分析确认这两个结构的全部引用点(字段读取、方法调用、测试用例)与 `handleRefundNotify`/`verifyXPayRefund`/`syncRefundFlowSuccess` 的调用链,并核对新增 DAO 方法、Redis key 常量、调度函数命名无冲突
- Expected outcome: 输出完整的引用清单与受影响的文件/调用点,确保字段替换无遗漏、无编译期破坏,并避免与既有命名重复
@@ -0,0 +1,148 @@
---
name: 退款申请订单状态流转修复
overview: 新增订单状态「6=申请退款中」,修复用户发起退款申请后订单状态仍停留在「已支付」的问题:退款受理与订单状态流转原子化,退款成功流转为「5 已退款」,退款失败回退为「2 已支付」以便重新申请,并同步接口文档与状态机。
todos:
- id: add-status-constant
content: 新增订单状态 6「申请退款中」常量,并同步 mysqlmodel 与两份迁移 SQL 的列注释
status: completed
- id: atomic-refund-apply
content: 实现退款受理原子流转 DAO 并改造 RefundApply,使订单 2→6 与流水落库同事务
status: completed
dependencies:
- add-status-constant
- id: settle-and-failure
content: 放行状态 6:退款成功结算 6→5 并建回收任务,退款失败回退 6→2(普通与虚拟支付回调)
status: completed
dependencies:
- atomic-refund-apply
- id: list-filter-dto
content: 订单列表放开按状态 6 筛选并更新 DTO 注释
status: completed
dependencies:
- add-status-constant
- id: refund-tests
content: 用 sqlmock 补充退款受理、成功结算、失败回退的单测
status: completed
dependencies:
- atomic-refund-apply
- settle-and-failure
- id: sync-api-docs
content: 同步接口文档:枚举、状态机、退款流程说明与前端轮询 FAQ
status: completed
dependencies:
- atomic-refund-apply
- settle-and-failure
---
## 产品概述
修复订单退款流程中的状态缺失问题:用户提交退款申请成功后,订单状态应实时切换为「申请退款中」,而当前订单仍停留在「已支付」,导致用户端无法感知退款进度,运营也无法按状态筛选待处理退款单。
## 核心功能
- 新增订单状态「6=申请退款中」:用户通过 `POST /order/refund` 申请退款成功后,订单立即由「2 已支付」流转为「6 申请退款中」
- 申请受理保持幂等与并发安全:同一订单重复申请、并发申请均只受理一次;上次退款失败的订单可复用旧退款流水重新申请
- 退款成功:微信退款回调(普通支付/虚拟支付)到达后订单流转为「5 已退款」,并回收已发放权益、发送退款通知
- 退款失败:微信回调返回非成功结果时,订单回退为「2 已支付」、退款流水置为失败,用户可重新申请
- 订单列表支持按新状态筛选:前端可按「申请退款中」过滤订单并轮询进度
- 已取消但已付款的竞态订单保持「3 已取消」不变(其权益从未发放)
- 同步更新接口文档:订单状态枚举、状态机、退款模块说明与前端轮询说明
## 技术栈
- 语言与框架:Go + Gin + GORM(现有项目架构,不引入新框架)
- 数据库:MySQL(`orders.order_status` 为 TINYINT,无枚举约束,无需改列类型);Redis 仅用于回调分布式锁(已有)
- 测试:`go-sqlmock` + 现有 `global.MysqlEngine` 注入模式(参考 `internal/service/order_quota_test.go`、`internal/mysqldao/hardening_test.go`)
- 迁移:golang-migrate(`pkg/migration`,go:embed,`cmd/migrate` 手动执行)
## 实现方案
### 核心思路
在订单状态枚举中新增 `OrderStatusRefunding int8 = 6 // 申请退款中`;将「受理退款申请」改造为事务内的「锁定订单 → 校验已支付 → 订单流转 2→6 → 写入/复用退款流水」原子操作,使订单状态与退款流水严格一致;再把退款结果回调链路(成功置 5、失败回退 2)与权益回收判断同步到新状态 6。
### 关键决策与权衡
1. **用订单行锁 + 条件流转作为并发闸门**:现有 `RefundApply` 注释称「order_no 唯一索引兜底并发」,但 `refund_flow_logs` 实际只有 `uk_refund_flow_no`(refund_no 唯一)与普通索引 `idx_refund_flow_order(order_id)`,无 order_no 唯一约束,并发申请存在双写风险。改为在 DAO 中新增事务方法(`SELECT ... FOR UPDATE` 锁订单行,沿用 `RefundSettleWithRevokeTask` 的既有范式),只有拿到「2 已支付」的请求才能流转为 6 并落流水,天然互斥且幂等;顺手修正误导性注释。
2. **失败回退为「已支付」(已与用户确认)**:与现有「失败可复用旧流水重新申请」逻辑闭环;注意运营在线下直接拒绝(未发起微信退款)不会产生回调,文档中明确提示该场景订单会停留在 6,需运营退款或人工订正。
3. **已取消但已付款的自动退款单不改状态(已与用户确认)**:`autoRefundCancelledOrder` 生成待审核退款流水后保持订单 3 已取消,避免误创建权益回收任务(该单权益从未发放)。
4. **`RefundSettleWithRevokeTask` 必须放行 6**:否则退款成功回调会静默 return,订单无法置 5;且状态 6 表示权益已发放过,必须创建 `benefit_revoke_tasks` 回收任务(现仅 `order_status == 2` 时创建,需扩展为 2 或 6)。
5. **错误码不新增**:复用 `errcode.RefundNotAllowed(71001)`、`RefundApplyFailed(71003)`、`DatabaseError(19001)`,避免破坏前端约定。
### 性能与可靠性
- 受理路径新增一次行锁事务,单次申请成本为 1 次主键/唯一索引行锁 + 2 条写语句,量级可忽略;无 N+1、无热点表全扫。
- 状态流转全部使用条件更新(`WHERE order_status = from`),并发/乱序回调天然幂等,不依赖额外分布式锁。
- 回调链路保持原有 Redis 锁 + 条件流转双重防重,不改变现有时序。
## 实现要点
- `internal/service/order.go`:`RefundApply` 改用新的原子 DAO 方法,成功后返回 `dto.RefundResponse`(`status=0` 待处理,订单状态由前端 `GET /order` 轮询);`RefundNotify` 失败分支新增订单回退 6→2(条件更新,失败仅记日志不回滚已更新的流水状态,保证幂等)。
- 日志沿用 `slog.InfoContext/WarnContext/ErrorContext` 与关键字段(`order_no`/`user_id`/`out_refund_no`/`from_status`/`to_status`),不打印敏感信息。
- 兼容性:`executeGrant` 已按 `order_status != 2` 跳过发放,状态 6 自动阻止继续发权益,无需改动;`OrderExpireByTime`(1→4)、`OrderListVirtualUnconfirmed`(IN 1,4)、`OrderListPaidMissingBenefitTasks`(=2)、`Cancel`(仅待支付)均不受影响。
- 新增迁移只改列注释(`ALTER TABLE orders MODIFY COLUMN order_status ...`),不涉及数据回填;存量已进入退款流程但订单仍为 2 的老数据,可后续人工订正,不在本次自动改写范围。
## 架构设计
沿用现有分层:`routers → service → mysqldao → mysqlmodel`,不新增架构模式。
```mermaid
flowchart LR
A[POST /order/refund] --> B[OrderService.RefundApply]
B --> C[RefundFlowLogDao.RefundApplyWithOrderStatus 事务<br/>锁订单行 + 2→6 + 写/复用流水]
C --> D[(orders.order_status=6)]
D --> E{微信退款回调}
E -->|SUCCESS| F[RefundSettleWithRevokeTask<br/>6→5 + 取消发放 + 建回收任务]
E -->|非 SUCCESS| G[流水置失败 + 订单回退 6→2]
```
## 目录结构
```
zt-closet-backend/
├── internal/
│ ├── service/
│ │ ├── constants.go # [MODIFY] 新增 OrderStatusRefunding int8 = 6「申请退款中」;补充退款状态注释
│ │ ├── order.go # [MODIFY] RefundApply 改为调用原子受理 DAO 方法并修正并发注释;RefundNotify 失败分支回退订单 6→2
│ │ └── virtualpay.go # [MODIFY] handleRefundNotify:非 SUCCESS 时回退订单 6→2 并尽力置流水失败;SUCCESS 时尽力将已存在的流水置成功(保持与订单终态一致)
│ ├── mysqldao/
│ │ ├── order.go # [MODIFY] RefundFlowLogDao 新增 RefundApplyWithOrderStatus:事务内 SELECT ... FOR UPDATE 锁订单、校验 2 已支付、条件流转 2→6、新建或复用(上次失败)退款流水;返回受理结果区分「不可退款/并发冲突/DB 错误」
│ │ └── benefit_revoke.go # [MODIFY] RefundSettleWithRevokeTask:前置状态放行 6;状态为 2 或 6 时创建 benefit_revoke_tasks 回收任务
│ ├── dto/
│ │ └── order.go # [MODIFY] OrderResponse.OrderStatus 注释补 6;OrderListRequest.Status 校验改为 oneof=1 2 3 4 5 6
│ └── mysqlmodel/
│ └── order.go # [MODIFY] order_status 字段注释补 6=申请退款中
├── pkg/migration/
│ ├── 000001_init.up.sql # [MODIFY] orders.order_status 列注释补 6=申请退款中(新库一致)
│ ├── 000002_order_status_refunding.up.sql # [NEW] ALTER TABLE orders MODIFY COLUMN order_status 更新列注释(存量库)
│ └── 000002_order_status_refunding.down.sql # [NEW] 回滚列注释为旧文案
├── internal/mysqldao/
│ └── order_refund_test.go # [NEW] sqlmock 单测:受理成功 2→6 且流水落库;订单非已支付时拒绝且无写入;退款成功 6→5 且创建回收任务;失败回退 6→2 条件流转(复用 hardening_test.go / order_quota_test.go 的 setup 范式)
└── 接口文档.md # [MODIFY] order_status 枚举补 6(字段表、5.12 模块、错误码章节附近说明)、状态机图与退款流程说明、前端轮询表与 FAQ(申请后订单=6 申请退款中、失败回退 2)
```
## 关键代码结构(示意,接口级)
```
// internal/service/constants.go
const (
// ...现有 1~5 不变
OrderStatusRefunding int8 = 6 // 申请退款中(已受理退款申请,待运营审核/退款)
)
// internal/mysqldao/order.go
// RefundApplyWithOrderStatus 原子受理退款申请:锁定订单行并校验「2 已支付」,
// 流转为「6 申请退款中」,同时新建退款流水或复用上次失败的流水。
// 返回 allowed=false 表示订单状态不满足或并发冲突(映射 errcode.RefundNotAllowed)。
func (d *RefundFlowLogDao) RefundApplyWithOrderStatus(
ctx context.Context, orderNo, refundNo, reason string, applyTime int64,
) (flow *mysqlmodel.RefundFlowLog, allowed bool, err error)
```
## Agent Extensions
### Skill
- **lsp-code-analysis**
- Purpose: 在改动前对 `OrderStatusPaid`/`OrderStatusRefunded`/`order_status` 做引用与影响面分析,确认所有消费订单状态的分支(含 DAO 查询、补偿任务、DTO 校验)都已覆盖状态 6
- Expected outcome: 输出完整的引用清单与需同步修改的位置,避免遗漏消费点导致状态机断裂
+13 -8
View File
@@ -337,7 +337,10 @@ stateDiagram-v2
pending --> expired : 超时 15 分钟(后台每分钟扫描) pending --> expired : 超时 15 分钟(后台每分钟扫描)
pending --> cancelled : 用户主动取消(同步关闭微信订单) pending --> cancelled : 用户主动取消(同步关闭微信订单)
expired --> paid : 过期后付款到账(补发货) expired --> paid : 过期后付款到账(补发货)
cancelled --> refunded : 已付款的取消订单自动发起退款 cancelled --> refunded : 已付款的取消订单生成待审核退款单(管理员手动退款)
paid --> refunding : 用户申请退款(同事务写待审核退款单)
refunding --> refunded : 退款成功回调(回收权益 → 通知)
refunding --> paid : 退款失败/被驳回(流水置失败,可重新申请)
paid --> refunded : 退款成功回调(回收权益 → 通知) paid --> refunded : 退款成功回调(回收权益 → 通知)
state "1 待支付" as pending state "1 待支付" as pending
@@ -345,6 +348,7 @@ stateDiagram-v2
state "3 已取消" as cancelled state "3 已取消" as cancelled
state "4 已过期" as expired state "4 已过期" as expired
state "5 已退款" as refunded state "5 已退款" as refunded
state "6 申请退款中" as refunding
``` ```
处理流程: 处理流程:
@@ -352,11 +356,11 @@ stateDiagram-v2
1. **商品**:5 类商品(`1` 会员包 / `2` 上传包 / `3` AI 推荐包 / `4` 衣柜分享包 / `5` 搭配分享包),价格单位为**分**;仅 `1 上架` 状态可购买。 1. **商品**:5 类商品(`1` 会员包 / `2` 上传包 / `3` AI 推荐包 / `4` 衣柜分享包 / `5` 搭配分享包),价格单位为**分**;仅 `1 上架` 状态可购买。
2. **下单**:实际支付金额取 `discount_price`;下单时把商品权益快照(会员天数/各类额度)冗余进订单,防止商品后续变更导致错发;先落本地待支付订单再调微信 JSAPI,微信下单失败时订单保留并靠超时自动过期。同一用户可并存多个待支付订单(无防重限制)。 2. **下单**:实际支付金额取 `discount_price`;下单时把商品权益快照(会员天数/各类额度)冗余进订单,防止商品后续变更导致错发;先落本地待支付订单再调微信 JSAPI,微信下单失败时订单保留并靠超时自动过期。同一用户可并存多个待支付订单(无防重限制)。
3. **支付回调**:验签 + 解密 → 仅处理 `SUCCESS` → 幂等锁 30s → **金额校验**(回调金额与订单不一致则拒绝流转、人工介入)→ 待支付 → 已支付 → 发放权益 + 写支付信息 + 发订单通知。竞态兜底:已过期但已付款的订单补发货;已取消但已付款的订单生成**待审核退款单**,由管理员手动退款。 3. **支付回调**:验签 + 解密 → 仅处理 `SUCCESS` → 幂等锁 30s → **金额校验**(回调金额与订单不一致则拒绝流转、人工介入)→ 待支付 → 已支付 → 发放权益 + 写支付信息 + 发订单通知。竞态兜底:已过期但已付款的订单补发货;已取消但已付款的订单生成**待审核退款单**,由管理员手动退款。
4. **权益发放**:会员天数实际发放(叠加规则 `vip_expire = max(vip_expire, now) + 天数×86400`,续费顺延,会员类型按**续费后最终到期时长**推导——剩余 ≥365 天为年度会员、否则月度会员,避免年度会员续月包被降级);Cos / AI / 分享额度改为写入**额度凭证** `quota_grants`——会员包 `expire_time=0`(跟随 VIP)、加量包按 `goods.valid_days` 独立到期,幂等由 `(ref_id, quota_type)` 唯一索引保证;补偿任务 `order_no + benefit_type` 唯一保证不重发。 4. **权益发放**:会员天数实际发放(叠加规则 `vip_expire = max(vip_expire, now) + 天数×86400`,续费顺延,会员类型按**续费后最终到期时长**推导——剩余 ≥365 天为年度会员、否则月度会员,避免年度会员续月包被降级);Cos / AI / 分享额度改为写入**额度凭证** `quota_grants`——会员包 `expire_time=0`(跟随 VIP)、加量包按 `goods.valid_days` 独立到期,幂等由 `(ref_id, quota_type)` 唯一索引保证;补偿任务 `order_no + benefit_type` 唯一保证不重发。**会员包来源(source=1)额度不叠加**:月度包/年度包同时持有时同类额度只取 amount 最大的一份(用尽后自动顺延下一份),避免额度翻倍。
5. **超时过期**:后台任务**每分钟**扫描 `待支付且 expire_time < now` 的订单批量置为已过期(条件更新天然幂等);过期后无权益可回收。 5. **超时过期**:后台任务**每分钟**扫描 `待支付且 expire_time < now` 的订单批量置为已过期(条件更新天然幂等);过期后无权益可回收。
6. **退款**:**所有退款均需管理员操作**——用户申请退款仅登记待审核退款单(`0 待处理`),运营审核后在微信后台手动退款,退款回调更新状态(`0 待处理 → 1 退款中 → 2 成功 / 3 失败`);「已取消但已付款」的订单同样生成待审核退款单而非自动退款。退款成功回调将订单置已退款、**回收权益**(会员天数不回退到过去;赠送额度通过失效凭证回收未用部分,已用不追回;已取消订单权益从未发放,跳过回收)并发通知。 6. **退款**:**所有退款均需管理员操作**——用户申请退款受理成功后将订单置为 `6 申请退款中` 并登记待审核退款单(流水 `0 待处理`,同一事务 + 订单行锁,重复/并发申请仅受理一次),运营审核后在微信后台手动退款,退款回调更新流水状态(`0 待处理 → 2 成功 / 3 失败`);退款成功 → 订单 `6 → 5`,退款失败 → 订单回退 `2 已支付`(可重新申请)。**虚拟支付退款推送(`xpay_refund_notify`)按官方字段解析**:订单号取 `MchOrderId`、结果取 `RetCode`(0=成功,失败原因见 `RetMsg`),关键字段缺失或结果无法判定时**应答失败**触发微信重试(最多 15 次)而非静默成功;成功后仍反查 `query_order` 确认真实退款与金额一致。**查单兜底任务**每 60 秒反查停留超 10 分钟的「申请退款中」订单:微信侧已退款自动置 `5` 并回收权益,仍未退款则保持状态、超 24 小时告警人工。「已取消但已付款」的订单同样生成待审核退款单而非自动退款,订单保持 `3 已取消`。退款成功回调将订单置已退款、**回收权益**(会员天数不回退到过去;赠送额度通过失效凭证回收未用部分,已用不追回;已取消订单权益从未发放,跳过回收)并发通知。
7. **发放失败补偿**:权益发放失败自动落 pending 任务,后台任务**每分钟**扫描重试(Redis 锁保证单实例),每轮最多 100 个,重试 3 次仍失败则标记失败 + 告警人工介入;重试前先查发放流水幂等。 7. **发放失败补偿**:权益发放失败自动落 pending 任务,后台任务**每分钟**扫描重试(Redis 锁保证单实例),每轮最多 100 个,重试 3 次仍失败则标记失败 + 告警人工介入;重试前先查发放流水幂等。
8. **额度变更统一收口**:Cos 永久额度扣减/回退封装在事务内「凭证扣减(先到期先用)+ 免费额度扣减 + 写流水」强一致;每日额度(AI/分享/衣柜分享)由 Redis 原子限流计数(当天 0 点自然过期),MySQL `user_daily_usages` 兜底统计。 8. **额度变更统一收口**:Cos 永久额度扣减/回退封装在事务内「凭证扣减(非会员包先到期先用 → 会员包生效份)+ 免费额度扣减 + 写流水」强一致(会员包来源不叠加,未生效份不参与扣减/回退);每日额度(AI/分享/衣柜分享)由 Redis 原子限流计数(当天 0 点自然过期),MySQL `user_daily_usages` 兜底统计。
### 6. AI 推荐 ### 6. AI 推荐
@@ -641,7 +645,7 @@ flowchart TD
| goods_name | string | 商品名称 | | goods_name | string | 商品名称 |
| unit_price | int64 | 单价(分) | | unit_price | int64 | 单价(分) |
| pay_amount | int64 | 实付金额(分) | | pay_amount | int64 | 实付金额(分) |
| order_status | int | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 | | order_status | int | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中 |
| member_days | int64 | 会员天数 | | member_days | int64 | 会员天数 |
| expire_time | int64 | 支付截止时间戳(秒) | | expire_time | int64 | 支付截止时间戳(秒) |
| pay_time | int64 | 支付时间戳(秒) | | pay_time | int64 | 支付时间戳(秒) |
@@ -1152,7 +1156,7 @@ multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),
#### 10. 订单模块 #### 10. 订单模块
订单状态枚举:`order_status` 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款;退款状态 `status` 0=待处理 1=退款中 2=退款成功 3=退款失败。 订单状态枚举:`order_status` 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中;退款状态 `status` 0=待处理 1=退款中 2=退款成功 3=退款失败。
**POST `/order` —— 创建订单并发起微信支付(需鉴权)** **POST `/order` —— 创建订单并发起微信支付(需鉴权)**
@@ -1180,7 +1184,7 @@ multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| page / page_size | int | 否 | 分页 | | page / page_size | int | 否 | 分页 |
| status | int | 否 | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款;不传 = 全部 | | status | int | 否 | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中;不传 = 全部 |
响应 `data.list` 元素 = 订单对象(见公共结构)。 响应 `data.list` 元素 = 订单对象(见公共结构)。
@@ -1430,7 +1434,7 @@ sequenceDiagram
2. 前端用响应中的 `pay_params` 调 `wx.requestPayment`;`expire_time` 用于支付倒计时(15 分钟)。 2. 前端用响应中的 `pay_params` 调 `wx.requestPayment`;`expire_time` 用于支付倒计时(15 分钟)。
3. 支付结果以微信回调为准(公开路由,服务端验签 + 解密);前端支付完成后应通过 `GET /order` 确认 `order_status=2`,不要自行判定支付成功。 3. 支付结果以微信回调为准(公开路由,服务端验签 + 解密);前端支付完成后应通过 `GET /order` 确认 `order_status=2`,不要自行判定支付成功。
4. 超时未支付订单由后台任务每分钟扫描并自动置为已过期(4)。 4. 超时未支付订单由后台任务每分钟扫描并自动置为已过期(4)。
5. 退款:`POST /order/refund` 申请 → 微信异步处理 → 退款回调更新退款状态;可通过订单列表查看 `refund_time` / `refund_amount`。 5. 退款:`POST /order/refund` 申请成功后订单置 `6 申请退款中`(人工审核,非实时到账)→ 运营在微信后台手动退款 → 退款回调将订单置 `5 已退款`(失败则回退 `2 已支付`,可重新申请);虚拟支付退款推送字段为 `MchOrderId`/`MchRefundId`/`RetCode`(0=成功),推送丢失时由**查单兜底任务**自动订正;可通过订单列表查看 `refund_time` / `refund_amount`。
#### 流程 5:点赞 / 收藏切换 #### 流程 5:点赞 / 收藏切换
@@ -1600,6 +1604,7 @@ curl -X POST http://localhost:80/api/v1/ai-recommend \
| 每日运势生成 | 每日 0 点 + 启动兜底 | 大模型生成十二星座运势;Redis 分布式锁保证多实例仅执行一次;当日不足 12 条时按需兜底生成 | | 每日运势生成 | 每日 0 点 + 启动兜底 | 大模型生成十二星座运势;Redis 分布式锁保证多实例仅执行一次;当日不足 12 条时按需兜底生成 |
| 订单超时过期 | 每 60 秒 | 将超过 15 分钟未支付的订单批量置为"已过期" | | 订单超时过期 | 每 60 秒 | 将超过 15 分钟未支付的订单批量置为"已过期" |
| 权益发放补偿 | 每 60 秒 | 重试发放失败的权益任务(恢复僵死任务、幂等防重复发放、超限告警) | | 权益发放补偿 | 每 60 秒 | 重试发放失败的权益任务(恢复僵死任务、幂等防重复发放、超限告警) |
| 虚拟支付退款查单兜底 | 每 60 秒 | 反查停留超 10 分钟的「申请退款中」虚拟支付订单:已退款自动置「已退款」并回收权益,仍未退款保持状态、超 24 小时告警人工 |
## 部署提示 ## 部署提示
+1 -1
View File
@@ -133,7 +133,7 @@
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统色:警示橙 #FF9500、成功绿 #34C759、系统灰 #8E8E93、系统蓝 #007AFF,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(订单 = doc.text),金额「¥ + 两位小数」等宽数字。本条为第 12 屏,请生成本页画板,并一并生成「微信支付面板」「申请退款面板」与空态。 【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统色:警示橙 #FF9500、成功绿 #34C759、系统灰 #8E8E93、系统蓝 #007AFF,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(订单 = doc.text),金额「¥ + 两位小数」等宽数字。本条为第 12 屏,请生成本页画板,并一并生成「微信支付面板」「申请退款面板」与空态。
顶部导航标题「我的订单」,下方为状态筛选标签:全部、待支付、已支付、已取消、已过期、已退款。列表项为白卡:第一行左侧为订单号尾部「订单 …8b」灰色小字,右侧为状态徽标——待支付(警示橙)、已支付(成功绿)、已取消(浅灰)、已过期(系统灰)、已退款(系统蓝);第二行为商品名「月度会员」+ 权益灰字「30 天会员」;第三行左侧为实付金额「实付 ¥19.00」,右侧为操作按钮:待支付显示倒计时灰字「剩余 14:32」+ 描边按钮「取消订单」+ 系统蓝按钮「去支付」;已支付显示描边按钮「申请退款」;其余状态无按钮。点击「去支付」从底部弹起微信支付面板(金额「¥19.00」+ 商品名 + 绿色「确认支付」按钮)。申请退款弹出面板:退款原因输入框(占位「请填写退款原因」,上限 200 字)+ 提交按钮,提交后 轻提示「退款申请已提交,等待审核」,该订单出现「退款中」提示。空态:票据线稿插画 +「还没有订单」+ 描边按钮「去看看额度包」。 顶部导航标题「我的订单」,下方为状态筛选标签:全部、待支付、已支付、申请退款中、已取消、已过期、已退款。列表项为白卡:第一行左侧为订单号尾部「订单 …8b」灰色小字,右侧为状态徽标——待支付(警示橙)、已支付(成功绿)、申请退款中(警示橙)、已取消(浅灰)、已过期(系统灰)、已退款(系统蓝);第二行为商品名「月度会员」+ 权益灰字「30 天会员」;第三行左侧为实付金额「实付 ¥19.00」,右侧为操作按钮:待支付显示倒计时灰字「剩余 14:32」+ 描边按钮「取消订单」+ 系统蓝按钮「去支付」;已支付显示描边按钮「申请退款」;其余状态无按钮。点击「去支付」从底部弹起微信支付面板(金额「¥19.00」+ 商品名 + 绿色「确认支付」按钮)。申请退款弹出面板:退款原因输入框(占位「请填写退款原因」,上限 200 字)+ 提交按钮,提交后 轻提示「退款申请已提交,等待审核」,订单状态变为「申请退款中」(前端轮询 `GET /order`:`6` 继续等待、`5` 已退款为终态、回到 `2` 表示退款失败可重新申请)。空态:票据线稿插画 +「还没有订单」+ 描边按钮「去看看额度包」。
--- ---
+2 -2
View File
@@ -45,7 +45,7 @@ type OrderResponse struct {
UnitPrice int64 `json:"unit_price"` // 单价(分) UnitPrice int64 `json:"unit_price"` // 单价(分)
PayAmount int64 `json:"pay_amount"` // 实付(分) PayAmount int64 `json:"pay_amount"` // 实付(分)
PayChannel int8 `json:"pay_channel"` // 支付渠道 1=微信支付 2=虚拟支付 PayChannel int8 `json:"pay_channel"` // 支付渠道 1=微信支付 2=虚拟支付
OrderStatus int8 `json:"order_status"` // 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 OrderStatus int8 `json:"order_status"` // 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中
MemberDays int64 `json:"member_days"` MemberDays int64 `json:"member_days"`
ExpireTime int64 `json:"expire_time"` ExpireTime int64 `json:"expire_time"`
PayTime int64 `json:"pay_time"` PayTime int64 `json:"pay_time"`
@@ -57,7 +57,7 @@ type OrderResponse struct {
// OrderListRequest 订单列表 // OrderListRequest 订单列表
type OrderListRequest struct { type OrderListRequest struct {
PageRequest PageRequest
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4 5"` Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4 5 6"`
} }
// OrderListResponse 订单列表 // OrderListResponse 订单列表
+4 -2
View File
@@ -36,6 +36,7 @@ func (d *BenefitRevokeTaskDao) RecoverStale(ctx context.Context, before int64) e
} }
// RefundSettleWithRevokeTask 原子写入退款订单终态、取消未执行发放并创建回收任务。 // RefundSettleWithRevokeTask 原子写入退款订单终态、取消未执行发放并创建回收任务。
// 前置状态:2=已支付(正常退款)、3=已取消但已付款(竞态兜底)、6=申请退款中(用户已提交退款申请)。
func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, orderNo string, refundAmount, refundTime int64) (bool, error) { func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, orderNo string, refundAmount, refundTime int64) (bool, error) {
settled := false settled := false
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@@ -46,7 +47,7 @@ func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, o
if order.OrderStatus == 5 { if order.OrderStatus == 5 {
return nil return nil
} }
if order.OrderStatus != 2 && order.OrderStatus != 3 { if order.OrderStatus != 2 && order.OrderStatus != 3 && order.OrderStatus != 6 {
return nil return nil
} }
if err := tx.Model(&mysqlmodel.Order{}).Where("id = ?", order.ID).Updates(map[string]any{"order_status": 5, "refund_time": refundTime, "refund_amount": refundAmount}).Error; err != nil { if err := tx.Model(&mysqlmodel.Order{}).Where("id = ?", order.ID).Updates(map[string]any{"order_status": 5, "refund_time": refundTime, "refund_amount": refundAmount}).Error; err != nil {
@@ -55,7 +56,8 @@ func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, o
if err := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("order_no = ? AND status IN ?", orderNo, []int8{0, 2}).Update("status", 4).Error; err != nil { if err := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("order_no = ? AND status IN ?", orderNo, []int8{0, 2}).Update("status", 4).Error; err != nil {
return err return err
} }
if order.OrderStatus == 2 { // 2 / 6 表示权益已按订单发放过,需创建回收任务;3 从未发放,跳过避免误扣其它来源的会员天数/额度。
if order.OrderStatus == 2 || order.OrderStatus == 6 {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&mysqlmodel.BenefitRevokeTask{OrderNo: orderNo, UserID: order.UserID, MaxRetry: 10}).Error; err != nil { if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&mysqlmodel.BenefitRevokeTask{OrderNo: orderNo, UserID: order.UserID, MaxRetry: 10}).Error; err != nil {
return err return err
} }
+104 -5
View File
@@ -2,10 +2,12 @@ package mysqldao
import ( import (
"context" "context"
"errors"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// OrderDao 订单数据访问 // OrderDao 订单数据访问
@@ -91,15 +93,34 @@ func (d *OrderDao) OrderListVirtualUnconfirmed(ctx context.Context, since, until
return list, nil return list, nil
} }
// OrderListVirtualRefunding 查询停留在「申请退款中」的虚拟支付订单(updated_at 早于水位),
// 供退款结果查单兜底任务反查微信侧真实退款结果:推送丢失/未识别时避免订单永久卡单。
// 状态字面量:2=虚拟支付 6=申请退款中(与 service 层 PayChannelVirtual/OrderStatusRefunding 一致)。
func (d *OrderDao) OrderListVirtualRefunding(ctx context.Context, beforeUpdatedAt int64, limit int) ([]mysqlmodel.Order, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderListVirtualRefunding")
defer span.End()
var list []mysqlmodel.Order
if err := d.db.WithContext(ctx).
Where("pay_channel = ? AND order_status = ? AND updated_at < ?", 2, 6, beforeUpdatedAt).
Order("id ASC").Limit(limit).Find(&list).Error; err != nil {
span.RecordError(err)
return nil, err
}
return list, nil
}
// OrderListPaidMissingBenefitTasks 查询存在未建权益任务的已支付订单,修复支付回调与任务落库之间的崩溃窗口。 // OrderListPaidMissingBenefitTasks 查询存在未建权益任务的已支付订单,修复支付回调与任务落库之间的崩溃窗口。
// 除「任务不存在」外,也把「任务被取消(status=4)」视为待修复:订单曾进入退款流程时任务被取消,
// 退款失败回退为已支付后需要重新发放,避免「已付款却无权益」(已退款订单状态为 5,不会被本查询命中)。
func (d *OrderDao) OrderListPaidMissingBenefitTasks(ctx context.Context, limit int) ([]mysqlmodel.Order, error) { func (d *OrderDao) OrderListPaidMissingBenefitTasks(ctx context.Context, limit int) ([]mysqlmodel.Order, error) {
var list []mysqlmodel.Order var list []mysqlmodel.Order
err := d.db.WithContext(ctx).Where("order_status = ?", 2). err := d.db.WithContext(ctx).Where("order_status = ?", 2).
Where(`(member_days > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=1)) OR Where(`(member_days > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=1 AND t.status<>4)) OR
(cos_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=2)) OR (cos_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=2 AND t.status<>4)) OR
(ai_recommend_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=3)) OR (ai_recommend_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=3 AND t.status<>4)) OR
(closet_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=4)) OR (closet_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=4 AND t.status<>4)) OR
(outfit_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=5))`). (outfit_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=5 AND t.status<>4))`).
Order("id ASC").Limit(limit).Find(&list).Error Order("id ASC").Limit(limit).Find(&list).Error
return list, err return list, err
} }
@@ -162,6 +183,84 @@ func NewRefundFlowLogDao() *RefundFlowLogDao {
return &RefundFlowLogDao{db: global.MysqlEngine} return &RefundFlowLogDao{db: global.MysqlEngine}
} }
// RefundApplyWithOrderStatus 原子受理退款申请:锁定订单行并校验「2 已支付」,
// 将订单流转为「6 申请退款中」,同时新建退款流水或复用上次失败的流水(换新退款单号并重置为待处理)。
// allowed=false 表示订单状态不满足(非已支付/已被并发受理)或已有进行中的退款流水,
// 此时不做任何写入,调用方应返回「当前状态不允许退款」。
// 状态字面量:0=待处理(流水) 2=已支付 3=退款失败(流水) 6=申请退款中(与 service 层常量一致)。
func (d *RefundFlowLogDao) RefundApplyWithOrderStatus(ctx context.Context, orderNo, refundNo, reason string, applyTime int64) (*mysqlmodel.RefundFlowLog, bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundApplyWithOrderStatus")
defer span.End()
var flow *mysqlmodel.RefundFlowLog
allowed := false
// 订单行锁串行化同一订单的并发申请:仅首个请求能把 2 流转为 6,其余请求读到 6 后直接拒绝。
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order mysqlmodel.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
return err
}
if order.OrderStatus != 2 {
return nil
}
var existing mysqlmodel.RefundFlowLog
gerr := tx.Where("order_no = ?", orderNo).First(&existing).Error
switch {
case gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound):
return gerr
case gerr == nil && existing.Status != 3:
// 已有待处理 / 退款中 / 已成功的退款流水:不允许重复申请。
return nil
case gerr == nil:
// 失败重试:复用旧记录,换新退款单号并重置为待处理。
if err := tx.Model(&mysqlmodel.RefundFlowLog{}).Where("id = ?", existing.ID).Updates(map[string]any{
"refund_no": refundNo,
"refund_amount": order.PayAmount,
"refund_reason": reason,
"status": 0,
"apply_time": applyTime,
"fail_reason": "",
}).Error; err != nil {
return err
}
existing.RefundNo = refundNo
existing.RefundAmount = order.PayAmount
existing.RefundReason = reason
existing.Status = 0
existing.ApplyTime = applyTime
existing.FailReason = ""
flow = &existing
default:
created := &mysqlmodel.RefundFlowLog{
OrderID: order.ID,
OrderNo: order.OrderNo,
UserID: order.UserID,
RefundNo: refundNo,
RefundAmount: order.PayAmount,
RefundReason: reason,
Status: 0,
ApplyTime: applyTime,
}
if err := tx.Create(created).Error; err != nil {
return err
}
flow = created
}
if err := tx.Model(&mysqlmodel.Order{}).Where("id = ?", order.ID).Update("order_status", 6).Error; err != nil {
return err
}
allowed = true
return nil
})
if err != nil {
span.RecordError(err)
return nil, false, err
}
return flow, allowed, nil
}
// RefundFlowLogCreate 创建退款流水 // RefundFlowLogCreate 创建退款流水
func (d *RefundFlowLogDao) RefundFlowLogCreate(ctx context.Context, log *mysqlmodel.RefundFlowLog) error { func (d *RefundFlowLogDao) RefundFlowLogCreate(ctx context.Context, log *mysqlmodel.RefundFlowLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogCreate") ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogCreate")
+225
View File
@@ -0,0 +1,225 @@
package mysqldao
import (
"context"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
// TestRefundApplyWithOrderStatusCreatesFlowAndMarksRefunding 受理成功:订单 2 已支付 -> 6 申请退款中,并新建退款流水。
func TestRefundApplyWithOrderStatusCreatesFlowAndMarksRefunding(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &RefundFlowLogDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount"}).
AddRow(8, "ORDER-1", 7, 2, 990))
mock.ExpectQuery("SELECT .* FROM `refund_flow_logs`").
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec("INSERT INTO `refund_flow_logs`").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
flow, allowed, err := dao.RefundApplyWithOrderStatus(context.Background(), "ORDER-1", "RF-NEW", "不想要了", 1234)
if err != nil || !allowed {
t.Fatalf("allowed=%v err=%v", allowed, err)
}
if flow == nil || flow.RefundNo != "RF-NEW" || flow.Status != 0 || flow.RefundAmount != 990 || flow.UserID != 7 {
t.Fatalf("unexpected flow: %+v", flow)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyWithOrderStatusRejectsNonPaidOrder 订单已处于申请退款中(6)时拒绝受理且不写任何数据。
func TestRefundApplyWithOrderStatusRejectsNonPaidOrder(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &RefundFlowLogDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, 6))
mock.ExpectCommit()
flow, allowed, err := dao.RefundApplyWithOrderStatus(context.Background(), "ORDER-1", "RF-NEW", "重复申请", 1234)
if err != nil || allowed || flow != nil {
t.Fatalf("allowed=%v flow=%v err=%v", allowed, flow, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyWithOrderStatusReusesFailedFlow 上次退款失败:复用旧流水并换新退款单号、重置为待处理。
func TestRefundApplyWithOrderStatusReusesFailedFlow(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &RefundFlowLogDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount"}).
AddRow(8, "ORDER-1", 7, 2, 990))
mock.ExpectQuery("SELECT .* FROM `refund_flow_logs`").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "refund_no", "status"}).
AddRow(5, "ORDER-1", 7, "RF-OLD", 3))
mock.ExpectExec("UPDATE `refund_flow_logs` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
flow, allowed, err := dao.RefundApplyWithOrderStatus(context.Background(), "ORDER-1", "RF-NEW", "再次申请", 2222)
if err != nil || !allowed {
t.Fatalf("allowed=%v err=%v", allowed, err)
}
if flow == nil || flow.ID != 5 || flow.RefundNo != "RF-NEW" || flow.Status != 0 || flow.ApplyTime != 2222 {
t.Fatalf("unexpected flow: %+v", flow)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyWithOrderStatusRejectsInProgressFlow 已有待处理/退款中/成功的流水时拒绝重复申请。
func TestRefundApplyWithOrderStatusRejectsInProgressFlow(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &RefundFlowLogDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, 2))
mock.ExpectQuery("SELECT .* FROM `refund_flow_logs`").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "refund_no", "status"}).
AddRow(5, "ORDER-1", "RF-OLD", 0))
mock.ExpectCommit()
flow, allowed, err := dao.RefundApplyWithOrderStatus(context.Background(), "ORDER-1", "RF-NEW", "重复申请", 1234)
if err != nil || allowed || flow != nil {
t.Fatalf("allowed=%v flow=%v err=%v", allowed, flow, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestOrderStatusRevertRefundingToPaid 退款失败回退依赖的条件流转:6 -> 2 仅在订单仍为 6 时生效。
func TestOrderStatusRevertRefundingToPaid(t *testing.T) {
for _, tc := range []struct {
name string
rows int64
want bool
}{
{name: "still_refunding", rows: 1, want: true},
{name: "already_settled", rows: 0, want: false},
} {
t.Run(tc.name, func(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &OrderDao{db: base.db}
// gorm 默认把单条写语句包在事务里执行。
mock.ExpectBegin()
mock.ExpectExec("UPDATE `orders` SET .* WHERE \\(order_no = \\? AND order_status = \\?\\).*").
WillReturnResult(sqlmock.NewResult(0, tc.rows))
mock.ExpectCommit()
ok, err := dao.OrderUpdateStatusByOrderNo(context.Background(), "ORDER-1", 6, 2)
if err != nil || ok != tc.want {
t.Fatalf("ok=%v err=%v, want %v", ok, err, tc.want)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestOrderListVirtualRefunding 退款查单兜底扫描:仅取「虚拟支付 + 申请退款中 + updated_at 早于水位」的订单。
func TestOrderListVirtualRefunding(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &OrderDao{db: base.db}
mock.ExpectQuery("SELECT .* FROM `orders` WHERE .*pay_channel = \\? AND order_status = \\? AND updated_at < \\?.*").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_channel", "updated_at"}).
AddRow(8, "ORDER-1", 7, 6, 2, 900))
orders, err := dao.OrderListVirtualRefunding(context.Background(), 1000, 50)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(orders) != 1 || orders[0].OrderNo != "ORDER-1" || orders[0].OrderStatus != 6 {
t.Fatalf("unexpected orders: %+v", orders)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundSettlementAcceptsRefundingOrder 退款成功结算放行状态 6,并按「权益是否已发放」决定是否创建回收任务。
func TestRefundSettlementAcceptsRefundingOrder(t *testing.T) {
for _, tc := range []struct {
name string
orderStatus int8
wantRevoke bool
}{
{name: "refunding_creates_revoke_task", orderStatus: 6, wantRevoke: true},
{name: "cancelled_paid_skips_revoke_task", orderStatus: 3, wantRevoke: false},
} {
t.Run(tc.name, func(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &BenefitRevokeTaskDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, tc.orderStatus))
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE `benefit_grant_tasks` SET").WillReturnResult(sqlmock.NewResult(0, 2))
if tc.wantRevoke {
mock.ExpectExec("INSERT INTO `benefit_revoke_tasks`").WillReturnResult(sqlmock.NewResult(3, 1))
}
mock.ExpectCommit()
settled, err := dao.RefundSettleWithRevokeTask(context.Background(), "ORDER-1", 990, 1234)
if err != nil || !settled {
t.Fatalf("settled=%v err=%v", settled, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestRefundSettlementSkipsAlreadyRefunded 已退款订单重复回调直接跳过(幂等,不重复回退权益)。
func TestRefundSettlementSkipsAlreadyRefunded(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &BenefitRevokeTaskDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, 5))
mock.ExpectCommit()
settled, err := dao.RefundSettleWithRevokeTask(context.Background(), "ORDER-1", 990, 1234)
if err != nil || settled {
t.Fatalf("settled=%v err=%v", settled, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundSettlementRejectsExpiredOrder 未支付流转而来的终态(如已过期 4)不允许结算为已退款。
func TestRefundSettlementRejectsExpiredOrder(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &BenefitRevokeTaskDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, 4))
mock.ExpectCommit()
settled, err := dao.RefundSettleWithRevokeTask(context.Background(), "ORDER-1", 990, 1234)
if err != nil || settled {
t.Fatalf("settled=%v err=%v", settled, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ type Order struct {
TotalAmount int64 `gorm:"column:total_amount"` // 订单总金额(分) TotalAmount int64 `gorm:"column:total_amount"` // 订单总金额(分)
PayAmount int64 `gorm:"column:pay_amount"` // 实付金额(分) PayAmount int64 `gorm:"column:pay_amount"` // 实付金额(分)
PayChannel int8 `gorm:"column:pay_channel"` // 支付渠道 1=微信支付 PayChannel int8 `gorm:"column:pay_channel"` // 支付渠道 1=微信支付
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中
TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号 TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号
MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照) MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照)
GoodsType int8 `gorm:"column:goods_type"` // 商品类型(快照) GoodsType int8 `gorm:"column:goods_type"` // 商品类型(快照)
+41
View File
@@ -35,6 +35,47 @@ func VirtualPayUnpaidDel(ctx context.Context, orderNo string) error {
return nil return nil
} }
// VirtualRefundCheckedSetNX 标记某订单的退款结果本轮已完成反查(TTL 内不重复查询),返回是否为首次标记。
func VirtualRefundCheckedSetNX(ctx context.Context, orderNo string, ttl time.Duration) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualRefundCheckedSetNX")
defer span.End()
key := fmt.Sprintf(redismodel.KeyVirtualRefundChecked, orderNo)
ok, err := global.RedisEngine.SetNX(ctx, key, 1, ttl).Result()
if err != nil {
span.RecordError(err)
return false, err
}
return ok, nil
}
// VirtualRefundCheckedDel 撤销「已核对」标记:查单失败时调用,让下一轮立即重试而非等待 TTL 过期。
func VirtualRefundCheckedDel(ctx context.Context, orderNo string) error {
ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualRefundCheckedDel")
defer span.End()
key := fmt.Sprintf(redismodel.KeyVirtualRefundChecked, orderNo)
if err := global.RedisEngine.Del(ctx, key).Err(); err != nil {
span.RecordError(err)
return err
}
return nil
}
// VirtualRefundStuckAlertSetNX 标记某订单的「退款长期未完成」告警已发出(TTL 内不重复告警),返回是否为首次。
func VirtualRefundStuckAlertSetNX(ctx context.Context, orderNo string, ttl time.Duration) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualRefundStuckAlertSetNX")
defer span.End()
key := fmt.Sprintf(redismodel.KeyVirtualRefundStuckAlert, orderNo)
ok, err := global.RedisEngine.SetNX(ctx, key, 1, ttl).Result()
if err != nil {
span.RecordError(err)
return false, err
}
return ok, nil
}
// VirtualPayUnpaidBatchGet 批量判断哪些订单命中负缓存。 // VirtualPayUnpaidBatchGet 批量判断哪些订单命中负缓存。
// 用一次 MGET 代替 N 次 GET,避免逐单往返 Redis 放大单轮耗时。 // 用一次 MGET 代替 N 次 GET,避免逐单往返 Redis 放大单轮耗时。
func VirtualPayUnpaidBatchGet(ctx context.Context, orderNos []string) (map[string]bool, error) { func VirtualPayUnpaidBatchGet(ctx context.Context, orderNos []string) (map[string]bool, error) {
+10 -9
View File
@@ -2,13 +2,14 @@ package redismodel
// 分布式锁与幂等模块:对齐 mysqlmodel 的 Order、AIRecommend、AIHoroscope、审核等业务。 // 分布式锁与幂等模块:对齐 mysqlmodel 的 Order、AIRecommend、AIHoroscope、审核等业务。
const ( const (
KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重 KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重
KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等 KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等
KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_no,退款回调幂等 KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_no,退款回调幂等
KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐活跃位:值为进行中的 task_id,兼作防并发 KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐活跃位:值为进行中的 task_id,兼作防并发
KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发 KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发
KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重 KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重
KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重 KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重
KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s" // outfit_uuid,穿搭公开/取消公开串行化 KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s" // outfit_uuid,穿搭公开/取消公开串行化
KeyLockVirtualConfirm = RedisPrefix + "lock:virtual:confirm" // 虚拟支付查单补偿定时任务防重(无参数,全局单实例) KeyLockVirtualConfirm = RedisPrefix + "lock:virtual:confirm" // 虚拟支付查单补偿定时任务防重(无参数,全局单实例)
KeyLockVirtualRefundConfirm = RedisPrefix + "lock:virtual:refund:confirm" // 虚拟支付退款查单兜底定时任务防重(无参数,全局单实例)
) )
+7
View File
@@ -6,4 +6,11 @@ const (
// 命中表示最近一次 query_order 反查结果为未支付,本轮跳过该订单, // 命中表示最近一次 query_order 反查结果为未支付,本轮跳过该订单,
// 避免大量「下单后不付款」的积压订单长期占满扫描名额,把真正已支付的订单挤出去。 // 避免大量「下单后不付款」的积压订单长期占满扫描名额,把真正已支付的订单挤出去。
KeyVirtualPayUnpaid = RedisPrefix + "xpay:unpaid:%s" KeyVirtualPayUnpaid = RedisPrefix + "xpay:unpaid:%s"
// KeyVirtualRefundChecked 退款查单兜底的「已核对」标记:order_no。
// 命中表示 TTL 内已向微信反查过该订单的退款结果,本轮跳过,避免每轮重复调用 query_order。
KeyVirtualRefundChecked = RedisPrefix + "xpay:refund:checked:%s"
// KeyVirtualRefundStuckAlert 「退款长期未完成」告警去重标记:order_no,TTL 内只告警一次,避免刷屏。
KeyVirtualRefundStuckAlert = RedisPrefix + "xpay:refund:stuck:%s"
) )
+1
View File
@@ -152,6 +152,7 @@ const (
OrderStatusCancelled int8 = 3 // 已取消 OrderStatusCancelled int8 = 3 // 已取消
OrderStatusExpired int8 = 4 // 已过期 OrderStatusExpired int8 = 4 // 已过期
OrderStatusRefunded int8 = 5 // 已退款 OrderStatusRefunded int8 = 5 // 已退款
OrderStatusRefunding int8 = 6 // 申请退款中(已受理退款申请,待运营审核并手动发起退款)
PayChannelWechat int8 = 1 // 微信支付(普通 JSAPI) PayChannelWechat int8 = 1 // 微信支付(普通 JSAPI)
PayChannelVirtual int8 = 2 // 微信虚拟支付(XPay) PayChannelVirtual int8 = 2 // 微信虚拟支付(XPay)
+193
View File
@@ -0,0 +1,193 @@
package service
import (
"context"
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/gorm"
)
// setupQuotaTest 固定免费额度配置为内置默认值(Cos 20 / AI 3 / 分享 10 / 衣柜 5),保证断言确定。
func setupQuotaTest(t *testing.T) sqlmock.Sqlmock {
t.Helper()
old := global.UserDefaultQuotaSetting
global.UserDefaultQuotaSetting = nil
t.Cleanup(func() { global.UserDefaultQuotaSetting = old })
return setupOrderQuotaTest(t)
}
func TestEffectiveQuotaMemberGrantsNotStacked(t *testing.T) {
mock := setupQuotaTest(t)
// Cos 永久额度:免费 20 已用 0;会员包同额两份只计一份,加量包/体验额度仍叠加。
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
grants := []mysqlmodel.QuotaGrant{
memberGrantFixture(1, 100, 10), // 月度包:生效份(同额时保留正在使用的一份)
memberGrantFixture(2, 100, 0), // 年度包:不叠加,不计入
otherGrantFixture(3, QuotaGrantSourceAddon, 5, 2), // 加量包:叠加
otherGrantFixture(4, QuotaGrantSourceTrial, 7, 0), // 体验额度:叠加
}
eff := effectiveQuotaFromGrants(context.Background(), 7, QuotaTypeCos, grants)
// 免费 20 + 会员包 100 + 加量包 5 + 体验 7 = 132;已用 0 + 10 + 2 + 0 = 12。
if eff.Total != 132 || eff.Used != 12 || eff.Remain != 120 {
t.Fatalf("cos quota = %+v, want total=132 used=12 remain=120", eff)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestEffectiveQuotaDailyGrantsNotStacked(t *testing.T) {
mock := setupQuotaTest(t)
// AI 每日额度:免费 3,当日已用 1,会员包同额两份只计一份,广告激励仍叠加。
mock.ExpectQuery("SELECT .* FROM `user_daily_usages`").WillReturnRows(sqlmock.NewRows([]string{"ai_recommend_count"}).AddRow(1))
grants := []mysqlmodel.QuotaGrant{
memberGrantFixture(1, 50, 0),
memberGrantFixture(2, 50, 0),
otherGrantFixture(3, QuotaGrantSourceAdReward, 10, 0),
}
eff := effectiveQuotaFromGrants(context.Background(), 7, QuotaTypeAI, grants)
// 免费 3 + 会员包 50 + 广告 10 = 63;当日已用 1。
if eff.Total != 63 || eff.Used != 1 || eff.Remain != 62 {
t.Fatalf("ai quota = %+v, want total=63 used=1 remain=62", eff)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestConsumeCosQuotaOnlyConsumesEffectiveMemberGrant(t *testing.T) {
mock := setupQuotaTest(t)
future := nowUnix() + 86400
// 有效凭证:加量包(先到期先用)→ 月度包(生效份)→ 年度包(未生效,不得扣减)。
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).
AddRow(3, QuotaGrantSourceAddon, 10, 0, future).
AddRow(1, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 加量包 +10
mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 月度包 +5(年度包不参与扣减)
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).
AddRow(3, QuotaGrantSourceAddon, 10, 10, future).
AddRow(1, QuotaGrantSourceMember, 100, 35, 0).
AddRow(2, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(9, 1))
mock.ExpectCommit()
if err := consumeCosQuota(context.Background(), 7, 15, 123, "上传衣物"); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRefundCosQuotaOnlyRefundsEffectiveMemberGrant(t *testing.T) {
mock := setupQuotaTest(t)
mock.ExpectBegin()
// 免费额度已用 5,不足整体回退 10。
mock.ExpectExec("UPDATE .*`user_quotas`.*").WillReturnResult(sqlmock.NewResult(0, 0))
// 非会员包凭证无已用。
mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}))
// 会员包凭证:月度包为生效份(used=30),年度包未生效。
mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 月度包 -10
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(5))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaGrantSourceMember, 100, 20, 0).
AddRow(2, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(9, 1))
mock.ExpectCommit()
vipExpire := nowUnix() + 86400
if err := global.MysqlEngine.WithContext(context.Background()).Transaction(func(tx *gorm.DB) error {
return refundCosQuotaTx(context.Background(), tx, 7, vipExpire, 10, 123, "上传失败回退")
}); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestBuildQuotaRevokeFlowsSkipsNonEffectiveMemberGrant(t *testing.T) {
mock := setupQuotaTest(t)
// 订单凭证是年度包(未生效),月度包(生效份)来自其他订单:
// 吊销年度包不改变有效剩余,不应写回收流水。
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
flows, err := NewOrderService().buildQuotaRevokeFlows(context.Background(), &mysqlmodel.Order{OrderNo: "O1", UserID: 7})
if err != nil {
t.Fatal(err)
}
if len(flows) != 0 {
t.Fatalf("want no revoke flows, got %d", len(flows))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestBuildQuotaRevokeFlowsRecordsEffectiveGrantRevoke(t *testing.T) {
mock := setupQuotaTest(t)
// 订单凭证即生效份(月度包 100/30):吊销前有效剩余 20+70=90,吊销后仅剩免费 20,
// 回收流水变更量应为 -70。
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
flows, err := NewOrderService().buildQuotaRevokeFlows(context.Background(), &mysqlmodel.Order{OrderNo: "O2", UserID: 7})
if err != nil {
t.Fatal(err)
}
if len(flows) != 1 {
t.Fatalf("want 1 revoke flow, got %d", len(flows))
}
f := flows[0]
if f.ChangeNum != -70 || f.OldValue != 90 || f.NewValue != 20 || f.QuotaType != QuotaTypeCos {
t.Fatalf("flow = %+v, want change=-70 old=90 new=20", f)
}
}
+76 -52
View File
@@ -326,52 +326,25 @@ func (s *OrderService) RefundApply(ctx context.Context, userID int64, request *d
return nil, errcode.RefundNotAllowed return nil, errcode.RefundNotAllowed
} }
// 已有退款流水则按状态处理:非失败不允许重复申请;失败则复用旧记录重新申请。
existing, gerr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, request.OrderNo)
if gerr != nil && !isNotFound(gerr) {
return nil, errcode.DatabaseError
}
if existing != nil && existing.Status != RefundStatusFailed {
return nil, errcode.RefundNotAllowed
}
refundNo := genRefundNo() refundNo := genRefundNo()
applyTime := nowUnix() applyTime := nowUnix()
if existing != nil { // 受理与订单状态流转原子完成:订单 2 已支付 -> 6 申请退款中,并落退款流水
// 失败重试:复用旧记录,更新退款单号与状态。 // (已有失败流水则复用旧记录换新退款单号)。订单行锁兜底并发与重复申请。
if err := s.refundDao.RefundFlowLogUpdateStatus(ctx, existing.RefundNo, map[string]any{ if _, allowed, err := s.refundDao.RefundApplyWithOrderStatus(ctx, order.OrderNo, refundNo, request.Reason, applyTime); err != nil {
"refund_no": refundNo, slog.ErrorContext(ctx, "refund apply failed", "order_no", order.OrderNo, "user_id", userID, "error", err)
"refund_amount": order.PayAmount, return nil, errcode.RefundApplyFailed
"refund_reason": request.Reason, } else if !allowed {
"status": RefundStatusPending, slog.WarnContext(ctx, "refund apply rejected: order not paid or refund already in progress",
"apply_time": applyTime, "order_no", order.OrderNo, "user_id", userID, "order_status", order.OrderStatus)
"fail_reason": "", return nil, errcode.RefundNotAllowed
}); err != nil {
return nil, errcode.RefundApplyFailed
}
} else {
// 新建退款流水(order_no 唯一索引兜底并发,冲突即已有申请)。
flow := &mysqlmodel.RefundFlowLog{
OrderID: order.ID,
OrderNo: order.OrderNo,
UserID: userID,
RefundNo: refundNo,
RefundAmount: order.PayAmount,
RefundReason: request.Reason,
Status: RefundStatusPending,
ApplyTime: applyTime,
}
if err := s.refundDao.RefundFlowLogCreate(ctx, flow); err != nil {
if isDuplicateKey(err) {
return nil, errcode.RefundNotAllowed
}
return nil, errcode.RefundApplyFailed
}
} }
// 人工审核:不再自动调用微信退款,仅创建待审核退款申请,避免自动退款造成权益未回退的资损。 // 人工审核:不再自动调用微信退款,仅创建待审核退款申请,避免自动退款造成权益未回退的资损。
// 运营审核通过后在微信后台手动发起退款,微信退款回调 RefundNotify 会更新订单与流水状态。 // 运营审核通过后在微信后台手动发起退款,微信退款回调 RefundNotify 会更新订单与流水状态。
slog.InfoContext(ctx, "refund applied, order marked refunding",
"order_no", order.OrderNo, "user_id", userID, "refund_no", refundNo,
"refund_amount", order.PayAmount, "order_status", OrderStatusRefunding)
return &dto.RefundResponse{ return &dto.RefundResponse{
RefundNo: refundNo, RefundNo: refundNo,
RefundAmount: order.PayAmount, RefundAmount: order.PayAmount,
@@ -446,11 +419,23 @@ func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request)
slog.InfoContext(ctx, "refund notify: refund success settled", slog.InfoContext(ctx, "refund notify: refund success settled",
"order_no", order.OrderNo, "out_refund_no", data.OutRefundNo, "refund_amount", flow.RefundAmount) "order_no", order.OrderNo, "out_refund_no", data.OutRefundNo, "refund_amount", flow.RefundAmount)
} else if flow.Status != RefundStatusSuccess { } else if flow.Status != RefundStatusSuccess {
// 退款失败(微信返回 CLOSED/ABNORMAL 等):先把订单回退为「2 已支付」,允许用户重新申请;
// 顺序上先回退订单再落流水失败态,任一步失败时微信重试仍会回到本分支(流水尚未置失败),
// 不会出现「流水已失败但订单卡在 6 申请退款中」的死角。
reverted, rerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusRefunding, OrderStatusPaid)
if rerr != nil {
slog.ErrorContext(ctx, "refund notify: revert order status failed",
"out_trade_no", data.OutTradeNo, "out_refund_no", data.OutRefundNo, "error", rerr)
return errcode.DatabaseError
}
if _, err := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusFailed, "fail_reason": "微信退款未成功:" + data.RefundStatus, "callback_raw": data.Raw}); err != nil { if _, err := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusFailed, "fail_reason": "微信退款未成功:" + data.RefundStatus, "callback_raw": data.Raw}); err != nil {
slog.ErrorContext(ctx, "refund notify: mark refund failed status failed", slog.ErrorContext(ctx, "refund notify: mark refund failed status failed",
"out_refund_no", data.OutRefundNo, "refund_status", data.RefundStatus, "error", err) "out_refund_no", data.OutRefundNo, "refund_status", data.RefundStatus, "error", err)
return errcode.DatabaseError return errcode.DatabaseError
} }
slog.WarnContext(ctx, "refund notify: refund failed, order reverted to paid",
"order_no", data.OutTradeNo, "out_refund_no", data.OutRefundNo,
"refund_status", data.RefundStatus, "order_reverted", reverted)
} }
return nil return nil
} }
@@ -592,6 +577,21 @@ func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlm
return return
} }
s.executeGrant(ctx, existing) s.executeGrant(ctx, existing)
return
}
if existing.Status == BenefitTaskCancelled {
// 订单曾进入退款流程导致任务被取消,但订单已回到已支付(如退款失败回退):
// 恢复为待发放并立即尝试,避免「已付款却拿不到权益」;若订单实际仍为已退款,
// executeGrant 内的订单状态校验会重新取消该任务(幂等兜底)。
if rerr := taskDao.BenefitGrantTaskReset(ctx, existing.ID); rerr != nil {
slog.WarnContext(ctx, "reset cancelled benefit grant task failed",
"order_no", order.OrderNo, "benefit_type", benefitType, "error", rerr)
return
}
slog.InfoContext(ctx, "revive cancelled benefit grant task for paid order",
"order_no", order.OrderNo, "benefit_type", benefitType, "task_id", existing.ID)
s.executeGrant(ctx, existing)
return
} }
return // pending 交由定时任务处理。 return // pending 交由定时任务处理。
} }
@@ -814,7 +814,9 @@ func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmode
} }
// buildQuotaRevokeFlows 构造退款回收流水:按该订单的额度凭证逐类记录回收量。 // buildQuotaRevokeFlows 构造退款回收流水:按该订单的额度凭证逐类记录回收量。
// 已用部分不追回,故变更量只记未用量 (amount - used);已全部用掉的凭证不写流水。 // 记账口径为「吊销前有效剩余 vs 排除该订单凭证后重算的有效剩余」的差值,与额度聚合规则
// (会员包来源不叠加、只取一份)保持一致:已用部分不追回;吊销后有效剩余不变的凭证
// (例如被其他生效会员包覆盖的重复凭证)不写流水,避免出现用户实际感知不到的回收记录。
func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) ([]*mysqlmodel.QuotaFlowLog, error) { func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) ([]*mysqlmodel.QuotaFlowLog, error) {
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByRefID(ctx, order.OrderNo) grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByRefID(ctx, order.OrderNo)
if err != nil { if err != nil {
@@ -823,34 +825,56 @@ func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmo
} }
flows := make([]*mysqlmodel.QuotaFlowLog, 0, len(grants)) flows := make([]*mysqlmodel.QuotaFlowLog, 0, len(grants))
// 每类额度的当前有效剩余,逐张凭证递减(同一订单同类凭证通常只有一张,此处做通用处理)。 // 每类额度「吊销前」的有效剩余基准;excluded 累积本订单已计划吊销的凭证。
current := make(map[int8]int64, len(grants)) before := make(map[int8]int64, len(grants))
computed := make(map[int8]bool, len(grants)) beforeKnown := make(map[int8]bool, len(grants))
excluded := make(map[int64]bool, len(grants))
for i := range grants { for i := range grants {
g := grants[i] g := grants[i]
revoke := g.Amount - g.Used if g.Amount-g.Used <= 0 {
if revoke <= 0 {
continue // 已全部用掉,无可回收额度。 continue // 已全部用掉,无可回收额度。
} }
if !computed[g.QuotaType] { if !beforeKnown[g.QuotaType] {
if eff, qerr := getEffectiveQuota(ctx, order.UserID, g.QuotaType); qerr == nil { if eff, qerr := getEffectiveQuota(ctx, order.UserID, g.QuotaType); qerr == nil {
current[g.QuotaType] = eff.Remain before[g.QuotaType] = eff.Remain
beforeKnown[g.QuotaType] = true
} else { } else {
slog.WarnContext(ctx, "build quota revoke flows: calc effective quota failed", slog.WarnContext(ctx, "build quota revoke flows: calc effective quota failed",
"order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "error", qerr) "order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "error", qerr)
} }
computed[g.QuotaType] = true
} }
before := current[g.QuotaType] excluded[g.ID] = true
current[g.QuotaType] = before - revoke if !beforeKnown[g.QuotaType] {
continue // 基准未知:宁可少记不可记错,跳过该凭证的回收流水。
}
after, aerr := effectiveQuotaRemainExcluding(ctx, order.UserID, g.QuotaType, excluded)
if aerr != nil {
slog.WarnContext(ctx, "build quota revoke flows: calc remain after revoke failed",
"order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "grant_id", g.ID, "error", aerr)
beforeKnown[g.QuotaType] = false // 后续同类凭证不再基于错误基准记账。
continue
}
revoke := before[g.QuotaType] - after
before[g.QuotaType] = after
if revoke < 0 {
// 吊销后有效剩余反而增加(例如另一份未生效会员包被启用),属预期内的覆盖切换,只留痕不改动用户剩余。
slog.InfoContext(ctx, "quota revoke increases effective remain, skip flow",
"order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "grant_id", g.ID, "delta", -revoke)
continue
}
if revoke == 0 {
continue // 吊销不改变有效剩余(重复凭证被覆盖),无回收流水。
}
flows = append(flows, &mysqlmodel.QuotaFlowLog{ flows = append(flows, &mysqlmodel.QuotaFlowLog{
QuotaFlowUUID: genUUID(), QuotaFlowUUID: genUUID(),
UserID: order.UserID, UserID: order.UserID,
ChangeTarget: QuotaChangeTargetAdjust, ChangeTarget: QuotaChangeTargetAdjust,
ChangeType: QuotaChangeTypeRefundRevoke, ChangeType: QuotaChangeTypeRefundRevoke,
ChangeNum: -revoke, ChangeNum: -revoke,
OldValue: before, OldValue: before[g.QuotaType] + revoke,
NewValue: before - revoke, NewValue: before[g.QuotaType],
OrderNo: order.OrderNo, OrderNo: order.OrderNo,
QuotaType: g.QuotaType, QuotaType: g.QuotaType,
Reason: "退款回收未使用的赠送额度", Reason: "退款回收未使用的赠送额度",
+113
View File
@@ -0,0 +1,113 @@
package service
import (
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
)
// memberGrantFixture 构造会员包来源(source=1)额度凭证。
func memberGrantFixture(id, amount, used int64) mysqlmodel.QuotaGrant {
return mysqlmodel.QuotaGrant{ID: id, Source: QuotaGrantSourceMember, Amount: amount, Used: used}
}
// otherGrantFixture 构造非会员包来源(加量包/广告/体验)额度凭证。
func otherGrantFixture(id int64, source int8, amount, used int64) mysqlmodel.QuotaGrant {
return mysqlmodel.QuotaGrant{ID: id, Source: source, Amount: amount, Used: used}
}
func TestPickEffectiveMemberGrant(t *testing.T) {
for _, tc := range []struct {
name string
grants []mysqlmodel.QuotaGrant
wantID int64 // 0 表示期望无生效份(nil)
}{
{
name: "月度年度同额只取一份且保留正在使用的份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 30), memberGrantFixture(2, 100, 0)},
wantID: 1,
},
{
name: "额度不同时取额度最大的一份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 0), memberGrantFixture(2, 200, 0)},
wantID: 2,
},
{
name: "旧包用尽后顺延到新购的一份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 100), memberGrantFixture(2, 100, 0)},
wantID: 2,
},
{
name: "同额同用量时优先新购",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 0), memberGrantFixture(2, 100, 0)},
wantID: 2,
},
{
name: "无会员包凭证时返回 nil",
grants: []mysqlmodel.QuotaGrant{otherGrantFixture(1, QuotaGrantSourceAddon, 1000, 0), otherGrantFixture(2, QuotaGrantSourceTrial, 100, 0)},
wantID: 0,
},
{
name: "会员包全部用尽时返回 nil",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 100), memberGrantFixture(2, 50, 50)},
wantID: 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := pickEffectiveMemberGrant(tc.grants)
if tc.wantID == 0 {
if got != nil {
t.Fatalf("want nil, got id=%d", got.ID)
}
return
}
if got == nil || got.ID != tc.wantID {
t.Fatalf("want id=%d, got %+v", tc.wantID, got)
}
})
}
}
func TestPickMemberGrantForRefund(t *testing.T) {
for _, tc := range []struct {
name string
grants []mysqlmodel.QuotaGrant
handled map[int64]bool
wantID int64
}{
{
name: "优先回退当前生效份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 40), memberGrantFixture(2, 100, 10)},
wantID: 1,
},
{
name: "生效份未消耗时复活刚被扣尽的份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 100), memberGrantFixture(2, 100, 0)},
wantID: 1,
},
{
name: "已处理的份不重复回退",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 40), memberGrantFixture(2, 100, 10)},
handled: map[int64]bool{1: true},
wantID: 2,
},
{
name: "无可退额度时返回 nil",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 0)},
wantID: 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := pickMemberGrantForRefund(tc.grants, tc.handled)
if tc.wantID == 0 {
if got != nil {
t.Fatalf("want nil, got id=%d", got.ID)
}
return
}
if got == nil || got.ID != tc.wantID {
t.Fatalf("want id=%d, got %+v", tc.wantID, got)
}
})
}
}
+96
View File
@@ -0,0 +1,96 @@
package service
import (
"context"
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
"github.com/DATA-DOG/go-sqlmock"
)
// TestRefundApplyMarksOrderRefunding 申请退款受理成功:返回待处理退款流水信息,订单 2 -> 6 由 DAO 事务内完成。
func TestRefundApplyMarksOrderRefunding(t *testing.T) {
mock := setupOrderQuotaTest(t)
mock.ExpectQuery("SELECT .* FROM `orders` WHERE order_no = \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount", "pay_channel"}).
AddRow(8, "ORDER-1", 7, 2, 990, 1))
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount"}).
AddRow(8, "ORDER-1", 7, 2, 990))
mock.ExpectQuery("SELECT .* FROM `refund_flow_logs`").
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec("INSERT INTO `refund_flow_logs`").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
resp, e := NewOrderService().RefundApply(context.Background(), 7,
&dto.RefundApplyRequest{OrderNo: "ORDER-1", Reason: "不想要了"})
if e != nil {
t.Fatalf("unexpected error: %v", e)
}
if resp == nil || resp.RefundAmount != 990 || resp.Status != RefundStatusPending || resp.RefundNo == "" {
t.Fatalf("unexpected response: %+v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyRejectsRepeatApply 重复/并发申请被 DAO 拒绝时映射为「当前状态不允许退款」。
func TestRefundApplyRejectsRepeatApply(t *testing.T) {
mock := setupOrderQuotaTest(t)
mock.ExpectQuery("SELECT .* FROM `orders` WHERE order_no = \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount", "pay_channel"}).
AddRow(8, "ORDER-1", 7, 2, 990, 1))
// 行锁内读到订单已被并发请求置为 6 申请退款中:拒绝且不产生任何写入。
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).
AddRow(8, "ORDER-1", 7, OrderStatusRefunding))
mock.ExpectCommit()
resp, e := NewOrderService().RefundApply(context.Background(), 7,
&dto.RefundApplyRequest{OrderNo: "ORDER-1", Reason: "重复申请"})
if resp != nil || e != errcode.RefundNotAllowed {
t.Fatalf("resp=%v err=%v, want RefundNotAllowed", resp, e)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyRejectsIOSVirtualOrder iOS 虚拟支付订单不走本接口,引导用户走 App Store。
func TestRefundApplyRejectsIOSVirtualOrder(t *testing.T) {
mock := setupOrderQuotaTest(t)
mock.ExpectQuery("SELECT .* FROM `orders` WHERE order_no = \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount", "pay_channel", "xpay_platform"}).
AddRow(8, "ORDER-1", 7, 2, 990, 2, "ios"))
resp, e := NewOrderService().RefundApply(context.Background(), 7,
&dto.RefundApplyRequest{OrderNo: "ORDER-1", Reason: "不想要了"})
if resp != nil || e != errcode.IOSRefundViaAppStore {
t.Fatalf("resp=%v err=%v, want IOSRefundViaAppStore", resp, e)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestRefundApplyRejectsOtherUserOrder 非本人订单不允许申请退款(归属校验先于状态校验)。
func TestRefundApplyRejectsOtherUserOrder(t *testing.T) {
mock := setupOrderQuotaTest(t)
mock.ExpectQuery("SELECT .* FROM `orders` WHERE order_no = \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status", "pay_amount", "pay_channel"}).
AddRow(8, "ORDER-1", 9, 2, 990, 1))
resp, e := NewOrderService().RefundApply(context.Background(), 7,
&dto.RefundApplyRequest{OrderNo: "ORDER-1", Reason: "越权申请"})
if resp != nil || e != errcode.Forbidden {
t.Fatalf("resp=%v err=%v, want Forbidden", resp, e)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+192 -27
View File
@@ -135,12 +135,73 @@ func listValidGrants(ctx context.Context, userID int64, quotaType int8, vipExpir
return valid, nil return valid, nil
} }
// pickEffectiveMemberGrant 在有效凭证中选出会员包来源(source=1)的生效份。
// 会员包额度不叠加:月度包与年度包同时持有时只取一份计入有效额度,避免额度翻倍。
//
// 规则:排除已用尽(Amount-Used<=0)的份,保证旧包用完后新购包能自动接续、不浪费已购额度;
// 候选中按 Amount 降序(取额度最大的一份)→ Used 降序(保留正在使用的一份,避免购买新包后
// 剩余额度抖动)→ ID 降序(新购优先)取第一条;无候选返回 nil,表示会员包贡献为 0。
//
// 纯函数、无副作用,调用方需保证 grants 为「当前有效」的凭证集合;
// getEffectiveQuota / consumeCosQuota / cosRemainTx / refundCosQuotaTx 共用该规则,保证展示、扣减、回退口径一致。
func pickEffectiveMemberGrant(grants []mysqlmodel.QuotaGrant) *mysqlmodel.QuotaGrant {
var picked *mysqlmodel.QuotaGrant
for i := range grants {
g := &grants[i]
if g.Source != QuotaGrantSourceMember || g.Amount-g.Used <= 0 {
continue
}
if picked == nil || memberGrantBetter(g, picked) {
picked = g
}
}
return picked
}
// memberGrantBetter 报告 a 是否应优先于 b:Amount 降序 → Used 降序 → ID 降序。
func memberGrantBetter(a, b *mysqlmodel.QuotaGrant) bool {
switch {
case a.Amount != b.Amount:
return a.Amount > b.Amount
case a.Used != b.Used:
return a.Used > b.Used
default:
return a.ID > b.ID
}
}
// pickMemberGrantForRefund 选择会员包来源(source=1)的回退目标份(跳过 handled 中已处理的份)。
// 会员包额度不叠加,回退需与展示口径一致:
// 1. 存在「当前生效份」(未用尽且 used>0)时优先回退它,保证剩余额度即时回升、账实一致;
// 2. 生效份无可退额度(used=0)时,退而选择 used>0 的份中 memberGrantBetter 最优的一条,
// 用于复活刚被扣至用尽的一份(例如消费恰好把生效份用尽后回退)。
func pickMemberGrantForRefund(grants []mysqlmodel.QuotaGrant, handled map[int64]bool) *mysqlmodel.QuotaGrant {
candidates := make([]mysqlmodel.QuotaGrant, 0, len(grants))
for i := range grants {
g := grants[i]
if g.Source != QuotaGrantSourceMember || g.Used <= 0 || handled[g.ID] {
continue
}
candidates = append(candidates, g)
}
if len(candidates) == 0 {
return nil
}
if effective := pickEffectiveMemberGrant(candidates); effective != nil {
return effective
}
picked := &candidates[0]
for i := 1; i < len(candidates); i++ {
if memberGrantBetter(&candidates[i], picked) {
picked = &candidates[i]
}
}
return picked
}
// getEffectiveQuota 返回某类额度的有效额度概览(Cos 永久 / 每日额度)。 // getEffectiveQuota 返回某类额度的有效额度概览(Cos 永久 / 每日额度)。
// 有效总额度 = 免费配置值 + SUM(有效凭证 amount);已用按类型分别取永久累计或当日计数。
func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*EffectiveQuota, *errcode.Error) { func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*EffectiveQuota, *errcode.Error) {
now := nowUnix() now := nowUnix()
freeTotal := defaultQuotaValue(quotaType)
vipExpire := getUserVIPExpire(ctx, userID) vipExpire := getUserVIPExpire(ctx, userID)
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now) grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
if err != nil { if err != nil {
@@ -148,12 +209,28 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
"user_id", userID, "quota_type", quotaType, "error", err) "user_id", userID, "quota_type", quotaType, "error", err)
return nil, errcode.DatabaseError return nil, errcode.DatabaseError
} }
return effectiveQuotaFromGrants(ctx, userID, quotaType, grants), nil
}
// effectiveQuotaFromGrants 基于有效凭证快照聚合某类额度的有效额度概览(免费部分从库读取)。
// 有效总额度 = 免费配置值 + 会员包生效份(source=1,不叠加只取一份)+ 其他来源凭证 SUM;
// 已用按类型分别取永久累计或当日计数。
func effectiveQuotaFromGrants(ctx context.Context, userID int64, quotaType int8, grants []mysqlmodel.QuotaGrant) *EffectiveQuota {
freeTotal := defaultQuotaValue(quotaType)
// 会员包来源不叠加:只取生效的一份;加量包/广告/体验等其他来源仍叠加求和。
var grantTotal, grantUsed int64 var grantTotal, grantUsed int64
for i := range grants { for i := range grants {
if grants[i].Source == QuotaGrantSourceMember {
continue
}
grantTotal += grants[i].Amount grantTotal += grants[i].Amount
grantUsed += grants[i].Used grantUsed += grants[i].Used
} }
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
grantTotal += memberGrant.Amount
grantUsed += memberGrant.Used
}
if quotaType == QuotaTypeCos { if quotaType == QuotaTypeCos {
// 永久额度:免费已用来自 user_quotas.used(永久累计),赠送已用来自凭证 used。 // 永久额度:免费已用来自 user_quotas.used(永久累计),赠送已用来自凭证 used。
@@ -170,7 +247,7 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
if remain < 0 { if remain < 0 {
remain = 0 remain = 0
} }
return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil return &EffectiveQuota{Total: total, Used: used, Remain: remain}
} }
// 每日额度:免费与赠送合并为每日总额度,已用为当日计数。 // 每日额度:免费与赠送合并为每日总额度,已用为当日计数。
@@ -180,7 +257,27 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
if remain < 0 { if remain < 0 {
remain = 0 remain = 0
} }
return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil return &EffectiveQuota{Total: total, Used: used, Remain: remain}
}
// effectiveQuotaRemainExcluding 计算「排除指定凭证(按 ID)」后的某类有效剩余,供退款回收差值记账使用。
// 聚合口径与 getEffectiveQuota 完全一致(会员包不叠加只取一份,其他来源叠加),
// 保证「退款回收」流水的变更量等于用户真实可感知的剩余变化。
func effectiveQuotaRemainExcluding(ctx context.Context, userID int64, quotaType int8, excluded map[int64]bool) (int64, error) {
now := nowUnix()
vipExpire := getUserVIPExpire(ctx, userID)
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
if err != nil {
return 0, err
}
kept := make([]mysqlmodel.QuotaGrant, 0, len(grants))
for i := range grants {
if excluded[grants[i].ID] {
continue
}
kept = append(kept, grants[i])
}
return effectiveQuotaFromGrants(ctx, userID, quotaType, kept).Remain, nil
} }
// dailyQuotaKey 按额度类型构造当日计数 Redis key。 // dailyQuotaKey 按额度类型构造当日计数 Redis key。
@@ -275,7 +372,8 @@ func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64
} }
} }
// consumeCosQuota 消费 Cos 永久额度(先到期凭证先用,再扣免费额度),事务内保证数值与流水一致。 // consumeCosQuota 消费 Cos 永久额度(先到期凭证先用,再扣会员包生效份,最后扣免费额度),事务内保证数值与流水一致。
// 会员包来源(source=1)不叠加:多份会员包凭证中只扣生效的一份,未生效的份不参与扣减。
func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) *errcode.Error { func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) *errcode.Error {
now := nowUnix() now := nowUnix()
vipExpire := getUserVIPExpire(ctx, userID) vipExpire := getUserVIPExpire(ctx, userID)
@@ -296,32 +394,54 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
return err return err
} }
remain := n // consumeGrant 扣减单份凭证的已用(条件更新防并发超扣,扣不动则整单回滚)。
for i := range grants { consumeGrant := func(g *mysqlmodel.QuotaGrant, want int64) (int64, error) {
if remain <= 0 { delta := g.Amount - g.Used
break if delta <= 0 {
return 0, nil
} }
avail := grants[i].Amount - grants[i].Used if delta > want {
if avail <= 0 { delta = want
continue
}
delta := avail
if delta > remain {
delta = remain
} }
res := tx.Model(&mysqlmodel.QuotaGrant{}). res := tx.Model(&mysqlmodel.QuotaGrant{}).
Where("id = ? AND used + ? <= amount", grants[i].ID, delta). Where("id = ? AND used + ? <= amount", g.ID, delta).
UpdateColumn("used", gorm.Expr("used + ?", delta)) UpdateColumn("used", gorm.Expr("used + ?", delta))
if res.Error != nil { if res.Error != nil {
return res.Error return 0, res.Error
} }
if res.RowsAffected == 0 { if res.RowsAffected == 0 {
// 凭证在行锁下被并发改写(理论上不应发生)。此时若继续累加会少扣额度, // 凭证在行锁下被并发改写(理论上不应发生)。此时若继续累加会少扣额度,
// 直接回滚整个事务并报额度不足,宁可让用户重试也不超额放行。 // 直接回滚整个事务并报额度不足,宁可让用户重试也不超额放行。
return errcode.QuotaInsufficient return 0, errcode.QuotaInsufficient
}
return delta, nil
}
remain := n
// 1.1 先扣非会员包凭证(加量包/广告/体验,先到期先用)。
for i := range grants {
if remain <= 0 {
break
}
if grants[i].Source == QuotaGrantSourceMember {
continue
}
delta, cerr := consumeGrant(&grants[i], remain)
if cerr != nil {
return cerr
} }
remain -= delta remain -= delta
} }
// 1.2 会员包额度不叠加:只扣生效的一份,未生效的份不参与扣减。
if remain > 0 {
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
delta, cerr := consumeGrant(memberGrant, remain)
if cerr != nil {
return cerr
}
remain -= delta
}
}
// 2. 凭证不够再扣免费额度(免费上限以配置值为准,避免历史 user_quotas.total 污染)。 // 2. 凭证不够再扣免费额度(免费上限以配置值为准,避免历史 user_quotas.total 污染)。
if remain > 0 { if remain > 0 {
@@ -368,6 +488,7 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
} }
// cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。 // cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。
// 会员包来源不叠加:只计生效的一份,与 getEffectiveQuota 展示口径保持一致。
func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now int64) int64 { func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now int64) int64 {
freeRemain := int64(0) freeRemain := int64(0)
var freeQ mysqlmodel.Quota var freeQ mysqlmodel.Quota
@@ -375,16 +496,31 @@ func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now
freeRemain = defaultQuotaValue(QuotaTypeCos) - freeQ.Used freeRemain = defaultQuotaValue(QuotaTypeCos) - freeQ.Used
} }
var grantRemain int64 var grants []mysqlmodel.QuotaGrant
q := tx.Model(&mysqlmodel.QuotaGrant{}).Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos) q := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
if vipExpire > now { if vipExpire > now {
q = q.Where("(expire_time > ? OR expire_time = 0)", now) q = q.Where("(expire_time > ? OR expire_time = 0)", now)
} else { } else {
q = q.Where("expire_time > ?", now) q = q.Where("expire_time > ?", now)
} }
if err := q.Select("COALESCE(SUM(amount - used), 0)").Scan(&grantRemain).Error; err != nil { if err := q.Find(&grants).Error; err != nil {
slog.WarnContext(ctx, "calc cos grant remain failed", "user_id", userID, "error", err) slog.WarnContext(ctx, "calc cos grant remain failed", "user_id", userID, "error", err)
} }
var grantRemain int64
for i := range grants {
if grants[i].Source == QuotaGrantSourceMember {
continue
}
if r := grants[i].Amount - grants[i].Used; r > 0 {
grantRemain += r
}
}
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
if r := memberGrant.Amount - memberGrant.Used; r > 0 {
grantRemain += r
}
}
if freeRemain < 0 { if freeRemain < 0 {
freeRemain = 0 freeRemain = 0
} }
@@ -392,7 +528,8 @@ func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now
} }
// refundCosQuotaTx 在事务内回退 Cos 额度并写流水。 // refundCosQuotaTx 在事务内回退 Cos 额度并写流水。
// 回退策略(后进先出):优先回退免费额度(最后扣减的),不足再回退凭证(最后到期优先)。 // 回退策略(后进先出):优先回退免费额度(最后扣减的),再回退非会员包凭证(最后到期优先),
// 最后回退会员包凭证(不叠加口径:优先当前生效份,生效份无可退额度时复活刚被扣尽的份)。
func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error { func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error {
now := nowUnix() now := nowUnix()
@@ -408,10 +545,10 @@ func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire
remain = 0 remain = 0
} }
// 2. 免费额度不足,回退凭证(按到期时间倒序,最后到期的优先回退)。 // 2. 免费额度不足,先回退非会员包凭证(加量包/广告/体验,按到期时间倒序,最后到期的优先回退)。
if remain > 0 { if remain > 0 {
var grants []mysqlmodel.QuotaGrant var grants []mysqlmodel.QuotaGrant
if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0", userID, QuotaTypeCos). if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0 AND source <> ?", userID, QuotaTypeCos, QuotaGrantSourceMember).
Order("expire_time DESC"). Order("expire_time DESC").
Clauses(clause.Locking{Strength: "UPDATE"}). Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&grants).Error; err != nil { Find(&grants).Error; err != nil {
@@ -434,7 +571,35 @@ func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire
} }
} }
// 3. 写回退流水:变更量取「实际回退量」。 // 3. 再回退会员包凭证(不叠加口径:按回退选择规则逐份回退,未生效份不作为回退目标)。
if remain > 0 {
var grants []mysqlmodel.QuotaGrant
if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0 AND source = ?", userID, QuotaTypeCos, QuotaGrantSourceMember).
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&grants).Error; err != nil {
return err
}
handled := make(map[int64]bool, len(grants))
for remain > 0 {
target := pickMemberGrantForRefund(grants, handled)
if target == nil {
break
}
delta := target.Used
if delta > remain {
delta = remain
}
if err := tx.Model(&mysqlmodel.QuotaGrant{}).
Where("id = ? AND used >= ?", target.ID, delta).
UpdateColumn("used", gorm.Expr("used - ?", delta)).Error; err != nil {
return err
}
remain -= delta
handled[target.ID] = true
}
}
// 4. 写回退流水:变更量取「实际回退量」。
// 此前 `remain > 0` 时直接 return,导致额度已被部分改回却没有流水(账实不一致); // 此前 `remain > 0` 时直接 return,导致额度已被部分改回却没有流水(账实不一致);
// 同时 ChangeNum 写的是请求量 n,退不满时数字虚高。现统一按实际回退量记账, // 同时 ChangeNum 写的是请求量 n,退不满时数字虚高。现统一按实际回退量记账,
// 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。 // 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"context"
"log/slog"
"sync"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
)
const (
// virtualRefundConfirmGraceSeconds 退款申请的宽限期:只处理停留超过该时长的「申请退款中」订单,
// 给退款推送留出处理时间,避免与推送路径抢跑。
virtualRefundConfirmGraceSeconds int64 = 10 * 60
// virtualRefundConfirmBatchSize 单轮最多扫描的候选订单数。
virtualRefundConfirmBatchSize = 100
// virtualRefundConfirmWorkers 单轮并发反查的 worker 数(query_order 为 HTTP 调用,串行会在积压时撞上超时)。
virtualRefundConfirmWorkers = 5
// virtualRefundConfirmRecheckTTL 单个订单的查单频率下限:命中「已核对」标记则跳过本轮,
// 把单个订单的微信接口调用频率压到每 TTL 一次(退款为人工审核,长时间停留在 6 属正常)。
virtualRefundConfirmRecheckTTL = 10 * time.Minute
// virtualRefundConfirmStuckSeconds 停留超过该时长仍未见微信侧退款 -> Error 告警交人工介入。
virtualRefundConfirmStuckSeconds int64 = 24 * 60 * 60
// virtualRefundConfirmStuckAlertTTL 「长期未完成」告警去重 TTL,避免每分钟刷屏。
virtualRefundConfirmStuckAlertTTL = 6 * time.Hour
// virtualRefundConfirmLockTTL 单轮任务的分布式锁 TTL,必须大于单轮最大耗时,否则锁提前过期会让多实例并发执行。
virtualRefundConfirmLockTTL = 5 * time.Minute
// virtualRefundConfirmTimeout 单轮任务的上下文超时,需给「批量查询 + 并发反查」留足余量。
virtualRefundConfirmTimeout = 5 * time.Minute
)
// StartVirtualRefundConfirmScheduler 启动虚拟支付退款结果查单兜底任务。
//
// 与发货链路的「推送 + 轮询」双保险同理:退款推送可能丢失、字段版式变化或应答失败重试耗尽,
// 一旦发生订单会永久停留在「6 申请退款中」(用户看不到退款结果、运营无法按状态收口)。
// 本任务对停留超过宽限期的「申请退款中」订单主动反查 query_order:
// - 微信侧已退款 -> 复用推送同一结算链路置「5 已退款」、回收权益并同步退款流水;
// - 微信侧仍未退款 -> 保持订单状态不变(避免运营处理中的退款被误判回退),超过阈值输出 Error 告警交人工。
func StartVirtualRefundConfirmScheduler() {
go func() {
slog.Info("virtual refund confirm scheduler started")
for {
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("virtual refund confirm scheduler panic", "recover", r)
}
}()
runVirtualRefundConfirm()
}()
time.Sleep(time.Minute)
}
}()
}
// runVirtualRefundConfirm 扫描「申请退款中」的虚拟支付订单并反查微信侧退款结果,单实例通过 Redis 分布式锁防重。
func runVirtualRefundConfirm() {
// 虚拟支付未开通时直接返回,避免无谓的数据库扫描。
if !wechat.IsVirtualPayEnabled() {
return
}
ctx, cancel := context.WithTimeout(context.Background(), virtualRefundConfirmTimeout)
defer cancel()
ctx, span := global.Tracer.Start(ctx, "scheduler.VirtualRefundConfirm")
defer span.End()
lockKey := redismodel.KeyLockVirtualRefundConfirm
lockVal := genUUID()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, virtualRefundConfirmLockTTL)
if lerr != nil {
slog.ErrorContext(ctx, "virtual refund confirm: acquire lock failed", "error", lerr)
return
}
if !ok {
slog.DebugContext(ctx, "virtual refund confirm: another instance running, skip")
return
}
defer func() {
if uerr := redisdao.Unlock(context.Background(), lockKey, lockVal); uerr != nil {
slog.WarnContext(ctx, "virtual refund confirm: release lock failed", "error", uerr)
}
}()
now := nowUnix()
orders, err := mysqldao.NewOrderDao().
OrderListVirtualRefunding(ctx, now-virtualRefundConfirmGraceSeconds, virtualRefundConfirmBatchSize)
if err != nil {
slog.ErrorContext(ctx, "virtual refund confirm: list refunding orders failed", "error", err)
return
}
if len(orders) == 0 {
slog.DebugContext(ctx, "virtual refund confirm: no refunding orders in window")
return
}
if len(orders) >= virtualRefundConfirmBatchSize {
slog.WarnContext(ctx, "virtual refund confirm backlog detected, candidates reached limit",
"candidates", len(orders), "limit", virtualRefundConfirmBatchSize)
}
s := NewOrderService()
var (
mu sync.Mutex
wg sync.WaitGroup
queried int
reconciled int
stuck int
)
sem := make(chan struct{}, virtualRefundConfirmWorkers)
for i := range orders {
order := &orders[i]
// 频率限制:TTL 内已核对过的订单让出名额;标记不可用时不阻断本轮(退化为重复查询)。
first, serr := redisdao.VirtualRefundCheckedSetNX(ctx, order.OrderNo, virtualRefundConfirmRecheckTTL)
if serr != nil {
slog.WarnContext(ctx, "virtual refund confirm: set recheck mark failed",
"order_no", order.OrderNo, "error", serr)
} else if !first {
continue
}
queried++
wg.Add(1)
sem <- struct{}{}
go func(o *mysqlmodel.Order) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
slog.ErrorContext(ctx, "virtual refund confirm worker panic", "order_no", o.OrderNo, "recover", r)
}
}()
done, alerted, qok := s.reconcileVirtualRefund(ctx, o, now)
if !qok {
// 查单失败:撤销「已核对」标记,让下一轮立即重试(撤销失败不阻断,退化为 TTL 节奏)。
if derr := redisdao.VirtualRefundCheckedDel(ctx, o.OrderNo); derr != nil {
slog.WarnContext(ctx, "virtual refund confirm: clear recheck mark failed",
"order_no", o.OrderNo, "error", derr)
}
}
if done {
mu.Lock()
reconciled++
mu.Unlock()
}
if alerted {
mu.Lock()
stuck++
mu.Unlock()
}
}(order)
}
wg.Wait()
if reconciled > 0 || stuck > 0 {
slog.InfoContext(ctx, "virtual refund confirm finished",
"scanned", len(orders), "queried", queried, "reconciled", reconciled, "stuck_alerted", stuck)
return
}
slog.DebugContext(ctx, "virtual refund confirm finished without change",
"scanned", len(orders), "queried", queried)
}
// reconcileVirtualRefund 反查单笔「申请退款中」订单在微信侧的退款结果。
// - 微信侧已退款:置「已退款」、回收权益并同步退款流水(与推送路径同一结算链路,条件流转天然幂等);
// - 微信侧仍未退款:保持订单状态不变,停留超过阈值时输出 Error 告警交人工(不自动回退,避免运营处理中的退款被误判);
// - 查询失败/用户信息缺失:仅记日志并返回 queried=false,交由下一轮立即重试。
//
// 返回值:reconciled=已按查单结果订正为已退款;alerted=本次输出了人工介入告警;queried=查单是否成功执行。
func (s *OrderService) reconcileVirtualRefund(ctx context.Context, order *mysqlmodel.Order, now int64) (reconciled bool, alerted bool, queried bool) {
// query_order 需要 openid,订单表不冗余该字段,按 user_id 取。
user, err := s.userDao.UserGetByID(ctx, order.UserID)
if err != nil {
slog.ErrorContext(ctx, "virtual refund confirm: get user failed",
"order_no", order.OrderNo, "user_id", order.UserID, "error", err)
return false, false, false
}
if user.WxOpenID == "" {
slog.WarnContext(ctx, "virtual refund confirm: user openid unavailable",
"order_no", order.OrderNo, "user_id", order.UserID)
return false, false, false
}
info, qerr := wechat.QueryOrder(ctx, user.WxOpenID, order.OrderNo, "")
if qerr != nil {
slog.ErrorContext(ctx, "virtual refund confirm: query order failed",
"order_no", order.OrderNo, "error", qerr)
return false, false, false
}
if wechat.IsOrderRefundedStatus(info.Order.Status) {
// 退款金额优先取退款单金额,其次按支付单「实付 - 剩余」推算,最后回退订单实付金额。
refundFee := info.Order.RefundFee
if refundFee <= 0 {
refundFee = info.Order.PaidFee - info.Order.LeftFee
}
if refundFee <= 0 {
refundFee = order.PayAmount
}
if serr := s.settleRefundSuccess(ctx, order, refundFee); serr != nil {
slog.ErrorContext(ctx, "virtual refund confirm: settle refund success failed",
"order_no", order.OrderNo, "refund_fee", refundFee, "error", serr)
return false, false, true // 查单成功但结算失败:保持 TTL 节奏重试。
}
// 查单分支没有推送报文与微信退款单号,仅同步流水终态(有本地申请流水时)。
s.markRefundFlowSuccess(ctx, order, "", "")
slog.InfoContext(ctx, "virtual refund confirm: refund reconciled by query_order",
"order_no", order.OrderNo, "user_id", order.UserID,
"wx_status", info.Order.Status, "refund_fee", refundFee)
return true, false, true
}
// 微信侧仍未退款:订单保持「申请退款中」(运营可能正在处理),仅在长期无进展时告警人工介入。
stuckSeconds := now - order.UpdatedAt
if stuckSeconds < virtualRefundConfirmStuckSeconds {
slog.DebugContext(ctx, "virtual refund confirm: refund still pending in wechat side",
"order_no", order.OrderNo, "wx_status", info.Order.Status, "stuck_seconds", stuckSeconds)
return false, false, true
}
first, serr := redisdao.VirtualRefundStuckAlertSetNX(ctx, order.OrderNo, virtualRefundConfirmStuckAlertTTL)
if serr != nil {
slog.WarnContext(ctx, "virtual refund confirm: set stuck alert mark failed",
"order_no", order.OrderNo, "error", serr)
first = true // 标记不可用时仍然告警,避免静默。
}
if !first {
return false, false, true
}
slog.ErrorContext(ctx, "virtual refund confirm: refund stuck, manual intervention required",
"order_no", order.OrderNo, "user_id", order.UserID,
"stuck_seconds", stuckSeconds, "wx_status", info.Order.Status)
return false, true, true
}
+29
View File
@@ -0,0 +1,29 @@
package service
import (
"strings"
"testing"
"unicode/utf8"
)
// TestTruncateCallbackRaw 回调报文截断:短报文原样保留,长报文按上限截断且不产生非法 UTF-8
// (退款流水 callback_raw 为 utf8mb4 列,写入非法字节会直接报错)。
func TestTruncateCallbackRaw(t *testing.T) {
short := `{"Event":"xpay_refund_notify","MchOrderId":"OD1"}`
if got := truncateCallbackRaw(short); got != short {
t.Fatalf("short raw should be kept as-is, got %q", got)
}
// 构造跨越截断边界的多字节报文:4KB 边界前后都是中文字符。
long := strings.Repeat("退", 3000)
got := truncateCallbackRaw(long)
if len(got) > 4096 {
t.Fatalf("truncated length = %d, want <= 4096", len(got))
}
if !utf8.ValidString(got) {
t.Fatal("truncated raw must be valid UTF-8")
}
if got == "" || !strings.HasPrefix(long, got) {
t.Fatalf("truncated raw should be a prefix of the original, got prefix=%q", got[:min(8, len(got))])
}
}
+146 -27
View File
@@ -4,7 +4,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"strconv"
"time" "time"
"unicode/utf8"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
@@ -165,7 +167,7 @@ func (s *OrderService) XPayNotify(ctx context.Context, raw []byte) []byte {
slog.ErrorContext(ctx, "xpay refund notify decode failed", "error", err) slog.ErrorContext(ctx, "xpay refund notify decode failed", "error", err)
return wechat.BuildNotifyResponse(false) return wechat.BuildNotifyResponse(false)
} }
if e := s.handleRefundNotify(ctx, &refund); e != nil { if e := s.handleRefundNotify(ctx, &refund, string(raw)); e != nil {
// 内部失败分支已各自记录 Error 日志,这里仅保留调用链痕迹,避免重复告警。 // 内部失败分支已各自记录 Error 日志,这里仅保留调用链痕迹,避免重复告警。
slog.DebugContext(ctx, "xpay refund notify handle failed", "error", e) slog.DebugContext(ctx, "xpay refund notify handle failed", "error", e)
return wechat.BuildNotifyResponse(false) return wechat.BuildNotifyResponse(false)
@@ -224,12 +226,24 @@ func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmode
// query_order(带 HMAC 签名的服务端 API)确认订单在微信侧已真实退款、金额一致, // query_order(带 HMAC 签名的服务端 API)确认订单在微信侧已真实退款、金额一致,
// 防止伪造退款推送回收权益。任一校验不通过返回错误(fail-closed,触发微信重试)。 // 防止伪造退款推送回收权益。任一校验不通过返回错误(fail-closed,触发微信重试)。
func (s *OrderService) verifyXPayRefund(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify) *errcode.Error { func (s *OrderService) verifyXPayRefund(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify) *errcode.Error {
if refund.OpenID == "" { // query_order 必需 openid:推送未携带时回退用订单归属用户的 openid(订单表不冗余该字段,按 user_id 取)。
slog.ErrorContext(ctx, "xpay refund: empty openid", "order_no", order.OrderNo) openid := refund.OpenID
if openid == "" {
user, uerr := s.userDao.UserGetByID(ctx, order.UserID)
if uerr != nil {
slog.ErrorContext(ctx, "xpay refund: get user for openid failed",
"order_no", order.OrderNo, "user_id", order.UserID, "error", uerr)
return errcode.OrderRealityCheckFailed
}
openid = user.WxOpenID
}
if openid == "" {
slog.ErrorContext(ctx, "xpay refund: empty openid", "order_no", order.OrderNo, "user_id", order.UserID)
return errcode.OrderRealityCheckFailed return errcode.OrderRealityCheckFailed
} }
info, err := wechat.QueryOrder(ctx, refund.OpenID, refund.OutTradeNo, "") // 按订单号反查(而非推送中的订单号字段),保证查询目标与本地订单严格一致。
info, err := wechat.QueryOrder(ctx, openid, order.OrderNo, "")
if err != nil { if err != nil {
slog.ErrorContext(ctx, "xpay refund: query order failed", slog.ErrorContext(ctx, "xpay refund: query order failed",
"order_no", order.OrderNo, "error", err) "order_no", order.OrderNo, "error", err)
@@ -372,41 +386,71 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X
return nil return nil
} }
// handleRefundNotify 处理退款推送:复用退款成功回退权益链路(settleRefundSuccess)。 // handleRefundNotify 处理退款推送(xpay_refund_notify):按官方字段驱动状态流转。
func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XPayRefundNotify) *errcode.Error { //
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, refund.RefundOrderID) // 官方报文用 MchOrderId 定位订单、RetCode 判定结果(0=成功,非 0=失败,原因见 RetMsg)。
// 拿不到订单号或结果不可判定时一律应答失败(ErrCode:1),由微信按 2/4/8/16… 最多重试 15 次,
// 避免「静默成功」导致订单永久卡在「申请退款中」;成功路径仍强制反查 query_order 确认真实退款
// (推送无签名),防止伪造推送回收权益。
func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XPayRefundNotify, raw string) *errcode.Error {
orderNo := refund.OrderNo()
if orderNo == "" {
slog.ErrorContext(ctx, "xpay refund: empty order no in notify",
"mch_order_id", refund.MchOrderID, "out_trade_no", refund.OutTradeNo,
"mch_refund_id", refund.MchRefundID, "wx_refund_id", refund.WxRefundID,
"retry_times", refund.RetryTimes)
return errcode.OrderRealityCheckFailed // 应答失败 -> 微信重试。
}
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, orderNo)
lockVal := genUUID() lockVal := genUUID()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second) ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second)
if lerr != nil { if lerr != nil {
slog.ErrorContext(ctx, "xpay refund: acquire lock failed", "out_refund_no", refund.RefundOrderID, "error", lerr) slog.ErrorContext(ctx, "xpay refund: acquire lock failed", "order_no", orderNo, "error", lerr)
return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。 return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。
} }
if !ok { if !ok {
slog.DebugContext(ctx, "xpay refund: concurrent callback, skip", "out_refund_no", refund.RefundOrderID) slog.DebugContext(ctx, "xpay refund: concurrent callback, skip", "order_no", orderNo)
return nil return nil
} }
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
// 白名单判断:仅「退款成功」才回退权益;空串/未知/中间状态仅记录,防止误回退 succ, determinable := refund.Succeeded()
// (退款推送按官方文档在退款完成时触发,状态枚举联调时以官方为准)。 if !determinable {
if refund.RefundStatus != wechat.XPayRefundStatusSuccess { slog.ErrorContext(ctx, "xpay refund: refund result not determinable, ask wechat to retry",
slog.WarnContext(ctx, "xpay refund notify not success, skip revoke", "order_no", orderNo, "mch_refund_id", refund.MchRefundID, "wx_refund_id", refund.WxRefundID,
"order_no", refund.OutTradeNo, "refund_order_id", refund.RefundOrderID, "retry_times", refund.RetryTimes)
"refund_status", refund.RefundStatus, "refund_fee", refund.RefundFee) return errcode.OrderRealityCheckFailed
}
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, orderNo)
if gerr != nil {
if isNotFound(gerr) {
slog.ErrorContext(ctx, "xpay refund: order not found", "order_no", orderNo)
return errcode.OrderRealityCheckFailed
}
slog.ErrorContext(ctx, "xpay refund: query order failed", "order_no", orderNo, "error", gerr)
return errcode.DatabaseError
}
if !succ {
// 退款失败:订单条件回退「6 申请退款中 -> 2 已支付」,允许用户重新申请;
// 本地流水置失败并记录微信失败原因(无本地流水如 iOS 走 App Store 退款时跳过)。
reverted, rerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, OrderStatusRefunding, OrderStatusPaid)
if rerr != nil {
slog.ErrorContext(ctx, "xpay refund: revert order status failed",
"order_no", order.OrderNo, "mch_refund_id", refund.MchRefundID, "error", rerr)
}
s.markRefundFlowFailed(ctx, order, refund, raw)
slog.WarnContext(ctx, "xpay refund failed, order reverted to paid",
"order_no", order.OrderNo, "mch_refund_id", refund.MchRefundID, "ret_code", refundRetCodeText(refund),
"ret_msg", refund.RetMsg, "refund_fee", refund.RefundFee, "order_reverted", reverted)
return nil return nil
} }
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, refund.OutTradeNo) // 已终态则幂等返回;同时补一次流水回写,覆盖此前「订单已置 5 但流水未同步」的中途失败。
if gerr != nil {
if isNotFound(gerr) {
slog.ErrorContext(ctx, "xpay refund: order not found", "out_trade_no", refund.OutTradeNo)
return nil
}
slog.ErrorContext(ctx, "xpay refund: query order failed", "out_trade_no", refund.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
// 已终态则幂等返回。
if order.OrderStatus == OrderStatusRefunded { if order.OrderStatus == OrderStatusRefunded {
s.markRefundFlowSuccess(ctx, order, refund.WxRefundID, raw)
slog.DebugContext(ctx, "xpay refund: order already refunded, skip", "order_no", order.OrderNo) slog.DebugContext(ctx, "xpay refund: order already refunded, skip", "order_no", order.OrderNo)
return nil return nil
} }
@@ -417,15 +461,90 @@ func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XP
return verr return verr
} }
// settleRefundSuccess 内部含条件流转(Paid -> Refunded),重复推送不会重复回退。 // settleRefundSuccess 内部含条件流转(Paid/Refunding -> Refunded),重复推送不会重复回退。
// 内部失败已由 settleRefundSuccess 的使用方日志覆盖:此处接收返回值并记录,避免静默。 // 内部失败已由 settleRefundSuccess 的使用方日志覆盖:此处接收返回值并记录,避免静默。
if serr := s.settleRefundSuccess(ctx, order, refund.RefundFee); serr != nil { if serr := s.settleRefundSuccess(ctx, order, refund.RefundFee); serr != nil {
slog.ErrorContext(ctx, "xpay refund: settle refund success failed", slog.ErrorContext(ctx, "xpay refund: settle refund success failed",
"order_no", order.OrderNo, "refund_order_id", refund.RefundOrderID, "error", serr) "order_no", order.OrderNo, "mch_refund_id", refund.MchRefundID, "error", serr)
} }
// 订单已置 5:同步本地申请流水为成功,并回写微信退款单号与推送原始报文便于对账。
s.markRefundFlowSuccess(ctx, order, refund.WxRefundID, raw)
return nil return nil
} }
// refundRetCodeText 退款结果文本(日志与失败原因用):优先 RetCode,旧版式回退 RefundStatus。
func refundRetCodeText(refund *wechat.XPayRefundNotify) string {
if refund.RetCode != nil {
return "RetCode=" + strconv.Itoa(*refund.RetCode)
}
return "RefundStatus=" + refund.RefundStatus
}
// markRefundFlowSuccess 尽力把订单关联的退款流水置为成功(best-effort,仅记日志不返回错误),
// 同时回写微信退款单号与推送原始报文(对账/审计用)。无本地流水(如 iOS 走 App Store 退款)时静默跳过。
func (s *OrderService) markRefundFlowSuccess(ctx context.Context, order *mysqlmodel.Order, wechatRefundNo, raw string) {
flow, ferr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, order.OrderNo)
if ferr != nil {
if !isNotFound(ferr) {
slog.ErrorContext(ctx, "xpay refund: query refund flow failed", "order_no", order.OrderNo, "error", ferr)
}
return
}
updates := map[string]any{"status": RefundStatusSuccess, "success_time": nowUnix()}
if wechatRefundNo != "" {
updates["wechat_refund_no"] = wechatRefundNo
}
if raw != "" {
updates["callback_raw"] = truncateCallbackRaw(raw)
}
if _, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, flow.RefundNo, flow.Status, updates); uerr != nil {
slog.ErrorContext(ctx, "xpay refund: sync refund flow success failed",
"order_no", order.OrderNo, "refund_no", flow.RefundNo, "error", uerr)
}
}
// markRefundFlowFailed 尽力把订单关联的退款流水置为失败并记录微信失败原因(best-effort),无本地流水时跳过。
func (s *OrderService) markRefundFlowFailed(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify, raw string) {
flow, ferr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, order.OrderNo)
if ferr != nil {
if !isNotFound(ferr) {
slog.ErrorContext(ctx, "xpay refund: query refund flow failed", "order_no", order.OrderNo, "error", ferr)
}
return
}
if flow.Status == RefundStatusSuccess {
return // 已成功,不用失败态覆盖。
}
failReason := "虚拟支付退款失败:" + refundRetCodeText(refund)
if refund.RetMsg != "" {
failReason += "-" + refund.RetMsg
}
updates := map[string]any{"status": RefundStatusFailed, "fail_reason": failReason}
if refund.WxRefundID != "" {
updates["wechat_refund_no"] = refund.WxRefundID
}
if raw != "" {
updates["callback_raw"] = truncateCallbackRaw(raw)
}
if _, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, flow.RefundNo, flow.Status, updates); uerr != nil {
slog.ErrorContext(ctx, "xpay refund: mark refund flow failed",
"order_no", order.OrderNo, "refund_no", flow.RefundNo, "error", uerr)
}
}
// truncateCallbackRaw 截断回调原始报文,避免超长报文写入退款流水(按 rune 安全截断,防止产生非法 UTF-8)。
func truncateCallbackRaw(raw string) string {
const maxCallbackRawLen = 4096
if len(raw) <= maxCallbackRawLen {
return raw
}
cut := raw[:maxCallbackRawLen]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return cut
}
// handleIOSRefundQuery 处理 iOS 退款问询(3 秒内应答,仅本地查询)。 // handleIOSRefundQuery 处理 iOS 退款问询(3 秒内应答,仅本地查询)。
// 按权益使用情况细化:权益已消耗则拒绝退款(result_code=1 + evidence),未消耗则建议退款; // 按权益使用情况细化:权益已消耗则拒绝退款(result_code=1 + evidence),未消耗则建议退款;
// 建议退款后权益由后续退款推送 settleRefundSuccess 回收(回收链路保证「已消耗部分不回退」)。 // 建议退款后权益由后续退款推送 settleRefundSuccess 回收(回收链路保证「已消耗部分不回退」)。
+2
View File
@@ -67,6 +67,8 @@ func main() {
service.StartClothesProcessingRecoveryScheduler() service.StartClothesProcessingRecoveryScheduler()
service.StartOrderExpireScheduler() service.StartOrderExpireScheduler()
service.StartVirtualPayConfirmScheduler() service.StartVirtualPayConfirmScheduler()
// 虚拟支付退款结果查单兜底:退款推送丢失/未识别时避免订单永久停留在「申请退款中」。
service.StartVirtualRefundConfirmScheduler()
// 清理超期未保存的 AI 推荐穿搭(status=3),避免长期堆积。 // 清理超期未保存的 AI 推荐穿搭(status=3),避免长期堆积。
service.StartAIOutfitCleanupScheduler() service.StartAIOutfitCleanupScheduler()
+1 -1
View File
@@ -259,7 +259,7 @@ CREATE TABLE `orders` (
`total_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '订单总金额(分)', `total_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '订单总金额(分)',
`pay_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '实付金额(分)', `pay_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '实付金额(分)',
`pay_channel` TINYINT NOT NULL DEFAULT 0 COMMENT '支付渠道 1=微信支付 2=微信虚拟支付', `pay_channel` TINYINT NOT NULL DEFAULT 0 COMMENT '支付渠道 1=微信支付 2=微信虚拟支付',
`order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款', `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中',
`transaction_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信支付流水号', `transaction_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信支付流水号',
`member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '购买的会员天数(快照)', `member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '购买的会员天数(快照)',
`goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型快照', `goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型快照',
@@ -0,0 +1,3 @@
-- 回滚:将 orders.order_status 列注释还原为不含 6=申请退款中 的文案。
ALTER TABLE `orders`
MODIFY COLUMN `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款';
@@ -0,0 +1,3 @@
-- 订单状态新增 6=申请退款中:仅更新列注释,便于运营按状态筛选待处理退款单,无数据回填。
ALTER TABLE `orders`
MODIFY COLUMN `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中';
+70 -8
View File
@@ -394,10 +394,12 @@ func IsOrderRefundedStatus(status int) bool {
return status == XPayOrderStatusRefunded || status == XPayOrderStatusUserRefunded return status == XPayOrderStatusRefunded || status == XPayOrderStatusUserRefunded
} }
// 虚拟支付退款推送的退款状态(RefundStatus 字段,以微信官方枚举为准,联调时校正)。 // 虚拟支付退款推送(xpay_refund_notify)的退款结果字段:官方以 RetCode 判定,
const ( // RetCode=0 表示退款成功,非 0 表示失败(失败原因见 RetMsg)。
XPayRefundStatusSuccess = "SUCCESS" // 退款成功 const XPayRefundRetCodeSuccess = 0
)
// XPayRefundStatusSuccess 旧版式退款推送的 RefundStatus 取值(官方现行报文已无该字段,保留兼容解析)。
const XPayRefundStatusSuccess = "SUCCESS"
// QueryOrder 查询虚拟支付订单(orderID 与 wxOrderID 二选一,非空者优先;作用于激活环境)。 // QueryOrder 查询虚拟支付订单(orderID 与 wxOrderID 二选一,非空者优先;作用于激活环境)。
func QueryOrder(ctx context.Context, openid, orderID, wxOrderID string) (*XPayOrderInfo, error) { func QueryOrder(ctx context.Context, openid, orderID, wxOrderID string) (*XPayOrderInfo, error) {
@@ -562,22 +564,82 @@ type XPayGoodsDeliverNotify struct {
} `json:"GoodsInfo"` } `json:"GoodsInfo"`
} }
// XPayRefundNotify 退款推送。 // XPayRefundNotify 退款推送(xpay_refund_notify)。
//
// 字段以官方《虚拟支付:企业、个体户 → 消息推送 → 退款推送》为准:
// 官方报文中**没有** OutTradeNo / RefundStatus / Env,订单号取 MchOrderId(原支付单商户单号,
// 即本地 orders.order_no),退款结果取 RetCode(0=成功,非 0=失败,原因见 RetMsg)。
// 同时保留 RefundOrderId / RefundStatus 等旧版式字段与非官方版式的 OutTradeNo,避免版式差异导致静默失败。
type XPayRefundNotify struct { type XPayRefundNotify struct {
XPayNotifyBase XPayNotifyBase
RefundStatus string `json:"RefundStatus"` // 退款状态(以官方枚举为准) WxRefundID string `json:"WxRefundId"` // 微信退款单号
RefundFee int64 `json:"RefundFee"` // 退款金额(分) MchRefundID string `json:"MchRefundId"` // 商户退款单号
RefundOrderID string `json:"RefundOrderId"` // 商户退款单号 WxOrderID string `json:"WxOrderId"` // 退款单对应支付单的微信单号
MchOrderID string `json:"MchOrderId"` // 退款单对应支付单的商户单号(= 本地订单号)
RefundFee int64 `json:"RefundFee"` // 退款金额(分)
RetCode *int `json:"RetCode"` // 退款结果:0=成功,非 0=失败;nil 表示报文未携带
RetMsg string `json:"RetMsg"` // 退款结果详情(失败原因)
RefundStartTimestamp int64 `json:"RefundStartTimestamp"` // 开始退款时间(秒级时间戳)
RefundSuccTimestamp int64 `json:"RefundSuccTimestamp"` // 结束退款时间(秒级时间戳)
WxpayRefundTransactionID string `json:"WxpayRefundTransactionId"` // 退款单的微信支付单号
RetryTimes int `json:"RetryTimes"` // 微信重试次数(从 0 开始)
Attach string `json:"Attach"` // iOS 退款推送附带:下单 attach(选填)
WxTransactionID string `json:"WxTransactionId"` // iOS 退款推送附带:原支付单 TransactionId(选填)
// ---- 旧版式兼容字段(官方现行报文已无,保留解析以防版式回退)----
RefundStatus string `json:"RefundStatus"` // 退款状态(旧版式,SUCCESS=成功)
RefundOrderID string `json:"RefundOrderId"` // 商户退款单号(旧版式)
}
// OrderNo 取商户订单号:官方字段 MchOrderId 优先,旧版式回退公共字段 OutTradeNo。
func (n *XPayRefundNotify) OrderNo() string {
if n.MchOrderID != "" {
return n.MchOrderID
}
return n.OutTradeNo
}
// RefundNo 取商户退款单号:官方字段 MchRefundId 优先,依次回退旧版式字段与微信退款单号(仅用于幂等锁与日志)。
func (n *XPayRefundNotify) RefundNo() string {
switch {
case n.MchRefundID != "":
return n.MchRefundID
case n.RefundOrderID != "":
return n.RefundOrderID
default:
return n.WxRefundID
}
}
// Succeeded 判定退款结果:官方以 RetCode=0 表示成功;旧版式回退 RefundStatus(大小写不敏感 SUCCESS)。
// determinable=false 表示报文未携带任何可判定字段,调用方应 fail-closed(应答失败触发微信重试)。
func (n *XPayRefundNotify) Succeeded() (ok bool, determinable bool) {
if n.RetCode != nil {
return *n.RetCode == XPayRefundRetCodeSuccess, true
}
if n.RefundStatus != "" {
return strings.EqualFold(n.RefundStatus, XPayRefundStatusSuccess), true
}
return false, false
} }
// XPayIOSRefundQueryNotify iOS 退款问询推送。 // XPayIOSRefundQueryNotify iOS 退款问询推送。
type XPayIOSRefundQueryNotify struct { type XPayIOSRefundQueryNotify struct {
XPayNotifyBase XPayNotifyBase
// 字段以官方文档为准;此处解析退款问询所需的最小信息。 // 字段以官方文档为准;此处解析退款问询所需的最小信息。
MchOrderID string `json:"MchOrderId"` // 兼容:部分版式用商户单号命名
RefundOrderID string `json:"RefundOrderId"` RefundOrderID string `json:"RefundOrderId"`
RefundFee int64 `json:"RefundFee"` RefundFee int64 `json:"RefundFee"`
} }
// OrderNo 取商户订单号:公共字段 OutTradeNo 优先(保持既有行为),为空时回退 MchOrderId。
func (n *XPayIOSRefundQueryNotify) OrderNo() string {
if n.OutTradeNo != "" {
return n.OutTradeNo
}
return n.MchOrderID
}
// ParseNotify 解析消息推送 JSON 并返回事件类型与公共字段。 // ParseNotify 解析消息推送 JSON 并返回事件类型与公共字段。
// 返回值为 base(公共字段)+ 原始 body,调用方再按 Event 分发到具体结构。 // 返回值为 base(公共字段)+ 原始 body,调用方再按 Event 分发到具体结构。
func ParseNotify(raw []byte) (*XPayNotifyBase, error) { func ParseNotify(raw []byte) (*XPayNotifyBase, error) {
+123
View File
@@ -252,6 +252,129 @@ func TestXPayOrderInfoUnmarshal(t *testing.T) {
} }
} }
// TestXPayRefundNotifyOfficialPayload 官方退款推送报文:订单号取 MchOrderId、退款单号取 MchRefundId、
// 结果由 RetCode=0 判定为成功(回归防护:早期误用 OutTradeNo/RefundStatus 导致取不到订单号而静默丢单)。
func TestXPayRefundNotifyOfficialPayload(t *testing.T) {
raw := []byte(`{
"ToUserName":"gh_test",
"FromUserName":"official_openid",
"CreateTime":1789500611,
"MsgType":"event",
"Event":"xpay_refund_notify",
"OpenId":"o_user",
"WxRefundId":"wx_refund_1",
"MchRefundId":"RF20260916033011abcdef",
"WxOrderId":"wx_order_1",
"MchOrderId":"OD20260916030000fc1cfee91b3748a2",
"RefundFee":990,
"RetCode":0,
"RetMsg":"",
"RefundStartTimestamp":1789500600,
"RefundSuccTimestamp":1789500611,
"WxpayRefundTransactionId":"wxpay_refund_1",
"RetryTimes":0
}`)
var refund XPayRefundNotify
if err := json.Unmarshal(raw, &refund); err != nil {
t.Fatalf("unmarshal refund notify: %v", err)
}
if got := refund.OrderNo(); got != "OD20260916030000fc1cfee91b3748a2" {
t.Fatalf("OrderNo() = %q", got)
}
if got := refund.RefundNo(); got != "RF20260916033011abcdef" {
t.Fatalf("RefundNo() = %q", got)
}
if refund.RefundFee != 990 {
t.Fatalf("RefundFee = %d, want 990", refund.RefundFee)
}
succ, determinable := refund.Succeeded()
if !determinable || !succ {
t.Fatalf("Succeeded() = (%v, %v), want (true, true)", succ, determinable)
}
}
// TestXPayRefundNotifyFailurePayload 官方失败报文:RetCode 非 0 即退款失败,原因在 RetMsg。
func TestXPayRefundNotifyFailurePayload(t *testing.T) {
raw := []byte(`{
"Event":"xpay_refund_notify",
"OpenId":"o_user",
"MchOrderId":"OD1",
"MchRefundId":"RF1",
"RefundFee":990,
"RetCode":268490005,
"RetMsg":"已退款不可再退"
}`)
var refund XPayRefundNotify
if err := json.Unmarshal(raw, &refund); err != nil {
t.Fatalf("unmarshal refund notify: %v", err)
}
succ, determinable := refund.Succeeded()
if !determinable || succ {
t.Fatalf("Succeeded() = (%v, %v), want (false, true)", succ, determinable)
}
if refund.RetMsg == "" {
t.Fatal("RetMsg should be preserved for fail_reason")
}
}
// TestXPayRefundNotifyLegacyPayload 旧版式报文(OutTradeNo + RefundStatus)仍可解析并判定(大小写不敏感)。
func TestXPayRefundNotifyLegacyPayload(t *testing.T) {
raw := []byte(`{
"Event":"xpay_refund_notify",
"OutTradeNo":"OD-LEGACY",
"RefundOrderId":"RF-LEGACY",
"RefundStatus":"success",
"RefundFee":100
}`)
var refund XPayRefundNotify
if err := json.Unmarshal(raw, &refund); err != nil {
t.Fatalf("unmarshal refund notify: %v", err)
}
if got := refund.OrderNo(); got != "OD-LEGACY" {
t.Fatalf("OrderNo() = %q, want OD-LEGACY", got)
}
if got := refund.RefundNo(); got != "RF-LEGACY" {
t.Fatalf("RefundNo() = %q, want RF-LEGACY", got)
}
succ, determinable := refund.Succeeded()
if !determinable || !succ {
t.Fatalf("Succeeded() = (%v, %v), want (true, true)", succ, determinable)
}
}
// TestXPayRefundNotifyUndeterminable 报文未携带 RetCode 与 RefundStatus 时必须报「不可判定」,
// 调用方据此应答失败触发微信重试(而非静默按成功处理)。
func TestXPayRefundNotifyUndeterminable(t *testing.T) {
raw := []byte(`{"Event":"xpay_refund_notify","MchOrderId":"OD1","RefundFee":100}`)
var refund XPayRefundNotify
if err := json.Unmarshal(raw, &refund); err != nil {
t.Fatalf("unmarshal refund notify: %v", err)
}
succ, determinable := refund.Succeeded()
if determinable || succ {
t.Fatalf("Succeeded() = (%v, %v), want (false, false)", succ, determinable)
}
if got := refund.RefundNo(); got != "" {
t.Fatalf("RefundNo() = %q, want empty", got)
}
}
// TestXPayIOSRefundQueryOrderNoFallback iOS 退款问询推送订单号:OutTradeNo 优先,为空回退 MchOrderId。
func TestXPayIOSRefundQueryOrderNoFallback(t *testing.T) {
withOutTradeNo := XPayIOSRefundQueryNotify{XPayNotifyBase: XPayNotifyBase{OutTradeNo: "OD-A"}, MchOrderID: "OD-B"}
if got := withOutTradeNo.OrderNo(); got != "OD-A" {
t.Fatalf("OrderNo() = %q, want OD-A (OutTradeNo 优先)", got)
}
onlyMch := XPayIOSRefundQueryNotify{MchOrderID: "OD-B"}
if got := onlyMch.OrderNo(); got != "OD-B" {
t.Fatalf("OrderNo() = %q, want OD-B (回退 MchOrderId)", got)
}
}
// TestResolveEnvKey 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。 // TestResolveEnvKey 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。
func TestResolveEnvKey(t *testing.T) { func TestResolveEnvKey(t *testing.T) {
// 只填现网 -> 现网 // 只填现网 -> 现网
+49 -15
View File
@@ -228,7 +228,7 @@ Authorization: Bearer <access_token>
| 衣物 AI 处理 | `GET /clothes/:uuid/status` | `4` 成功 / `5` 失败(`1/2/3` 继续轮询) | 1~2 秒,建议上限 60 次 | | 衣物 AI 处理 | `GET /clothes/:uuid/status` | `4` 成功 / `5` 失败(`1/2/3` 继续轮询) | 1~2 秒,建议上限 60 次 |
| AI 推荐生成 | `GET /ai-recommend/status` | `2` 成功 / `3` 失败(`1` 继续轮询) | 2 秒,建议上限 90 次 | | AI 推荐生成 | `GET /ai-recommend/status` | `2` 成功 / `3` 失败(`1` 继续轮询) | 2 秒,建议上限 90 次 |
| 支付结果 | `GET /order`(按 `status` 或 `order_no` 过滤) | `2` 已支付 / `3` 已取消 / `4` 已过期 | 支付后 1 秒起,最多 15 分钟 | | 支付结果 | `GET /order`(按 `status` 或 `order_no` 过滤) | `2` 已支付 / `3` 已取消 / `4` 已过期 | 支付后 1 秒起,最多 15 分钟 |
| 退款进度 | `GET /order` | `5` 已退款 | 30 秒(人工审核,可能很久) | | 退款进度 | `GET /order` | `5` 已退款(申请后为 `6` 申请退款中,继续轮询;回到 `2` 表示退款失败/被驳回) | 30 秒(人工审核,可能很久) |
| 今日运势未生成 | `GET /horoscope/today` | 返回数据(首次可能 `90300`) | 3~5 秒,最多 3 次 | | 今日运势未生成 | `GET /horoscope/today` | 返回数据(首次可能 `90300`) | 3~5 秒,最多 3 次 |
| 未读通知数 | `GET /notification/unread` | 无终态,按需刷新 | 进入页面时 / 30~60 秒 | | 未读通知数 | `GET /notification/unread` | 无终态,按需刷新 | 进入页面时 / 30~60 秒 |
@@ -452,7 +452,7 @@ Authorization: Bearer <access_token>
| unit_price | int64 | 单价(**分**) | | unit_price | int64 | 单价(**分**) |
| pay_amount | int64 | 实付金额(**分**) | | pay_amount | int64 | 实付金额(**分**) |
| pay_channel | int | 支付渠道 `1`=微信支付(JSAPI) `2`=虚拟支付 | | pay_channel | int | 支付渠道 `1`=微信支付(JSAPI) `2`=虚拟支付 |
| order_status | int | `1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 | | order_status | int | `1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 `6`=申请退款中 |
| member_days | int64 | 订单快照:会员天数 | | member_days | int64 | 订单快照:会员天数 |
| expire_time | int64 | 支付截止时间(秒),前端倒计时用(15 分钟) | | expire_time | int64 | 支付截止时间(秒),前端倒计时用(15 分钟) |
| pay_time | int64 | 支付时间(秒),未支付为 0 | | pay_time | int64 | 支付时间(秒),未支付为 0 |
@@ -639,7 +639,7 @@ Authorization: Bearer <access_token>
| 3 | 社区分享额度(搭配分享) | 每日 | 10 | 穿搭首次公开扣 1 | | 3 | 社区分享额度(搭配分享) | 每日 | 10 | 穿搭首次公开扣 1 |
| 4 | 衣柜分享额度 | 每日 | 5 | 创建一次衣柜分享扣 1 | | 4 | 衣柜分享额度 | 每日 | 5 | 创建一次衣柜分享扣 1 |
**② 额度凭证**(`quota_grants`)—— 有有效期的赠送额度,按来源分三类: **② 额度凭证**(`quota_grants`)—— 有有效期的赠送额度,按来源分四类:
| source | 来源 | 有效期 | | source | 来源 | 有效期 |
| --- | --- | --- | | --- | --- | --- |
@@ -650,7 +650,8 @@ Authorization: Bearer <access_token>
> 新用户注册时,`NewUserGift` 段配置的体验会员额度以 **source=4** 凭证发放;`GiftVIPDay` 配 0(不赠送会员)时这部分额度一律不发放。 > 新用户注册时,`NewUserGift` 段配置的体验会员额度以 **source=4** 凭证发放;`GiftVIPDay` 配 0(不赠送会员)时这部分额度一律不发放。
> ⚠️ 体验额度**不会**被后续充值「继承」:用户 3 天体验期后再购买会员,体验额度照常失效,只剩免费额度 + 新购会员包额度。 > ⚠️ 体验额度**不会**被后续充值「继承」:用户 3 天体验期后再购买会员,体验额度照常失效,只剩免费额度 + 新购会员包额度。
> Cos 消费先扣「即将过期凭证」再扣免费额度;每日额度总额 = 免费配置 + 有效凭证赠送额。 > ⚠️ **会员包来源(source=1)的额度不叠加**:月度包与年度包同时持有时,同类额度只取 **amount 最大的一份**计入(月/年额度相同时即等价于只按一份计算),避免额度翻倍;该份**用尽后自动顺延**到下一份有效会员包凭证,已购额度不浪费。加量包(source=2)、广告激励(source=3)、体验额度(source=4)仍按原逻辑**叠加求和**。
> Cos 消费先扣「非会员包凭证(先到期先用)」再扣「会员包生效份」,最后扣免费额度;未生效的会员包份不参与扣减与回退。每日额度总额 = 免费配置 + 会员包生效份 + 其他来源凭证赠送额。
#### GET `/quota/info` — 额度概览(鉴权) #### GET `/quota/info` — 额度概览(鉴权)
@@ -1707,7 +1708,7 @@ Authorization: Bearer <access_token>
### 5.12 订单与退款模块 ### 5.12 订单与退款模块
**`order_status` 枚举**:`1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 **`order_status` 枚举**:`1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 `6`=申请退款中(已受理退款申请,待运营审核并手动发起退款)
**退款 `status` 枚举**:`0`=待处理 `1`=退款中 `2`=退款成功 `3`=退款失败 **退款 `status` 枚举**:`0`=待处理 `1`=退款中 `2`=退款成功 `3`=退款失败
#### POST `/order` — 创建订单并发起微信支付(鉴权) #### POST `/order` — 创建订单并发起微信支付(鉴权)
@@ -1798,7 +1799,7 @@ wx.requestVirtualPayment({
| 参数 | 类型 | 必填 | 约束 | 说明 | | 参数 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| page / page_size | int | 否 | — | 分页 | | page / page_size | int | 否 | — | 分页 |
| status | int | 否 | `oneof=1 2 3 4 5` | 订单状态筛选;不传 = 全部 | | status | int | 否 | `oneof=1 2 3 4 5 6` | 订单状态筛选(`6`=申请退款中);不传 = 全部 |
**响应** `data.list[]` = [订单对象](#45-订单对象-orderitem) **响应** `data.list[]` = [订单对象](#45-订单对象-orderitem)
@@ -1833,11 +1834,15 @@ wx.requestVirtualPayment({
**业务逻辑(重要:人工审核模式)** **业务逻辑(重要:人工审核模式)**
- **iOS 虚拟支付订单不可主动退款** → `71004`,请引导用户走 App Store 申请退款(Apple 通过退款问询推送回收权益)。 - **iOS 虚拟支付订单不可主动退款** → `71004`,请引导用户走 App Store 申请退款(Apple 通过退款问询推送回收权益)。
- **仅 `2` 已支付**订单可申请,其它状态 → `71001`。 - **仅 `2` 已支付**订单可申请,其它状态(含申请中的 `6`)→ `71001`。
- ⚠️ **系统不会自动调微信退款**:接口只是登记退款申请(`status=0` 待处理),**需运营人工审核并手动发起退款**——普通支付订单在微信支付商户后台退款,**虚拟支付订单(`pay_channel=2`)请在虚拟支付后台(Midas)发起退款**,退款结果由推送回调同步。 - ✅ **受理成功后订单立即置 `6` 申请退款中**:订单状态流转与退款流水(`status=0` 待处理)在同一事务内完成;重复/并发申请只有一次受理成功,其余返回 `71001`。前端刷新订单列表即可看到"退款审核中"。
- 前端应提示"**退款审核中**",并允许轮询订单状态直到 `5` 已退款;**不要承诺实时到账**。 - ⚠️ **系统不会自动调微信退款**:接口只是登记退款申请(流水 `status=0` 待处理),**需运营人工审核并手动发起退款**——普通支付订单在微信支付商户后台退款,**虚拟支付订单(`pay_channel=2`)请在虚拟支付后台(Midas)发起退款**,退款结果由推送回调同步。
- 退款结果由回调驱动(`pay_channel=1` 走微信支付 V3 退款通知;`pay_channel=2` 走虚拟支付退款推送 `xpay_refund_notify`,以 `RetCode=0` 判定成功):**成功** → 订单 `5` 已退款(回收权益 + 发退款通知);**失败** → 退款流水置 `3` 失败、订单回退 `2` 已支付,用户可重新申请。
- 前端应提示"**退款审核中**"并轮询订单状态:`6` 继续轮询、`5` 为终态、回到 `2` 表示退款失败/被驳回(可再次申请);**不要承诺实时到账**。
- 同一订单已有退款记录且状态不是 `3`(失败)→ `71001`;上次失败可重新申请(复用记录、换新退款单号)。 - 同一订单已有退款记录且状态不是 `3`(失败)→ `71001`;上次失败可重新申请(复用记录、换新退款单号)。
- 退款成功后,订单置 `5`,**会员天数与已发放额度会被回收**(已消耗部分不回收),并发退款通知。 - 退款成功后,**会员天数与已发放额度会被回收**(已消耗部分不回收),并发退款通知。
- 🛟 **兜底**:虚拟支付退款结果除推送外还有**查单兜底任务**(每 60 秒扫描停留超 10 分钟的「申请退款中」订单反查 `query_order`)——微信侧已退款会自动订正为 `5` 并回收权益;微信侧仍未退款则保持 `6`,超过 24 小时输出告警交人工。**推送丢失也不会导致订单永久卡单**。
- ⚠️ **运营线下拒绝**(未发起微信退款)不会产生回调,订单会停留在 `6` 申请退款中:兜底任务会持续核对并对超期订单告警,需运营在后台发起退款,或由研发人工订正。
**请求**(json) **请求**(json)
@@ -1865,7 +1870,28 @@ wx.requestVirtualPayment({
- 不走统一 `{code,msg,data}` 响应包装,按微信 V3 规范返回:成功 HTTP 200 `{"code":"SUCCESS","message":"成功"}`;失败 HTTP 500 `{"code":"FAIL","message":"<原因>"}`。 - 不走统一 `{code,msg,data}` 响应包装,按微信 V3 规范返回:成功 HTTP 200 `{"code":"SUCCESS","message":"成功"}`;失败 HTTP 500 `{"code":"FAIL","message":"<原因>"}`。
- 服务端处理:验签 + 解密 → 幂等锁(30 秒)→ **金额校验**(与订单金额不一致则拒绝流转、人工介入)→ 流转订单状态 → 发放/回收权益 → 发站内通知。 - 服务端处理:验签 + 解密 → 幂等锁(30 秒)→ **金额校验**(与订单金额不一致则拒绝流转、人工介入)→ 流转订单状态 → 发放/回收权益 → 发站内通知。
- 竞态兜底:已过期但已付款 → 补发货;已取消但已付款 → **自动发起微信退款**。 - 竞态兜底:已过期但已付款 → 补发货;已取消但已付款 → 生成**待审核退款单**(订单保持 `3` 已取消,其权益从未发放),由管理员手动退款。
---
#### POST `/xpay/notify` — 虚拟支付消息推送(公开)
🚫 **这是微信服务端推送地址,前端绝对不要调用。**
- 事件类型(`Event` 字段):`xpay_goods_deliver_notify` 道具发货、`xpay_refund_notify` 退款完成、`xpay_subscribe_ios_refund_query_notify` iOS 退款问询(需 3 秒内应答);代币支付/投诉/风控等其余事件仅记录不处理。
- 应答按虚拟支付规范:`{"ErrCode":0,"ErrMsg":"success"}` 表示处理完成;`{"ErrCode":1}` 表示处理失败,**微信会按 2/4/8/16… 秒重试,最多 15 次**。服务端对「关键字段缺失 / 订单查不到 / 退款结果无法判定」一律应答失败以触发重试,不再静默应答成功。
- **退款推送 `xpay_refund_notify` 的官方字段**(易错点,勿按普通支付回调的字段理解):
| 字段 | 说明 |
| --- | --- |
| MchOrderId | 原支付单**商户单号**(= 本地 `order_no`);该推送**没有** `OutTradeNo` |
| MchRefundId | 商户退款单号 |
| WxRefundId | 微信退款单号 |
| RefundFee | 退款金额(分) |
| RetCode | **退款结果:`0`=成功,非 0=失败**;该推送**没有** `RefundStatus` |
| RetMsg | 退款失败原因 |
- 处理链路:定位订单 → 幂等锁 → `RetCode=0` 时**反查 `query_order` 确认真实退款且金额一致**(推送无签名,防伪造回收权益)→ 订单 `6 → 5` + 回收权益 + 发通知 + 退款流水回写微信退款单号与原始报文;`RetCode≠0` 时订单回退 `2 已支付` + 退款流水置失败(原因取 `RetMsg`)。
--- ---
## 6. 业务逻辑与状态机 ## 6. 业务逻辑与状态机
@@ -1987,6 +2013,9 @@ stateDiagram-v2
pending --> cancelled : 用户主动取消(同步关闭微信订单) pending --> cancelled : 用户主动取消(同步关闭微信订单)
expired --> paid : 过期后付款到账(补发货) expired --> paid : 过期后付款到账(补发货)
cancelled --> refunded : 已取消但已付款 → 生成待审核退款单,管理员手动退款 cancelled --> refunded : 已取消但已付款 → 生成待审核退款单,管理员手动退款
paid --> refunding : 用户申请退款(POST /order/refund,同事务写退款流水)
refunding --> refunded : 退款成功回调(回收权益 → 通知)
refunding --> paid : 退款失败/被驳回(流水置失败,可重新申请)
paid --> refunded : 退款成功回调(回收权益 → 通知) paid --> refunded : 退款成功回调(回收权益 → 通知)
state "1 待支付" as pending state "1 待支付" as pending
@@ -1994,14 +2023,16 @@ stateDiagram-v2
state "3 已取消" as cancelled state "3 已取消" as cancelled
state "4 已过期" as expired state "4 已过期" as expired
state "5 已退款" as refunded state "5 已退款" as refunded
state "6 申请退款中" as refunding
``` ```
**关键规则** **关键规则**
- **金额校验**:回调金额与订单 `pay_amount` 不一致 → 拒绝流转、不发货、转人工。 - **金额校验**:回调金额与订单 `pay_amount` 不一致 → 拒绝流转、不发货、转人工。
- **权益发放**:会员天数按快照实际发放,会员类型按**续费后最终到期时长**推导(剩余 ≥365 天为年度,否则月度,避免年度会员续月包被降级);上传 / AI / 衣柜分享 / 搭配分享额度改为写入**额度凭证** `quota_grants`(会员包 `expire_time=0` 跟随 VIP、加量包按 `goods.valid_days` 独立到期)。 - **权益发放**:会员天数按快照实际发放,会员类型按**续费后最终到期时长**推导(剩余 ≥365 天为年度,否则月度,避免年度会员续月包被降级);上传 / AI / 衣柜分享 / 搭配分享额度改为写入**额度凭证** `quota_grants`(会员包 `expire_time=0` 跟随 VIP、加量包按 `goods.valid_days` 独立到期)。**会员包来源额度不叠加**:月度包/年度包同时持有时同类额度只取 amount 最大的一份,该份用尽后自动顺延下一份。
- **幂等 + 补偿**:`benefit_grant_tasks` 唯一索引 + "发放中"状态防并发;失败自动落任务,**每分钟重试**,重试 3 次仍失败则标记失败并告警人工介入。 - **幂等 + 补偿**:`benefit_grant_tasks` 唯一索引 + "发放中"状态防并发;失败自动落任务,**每分钟重试**,重试 3 次仍失败则标记失败并告警人工介入。
- **退款回收**:会员天数按快照回退(不回退到过去);赠送额度通过**失效凭证**回收未用部分,**已消耗部分不追回**。 - **退款回收**:会员天数按快照回退(不回退到过去);赠送额度通过**失效凭证**回收未用部分,**已消耗部分不追回**。
- **退款状态流转**:`POST /order/refund` 受理后订单 `2 → 6`(申请退款中,同一事务写入退款流水,行锁防重复受理);退款成功回调 `6 → 5` 并回收权益;微信退款失败回调 `6 → 2` 且流水置失败,用户可重新申请。运营线下直接拒绝不会产生回调,订单会停留在 `6`,需人工介入。
--- ---
@@ -2315,7 +2346,7 @@ sequenceDiagram
| 79002 | 更新订单失败 | 200 | | 79002 | 更新订单失败 | 200 |
| 79003 | 创建虚拟支付订单失败 | 200 | | 79003 | 创建虚拟支付订单失败 | 200 |
| 71000 | 退款记录不存在 | 200 | | 71000 | 退款记录不存在 | 200 |
| 71001 | 当前状态不允许退款 | 200 | | 71001 | 当前状态不允许退款(非已支付或已有进行中的退款申请) | 200 |
| 71002 | 退款金额不合法 | 200 | | 71002 | 退款金额不合法 | 200 |
| 71003 | 退款申请失败 | 200 | | 71003 | 退款申请失败 | 200 |
| 71004 | iOS 订单请通过 App Store 申请退款 | 200 | | 71004 | iOS 订单请通过 App Store 申请退款 | 200 |
@@ -2383,7 +2414,10 @@ A:不能。额度在**生成时**已消耗,只有生成失败才回退。前
A:以服务端为准。微信回调是异步的,**轮询 `GET /order` 直到 `order_status=2`**。 A:以服务端为准。微信回调是异步的,**轮询 `GET /order` 直到 `order_status=2`**。
**Q:退款申请后一直没到账?** **Q:退款申请后一直没到账?**
A:退款是**人工审核模式**,接口只登记申请(`status=0`)。前端提示"退款审核中",并允许轮询订单状态到 `5`。 A:退款是**人工审核模式**,接口只登记申请(退款流水 `status=0`)。申请成功后订单为 `6` 申请退款中,前端提示"退款审核中"并轮询订单状态:`5` 已退款为终态;若回到 `2` 已支付,说明退款失败/被驳回,可引导用户重新申请。
**Q:虚拟支付退款推送丢了 / 解析失败,会不会一直卡在「申请退款中」?**
A:不会。退款推送处理对无法判定的报文会应答失败触发微信重试(间隔 2/4/8/16… 最多 15 次);同时后台**查单兜底任务**每 60 秒反查停留超 10 分钟的「申请退款中」订单——微信侧已退款会自动订正为 `5 已退款` 并回收权益;若微信侧确实未退款则保持 `6`,超过 24 小时输出告警交人工介入。
**Q:衣柜分享列表里显示"正常",但对方打不开?** **Q:衣柜分享列表里显示"正常",但对方打不开?**
A:过期是**惰性**的。前端需同时判断 `status` 与 `expire_time`。 A:过期是**惰性**的。前端需同时判断 `status` 与 `expire_time`。
@@ -2402,7 +2436,7 @@ A:调用 `GET /outfit/community` 分页获取其他用户已公开(公开 +
- [ ] 金额展示 `÷100`,时间展示 `×1000` - [ ] 金额展示 `÷100`,时间展示 `×1000`
- [ ] 上传后轮询状态(1~2 秒,最多 60 次),展示处理中/失败态 - [ ] 上传后轮询状态(1~2 秒,最多 60 次),展示处理中/失败态
- [ ] 支付后轮询订单状态,不依赖支付组件回调 - [ ] 支付后轮询订单状态,不依赖支付组件回调
- [ ] 退款提示"人工审核中" - [ ] 退款提示"人工审核中"(申请后订单状态 `6`,轮询到 `5` 为终态,回到 `2` 表示失败可重申请)
- [ ] 公开穿搭前提示将消耗 1 次搭配分享额度 - [ ] 公开穿搭前提示将消耗 1 次搭配分享额度
- [ ] AI 推荐前提示将消耗 1 次 AI 额度,且**不保存不退** - [ ] AI 推荐前提示将消耗 1 次 AI 额度,且**不保存不退**
- [ ] 点赞/收藏按钮做乐观更新 + 失败回滚(接口幂等,可重试) - [ ] 点赞/收藏按钮做乐观更新 + 失败回滚(接口幂等,可重试)