feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
This commit is contained in:
@@ -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: 输出完整的引用清单与需同步修改的位置,避免遗漏消费点导致状态机断裂
|
||||
@@ -337,7 +337,10 @@ stateDiagram-v2
|
||||
pending --> expired : 超时 15 分钟(后台每分钟扫描)
|
||||
pending --> cancelled : 用户主动取消(同步关闭微信订单)
|
||||
expired --> paid : 过期后付款到账(补发货)
|
||||
cancelled --> refunded : 已付款的取消订单自动发起退款
|
||||
cancelled --> refunded : 已付款的取消订单生成待审核退款单(管理员手动退款)
|
||||
paid --> refunding : 用户申请退款(同事务写待审核退款单)
|
||||
refunding --> refunded : 退款成功回调(回收权益 → 通知)
|
||||
refunding --> paid : 退款失败/被驳回(流水置失败,可重新申请)
|
||||
paid --> refunded : 退款成功回调(回收权益 → 通知)
|
||||
|
||||
state "1 待支付" as pending
|
||||
@@ -345,6 +348,7 @@ stateDiagram-v2
|
||||
state "3 已取消" as cancelled
|
||||
state "4 已过期" as expired
|
||||
state "5 已退款" as refunded
|
||||
state "6 申请退款中" as refunding
|
||||
```
|
||||
|
||||
处理流程:
|
||||
@@ -352,11 +356,11 @@ stateDiagram-v2
|
||||
1. **商品**:5 类商品(`1` 会员包 / `2` 上传包 / `3` AI 推荐包 / `4` 衣柜分享包 / `5` 搭配分享包),价格单位为**分**;仅 `1 上架` 状态可购买。
|
||||
2. **下单**:实际支付金额取 `discount_price`;下单时把商品权益快照(会员天数/各类额度)冗余进订单,防止商品后续变更导致错发;先落本地待支付订单再调微信 JSAPI,微信下单失败时订单保留并靠超时自动过期。同一用户可并存多个待支付订单(无防重限制)。
|
||||
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` 的订单批量置为已过期(条件更新天然幂等);过期后无权益可回收。
|
||||
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 次仍失败则标记失败 + 告警人工介入;重试前先查发放流水幂等。
|
||||
8. **额度变更统一收口**:Cos 永久额度扣减/回退封装在事务内「凭证扣减(先到期先用)+ 免费额度扣减 + 写流水」强一致;每日额度(AI/分享/衣柜分享)由 Redis 原子限流计数(当天 0 点自然过期),MySQL `user_daily_usages` 兜底统计。
|
||||
8. **额度变更统一收口**:Cos 永久额度扣减/回退封装在事务内「凭证扣减(非会员包先到期先用 → 会员包生效份)+ 免费额度扣减 + 写流水」强一致(会员包来源不叠加,未生效份不参与扣减/回退);每日额度(AI/分享/衣柜分享)由 Redis 原子限流计数(当天 0 点自然过期),MySQL `user_daily_usages` 兜底统计。
|
||||
|
||||
### 6. AI 推荐
|
||||
|
||||
@@ -641,7 +645,7 @@ flowchart TD
|
||||
| goods_name | string | 商品名称 |
|
||||
| unit_price | int64 | 单价(分) |
|
||||
| pay_amount | int64 | 实付金额(分) |
|
||||
| order_status | int | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 |
|
||||
| order_status | int | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中 |
|
||||
| member_days | int64 | 会员天数 |
|
||||
| expire_time | int64 | 支付截止时间戳(秒) |
|
||||
| pay_time | int64 | 支付时间戳(秒) |
|
||||
@@ -1152,7 +1156,7 @@ multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),
|
||||
|
||||
#### 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` —— 创建订单并发起微信支付(需鉴权)**
|
||||
|
||||
@@ -1180,7 +1184,7 @@ multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| page / page_size | int | 否 | 分页 |
|
||||
| status | int | 否 | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款;不传 = 全部 |
|
||||
| status | int | 否 | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中;不传 = 全部 |
|
||||
|
||||
响应 `data.list` 元素 = 订单对象(见公共结构)。
|
||||
|
||||
@@ -1430,7 +1434,7 @@ sequenceDiagram
|
||||
2. 前端用响应中的 `pay_params` 调 `wx.requestPayment`;`expire_time` 用于支付倒计时(15 分钟)。
|
||||
3. 支付结果以微信回调为准(公开路由,服务端验签 + 解密);前端支付完成后应通过 `GET /order` 确认 `order_status=2`,不要自行判定支付成功。
|
||||
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:点赞 / 收藏切换
|
||||
|
||||
@@ -1600,6 +1604,7 @@ curl -X POST http://localhost:80/api/v1/ai-recommend \
|
||||
| 每日运势生成 | 每日 0 点 + 启动兜底 | 大模型生成十二星座运势;Redis 分布式锁保证多实例仅执行一次;当日不足 12 条时按需兜底生成 |
|
||||
| 订单超时过期 | 每 60 秒 | 将超过 15 分钟未支付的订单批量置为"已过期" |
|
||||
| 权益发放补偿 | 每 60 秒 | 重试发放失败的权益任务(恢复僵死任务、幂等防重复发放、超限告警) |
|
||||
| 虚拟支付退款查单兜底 | 每 60 秒 | 反查停留超 10 分钟的「申请退款中」虚拟支付订单:已退款自动置「已退款」并回收权益,仍未退款保持状态、超 24 小时告警人工 |
|
||||
|
||||
## 部署提示
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
【上下文锚点】沿用第 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` 表示退款失败可重新申请)。空态:票据线稿插画 +「还没有订单」+ 描边按钮「去看看额度包」。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ type OrderResponse struct {
|
||||
UnitPrice int64 `json:"unit_price"` // 单价(分)
|
||||
PayAmount int64 `json:"pay_amount"` // 实付(分)
|
||||
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"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
PayTime int64 `json:"pay_time"`
|
||||
@@ -57,7 +57,7 @@ type OrderResponse struct {
|
||||
// OrderListRequest 订单列表
|
||||
type OrderListRequest struct {
|
||||
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 订单列表
|
||||
|
||||
@@ -36,6 +36,7 @@ func (d *BenefitRevokeTaskDao) RecoverStale(ctx context.Context, before int64) e
|
||||
}
|
||||
|
||||
// RefundSettleWithRevokeTask 原子写入退款订单终态、取消未执行发放并创建回收任务。
|
||||
// 前置状态:2=已支付(正常退款)、3=已取消但已付款(竞态兜底)、6=申请退款中(用户已提交退款申请)。
|
||||
func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, orderNo string, refundAmount, refundTime int64) (bool, error) {
|
||||
settled := false
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
if order.OrderStatus != 2 && order.OrderStatus != 3 {
|
||||
if order.OrderStatus != 2 && order.OrderStatus != 3 && order.OrderStatus != 6 {
|
||||
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 {
|
||||
@@ -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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
+104
-5
@@ -2,10 +2,12 @@ package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// OrderDao 订单数据访问
|
||||
@@ -91,15 +93,34 @@ func (d *OrderDao) OrderListVirtualUnconfirmed(ctx context.Context, since, until
|
||||
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 查询存在未建权益任务的已支付订单,修复支付回调与任务落库之间的崩溃窗口。
|
||||
// 除「任务不存在」外,也把「任务被取消(status=4)」视为待修复:订单曾进入退款流程时任务被取消,
|
||||
// 退款失败回退为已支付后需要重新发放,避免「已付款却无权益」(已退款订单状态为 5,不会被本查询命中)。
|
||||
func (d *OrderDao) OrderListPaidMissingBenefitTasks(ctx context.Context, limit int) ([]mysqlmodel.Order, error) {
|
||||
var list []mysqlmodel.Order
|
||||
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
|
||||
(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
|
||||
(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
|
||||
(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
|
||||
(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))`).
|
||||
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 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 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 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 AND t.status<>4))`).
|
||||
Order("id ASC").Limit(limit).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
@@ -162,6 +183,84 @@ func NewRefundFlowLogDao() *RefundFlowLogDao {
|
||||
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 创建退款流水
|
||||
func (d *RefundFlowLogDao) RefundFlowLogCreate(ctx context.Context, log *mysqlmodel.RefundFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogCreate")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type Order struct {
|
||||
TotalAmount int64 `gorm:"column:total_amount"` // 订单总金额(分)
|
||||
PayAmount int64 `gorm:"column:pay_amount"` // 实付金额(分)
|
||||
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"` // 微信支付流水号
|
||||
MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照)
|
||||
GoodsType int8 `gorm:"column:goods_type"` // 商品类型(快照)
|
||||
|
||||
@@ -35,6 +35,47 @@ func VirtualPayUnpaidDel(ctx context.Context, orderNo string) error {
|
||||
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 批量判断哪些订单命中负缓存。
|
||||
// 用一次 MGET 代替 N 次 GET,避免逐单往返 Redis 放大单轮耗时。
|
||||
func VirtualPayUnpaidBatchGet(ctx context.Context, orderNos []string) (map[string]bool, error) {
|
||||
|
||||
@@ -2,13 +2,14 @@ package redismodel
|
||||
|
||||
// 分布式锁与幂等模块:对齐 mysqlmodel 的 Order、AIRecommend、AIHoroscope、审核等业务。
|
||||
const (
|
||||
KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重
|
||||
KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等
|
||||
KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_no,退款回调幂等
|
||||
KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐活跃位:值为进行中的 task_id,兼作防并发
|
||||
KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发
|
||||
KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重
|
||||
KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重
|
||||
KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s" // outfit_uuid,穿搭公开/取消公开串行化
|
||||
KeyLockVirtualConfirm = RedisPrefix + "lock:virtual:confirm" // 虚拟支付查单补偿定时任务防重(无参数,全局单实例)
|
||||
KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重
|
||||
KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等
|
||||
KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_no,退款回调幂等
|
||||
KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐活跃位:值为进行中的 task_id,兼作防并发
|
||||
KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发
|
||||
KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重
|
||||
KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重
|
||||
KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s" // outfit_uuid,穿搭公开/取消公开串行化
|
||||
KeyLockVirtualConfirm = RedisPrefix + "lock:virtual:confirm" // 虚拟支付查单补偿定时任务防重(无参数,全局单实例)
|
||||
KeyLockVirtualRefundConfirm = RedisPrefix + "lock:virtual:refund:confirm" // 虚拟支付退款查单兜底定时任务防重(无参数,全局单实例)
|
||||
)
|
||||
|
||||
@@ -6,4 +6,11 @@ const (
|
||||
// 命中表示最近一次 query_order 反查结果为未支付,本轮跳过该订单,
|
||||
// 避免大量「下单后不付款」的积压订单长期占满扫描名额,把真正已支付的订单挤出去。
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -152,6 +152,7 @@ const (
|
||||
OrderStatusCancelled int8 = 3 // 已取消
|
||||
OrderStatusExpired int8 = 4 // 已过期
|
||||
OrderStatusRefunded int8 = 5 // 已退款
|
||||
OrderStatusRefunding int8 = 6 // 申请退款中(已受理退款申请,待运营审核并手动发起退款)
|
||||
|
||||
PayChannelWechat int8 = 1 // 微信支付(普通 JSAPI)
|
||||
PayChannelVirtual int8 = 2 // 微信虚拟支付(XPay)
|
||||
|
||||
@@ -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
@@ -326,52 +326,25 @@ func (s *OrderService) RefundApply(ctx context.Context, userID int64, request *d
|
||||
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()
|
||||
applyTime := nowUnix()
|
||||
|
||||
if existing != nil {
|
||||
// 失败重试:复用旧记录,更新退款单号与状态。
|
||||
if err := s.refundDao.RefundFlowLogUpdateStatus(ctx, existing.RefundNo, map[string]any{
|
||||
"refund_no": refundNo,
|
||||
"refund_amount": order.PayAmount,
|
||||
"refund_reason": request.Reason,
|
||||
"status": RefundStatusPending,
|
||||
"apply_time": applyTime,
|
||||
"fail_reason": "",
|
||||
}); 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
|
||||
}
|
||||
// 受理与订单状态流转原子完成:订单 2 已支付 -> 6 申请退款中,并落退款流水
|
||||
// (已有失败流水则复用旧记录换新退款单号)。订单行锁兜底并发与重复申请。
|
||||
if _, allowed, err := s.refundDao.RefundApplyWithOrderStatus(ctx, order.OrderNo, refundNo, request.Reason, applyTime); err != nil {
|
||||
slog.ErrorContext(ctx, "refund apply failed", "order_no", order.OrderNo, "user_id", userID, "error", err)
|
||||
return nil, errcode.RefundApplyFailed
|
||||
} else if !allowed {
|
||||
slog.WarnContext(ctx, "refund apply rejected: order not paid or refund already in progress",
|
||||
"order_no", order.OrderNo, "user_id", userID, "order_status", order.OrderStatus)
|
||||
return nil, errcode.RefundNotAllowed
|
||||
}
|
||||
|
||||
// 人工审核:不再自动调用微信退款,仅创建待审核退款申请,避免自动退款造成权益未回退的资损。
|
||||
// 运营审核通过后在微信后台手动发起退款,微信退款回调 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{
|
||||
RefundNo: refundNo,
|
||||
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",
|
||||
"order_no", order.OrderNo, "out_refund_no", data.OutRefundNo, "refund_amount", flow.RefundAmount)
|
||||
} 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 {
|
||||
slog.ErrorContext(ctx, "refund notify: mark refund failed status failed",
|
||||
"out_refund_no", data.OutRefundNo, "refund_status", data.RefundStatus, "error", err)
|
||||
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
|
||||
}
|
||||
@@ -592,6 +577,21 @@ func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlm
|
||||
return
|
||||
}
|
||||
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 交由定时任务处理。
|
||||
}
|
||||
@@ -814,7 +814,9 @@ func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmode
|
||||
}
|
||||
|
||||
// buildQuotaRevokeFlows 构造退款回收流水:按该订单的额度凭证逐类记录回收量。
|
||||
// 已用部分不追回,故变更量只记未用量 (amount - used);已全部用掉的凭证不写流水。
|
||||
// 记账口径为「吊销前有效剩余 vs 排除该订单凭证后重算的有效剩余」的差值,与额度聚合规则
|
||||
// (会员包来源不叠加、只取一份)保持一致:已用部分不追回;吊销后有效剩余不变的凭证
|
||||
// (例如被其他生效会员包覆盖的重复凭证)不写流水,避免出现用户实际感知不到的回收记录。
|
||||
func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) ([]*mysqlmodel.QuotaFlowLog, error) {
|
||||
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByRefID(ctx, order.OrderNo)
|
||||
if err != nil {
|
||||
@@ -823,34 +825,56 @@ func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmo
|
||||
}
|
||||
|
||||
flows := make([]*mysqlmodel.QuotaFlowLog, 0, len(grants))
|
||||
// 每类额度的当前有效剩余,逐张凭证递减(同一订单同类凭证通常只有一张,此处做通用处理)。
|
||||
current := make(map[int8]int64, len(grants))
|
||||
computed := make(map[int8]bool, len(grants))
|
||||
// 每类额度「吊销前」的有效剩余基准;excluded 累积本订单已计划吊销的凭证。
|
||||
before := make(map[int8]int64, len(grants))
|
||||
beforeKnown := make(map[int8]bool, len(grants))
|
||||
excluded := make(map[int64]bool, len(grants))
|
||||
for i := range grants {
|
||||
g := grants[i]
|
||||
revoke := g.Amount - g.Used
|
||||
if revoke <= 0 {
|
||||
if g.Amount-g.Used <= 0 {
|
||||
continue // 已全部用掉,无可回收额度。
|
||||
}
|
||||
if !computed[g.QuotaType] {
|
||||
if !beforeKnown[g.QuotaType] {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
computed[g.QuotaType] = true
|
||||
}
|
||||
before := current[g.QuotaType]
|
||||
current[g.QuotaType] = before - revoke
|
||||
excluded[g.ID] = true
|
||||
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{
|
||||
QuotaFlowUUID: genUUID(),
|
||||
UserID: order.UserID,
|
||||
ChangeTarget: QuotaChangeTargetAdjust,
|
||||
ChangeType: QuotaChangeTypeRefundRevoke,
|
||||
ChangeNum: -revoke,
|
||||
OldValue: before,
|
||||
NewValue: before - revoke,
|
||||
OldValue: before[g.QuotaType] + revoke,
|
||||
NewValue: before[g.QuotaType],
|
||||
OrderNo: order.OrderNo,
|
||||
QuotaType: g.QuotaType,
|
||||
Reason: "退款回收未使用的赠送额度",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -135,12 +135,73 @@ func listValidGrants(ctx context.Context, userID int64, quotaType int8, vipExpir
|
||||
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 永久 / 每日额度)。
|
||||
// 有效总额度 = 免费配置值 + SUM(有效凭证 amount);已用按类型分别取永久累计或当日计数。
|
||||
func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*EffectiveQuota, *errcode.Error) {
|
||||
now := nowUnix()
|
||||
freeTotal := defaultQuotaValue(quotaType)
|
||||
|
||||
vipExpire := getUserVIPExpire(ctx, userID)
|
||||
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
|
||||
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)
|
||||
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
|
||||
for i := range grants {
|
||||
if grants[i].Source == QuotaGrantSourceMember {
|
||||
continue
|
||||
}
|
||||
grantTotal += grants[i].Amount
|
||||
grantUsed += grants[i].Used
|
||||
}
|
||||
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
|
||||
grantTotal += memberGrant.Amount
|
||||
grantUsed += memberGrant.Used
|
||||
}
|
||||
|
||||
if quotaType == QuotaTypeCos {
|
||||
// 永久额度:免费已用来自 user_quotas.used(永久累计),赠送已用来自凭证 used。
|
||||
@@ -170,7 +247,7 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
|
||||
if 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 {
|
||||
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。
|
||||
@@ -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 {
|
||||
now := nowUnix()
|
||||
vipExpire := getUserVIPExpire(ctx, userID)
|
||||
@@ -296,32 +394,54 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
|
||||
return err
|
||||
}
|
||||
|
||||
remain := n
|
||||
for i := range grants {
|
||||
if remain <= 0 {
|
||||
break
|
||||
// consumeGrant 扣减单份凭证的已用(条件更新防并发超扣,扣不动则整单回滚)。
|
||||
consumeGrant := func(g *mysqlmodel.QuotaGrant, want int64) (int64, error) {
|
||||
delta := g.Amount - g.Used
|
||||
if delta <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
avail := grants[i].Amount - grants[i].Used
|
||||
if avail <= 0 {
|
||||
continue
|
||||
}
|
||||
delta := avail
|
||||
if delta > remain {
|
||||
delta = remain
|
||||
if delta > want {
|
||||
delta = want
|
||||
}
|
||||
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))
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
return 0, res.Error
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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 污染)。
|
||||
if remain > 0 {
|
||||
@@ -368,6 +488,7 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
|
||||
}
|
||||
|
||||
// cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。
|
||||
// 会员包来源不叠加:只计生效的一份,与 getEffectiveQuota 展示口径保持一致。
|
||||
func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now int64) int64 {
|
||||
freeRemain := int64(0)
|
||||
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
|
||||
}
|
||||
|
||||
var grantRemain int64
|
||||
q := tx.Model(&mysqlmodel.QuotaGrant{}).Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
|
||||
var grants []mysqlmodel.QuotaGrant
|
||||
q := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
|
||||
if vipExpire > now {
|
||||
q = q.Where("(expire_time > ? OR expire_time = 0)", now)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
freeRemain = 0
|
||||
}
|
||||
@@ -392,7 +528,8 @@ func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now
|
||||
}
|
||||
|
||||
// refundCosQuotaTx 在事务内回退 Cos 额度并写流水。
|
||||
// 回退策略(后进先出):优先回退免费额度(最后扣减的),不足再回退凭证(最后到期优先)。
|
||||
// 回退策略(后进先出):优先回退免费额度(最后扣减的),再回退非会员包凭证(最后到期优先),
|
||||
// 最后回退会员包凭证(不叠加口径:优先当前生效份,生效份无可退额度时复活刚被扣尽的份)。
|
||||
func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error {
|
||||
now := nowUnix()
|
||||
|
||||
@@ -408,10 +545,10 @@ func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire
|
||||
remain = 0
|
||||
}
|
||||
|
||||
// 2. 免费额度不足,回退凭证(按到期时间倒序,最后到期的优先回退)。
|
||||
// 2. 免费额度不足,先回退非会员包凭证(加量包/广告/体验,按到期时间倒序,最后到期的优先回退)。
|
||||
if remain > 0 {
|
||||
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").
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
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,导致额度已被部分改回却没有流水(账实不一致);
|
||||
// 同时 ChangeNum 写的是请求量 n,退不满时数字虚高。现统一按实际回退量记账,
|
||||
// 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"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)
|
||||
return wechat.BuildNotifyResponse(false)
|
||||
}
|
||||
if e := s.handleRefundNotify(ctx, &refund); e != nil {
|
||||
if e := s.handleRefundNotify(ctx, &refund, string(raw)); e != nil {
|
||||
// 内部失败分支已各自记录 Error 日志,这里仅保留调用链痕迹,避免重复告警。
|
||||
slog.DebugContext(ctx, "xpay refund notify handle failed", "error", e)
|
||||
return wechat.BuildNotifyResponse(false)
|
||||
@@ -224,12 +226,24 @@ func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmode
|
||||
// query_order(带 HMAC 签名的服务端 API)确认订单在微信侧已真实退款、金额一致,
|
||||
// 防止伪造退款推送回收权益。任一校验不通过返回错误(fail-closed,触发微信重试)。
|
||||
func (s *OrderService) verifyXPayRefund(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify) *errcode.Error {
|
||||
if refund.OpenID == "" {
|
||||
slog.ErrorContext(ctx, "xpay refund: empty openid", "order_no", order.OrderNo)
|
||||
// query_order 必需 openid:推送未携带时回退用订单归属用户的 openid(订单表不冗余该字段,按 user_id 取)。
|
||||
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
|
||||
}
|
||||
|
||||
info, err := wechat.QueryOrder(ctx, refund.OpenID, refund.OutTradeNo, "")
|
||||
// 按订单号反查(而非推送中的订单号字段),保证查询目标与本地订单严格一致。
|
||||
info, err := wechat.QueryOrder(ctx, openid, order.OrderNo, "")
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "xpay refund: query order failed",
|
||||
"order_no", order.OrderNo, "error", err)
|
||||
@@ -372,41 +386,71 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRefundNotify 处理退款推送:复用退款成功回退权益链路(settleRefundSuccess)。
|
||||
func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XPayRefundNotify) *errcode.Error {
|
||||
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, refund.RefundOrderID)
|
||||
// handleRefundNotify 处理退款推送(xpay_refund_notify):按官方字段驱动状态流转。
|
||||
//
|
||||
// 官方报文用 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()
|
||||
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second)
|
||||
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 故障):不重复处理,交由微信重试/持锁者。
|
||||
}
|
||||
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
|
||||
}
|
||||
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
|
||||
|
||||
// 白名单判断:仅「退款成功」才回退权益;空串/未知/中间状态仅记录,防止误回退
|
||||
// (退款推送按官方文档在退款完成时触发,状态枚举联调时以官方为准)。
|
||||
if refund.RefundStatus != wechat.XPayRefundStatusSuccess {
|
||||
slog.WarnContext(ctx, "xpay refund notify not success, skip revoke",
|
||||
"order_no", refund.OutTradeNo, "refund_order_id", refund.RefundOrderID,
|
||||
"refund_status", refund.RefundStatus, "refund_fee", refund.RefundFee)
|
||||
succ, determinable := refund.Succeeded()
|
||||
if !determinable {
|
||||
slog.ErrorContext(ctx, "xpay refund: refund result not determinable, ask wechat to retry",
|
||||
"order_no", orderNo, "mch_refund_id", refund.MchRefundID, "wx_refund_id", refund.WxRefundID,
|
||||
"retry_times", refund.RetryTimes)
|
||||
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
|
||||
}
|
||||
|
||||
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, refund.OutTradeNo)
|
||||
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
|
||||
}
|
||||
// 已终态则幂等返回。
|
||||
// 已终态则幂等返回;同时补一次流水回写,覆盖此前「订单已置 5 但流水未同步」的中途失败。
|
||||
if order.OrderStatus == OrderStatusRefunded {
|
||||
s.markRefundFlowSuccess(ctx, order, refund.WxRefundID, raw)
|
||||
slog.DebugContext(ctx, "xpay refund: order already refunded, skip", "order_no", order.OrderNo)
|
||||
return nil
|
||||
}
|
||||
@@ -417,15 +461,90 @@ func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XP
|
||||
return verr
|
||||
}
|
||||
|
||||
// settleRefundSuccess 内部含条件流转(Paid -> Refunded),重复推送不会重复回退。
|
||||
// settleRefundSuccess 内部含条件流转(Paid/Refunding -> Refunded),重复推送不会重复回退。
|
||||
// 内部失败已由 settleRefundSuccess 的使用方日志覆盖:此处接收返回值并记录,避免静默。
|
||||
if serr := s.settleRefundSuccess(ctx, order, refund.RefundFee); serr != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 秒内应答,仅本地查询)。
|
||||
// 按权益使用情况细化:权益已消耗则拒绝退款(result_code=1 + evidence),未消耗则建议退款;
|
||||
// 建议退款后权益由后续退款推送 settleRefundSuccess 回收(回收链路保证「已消耗部分不回退」)。
|
||||
|
||||
@@ -67,6 +67,8 @@ func main() {
|
||||
service.StartClothesProcessingRecoveryScheduler()
|
||||
service.StartOrderExpireScheduler()
|
||||
service.StartVirtualPayConfirmScheduler()
|
||||
// 虚拟支付退款结果查单兜底:退款推送丢失/未识别时避免订单永久停留在「申请退款中」。
|
||||
service.StartVirtualRefundConfirmScheduler()
|
||||
|
||||
// 清理超期未保存的 AI 推荐穿搭(status=3),避免长期堆积。
|
||||
service.StartAIOutfitCleanupScheduler()
|
||||
|
||||
@@ -259,7 +259,7 @@ CREATE TABLE `orders` (
|
||||
`total_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=微信虚拟支付',
|
||||
`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 '微信支付流水号',
|
||||
`member_days` BIGINT 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=申请退款中';
|
||||
@@ -394,10 +394,12 @@ func IsOrderRefundedStatus(status int) bool {
|
||||
return status == XPayOrderStatusRefunded || status == XPayOrderStatusUserRefunded
|
||||
}
|
||||
|
||||
// 虚拟支付退款推送的退款状态(RefundStatus 字段,以微信官方枚举为准,联调时校正)。
|
||||
const (
|
||||
XPayRefundStatusSuccess = "SUCCESS" // 退款成功
|
||||
)
|
||||
// 虚拟支付退款推送(xpay_refund_notify)的退款结果字段:官方以 RetCode 判定,
|
||||
// RetCode=0 表示退款成功,非 0 表示失败(失败原因见 RetMsg)。
|
||||
const XPayRefundRetCodeSuccess = 0
|
||||
|
||||
// XPayRefundStatusSuccess 旧版式退款推送的 RefundStatus 取值(官方现行报文已无该字段,保留兼容解析)。
|
||||
const XPayRefundStatusSuccess = "SUCCESS"
|
||||
|
||||
// QueryOrder 查询虚拟支付订单(orderID 与 wxOrderID 二选一,非空者优先;作用于激活环境)。
|
||||
func QueryOrder(ctx context.Context, openid, orderID, wxOrderID string) (*XPayOrderInfo, error) {
|
||||
@@ -562,22 +564,82 @@ type XPayGoodsDeliverNotify struct {
|
||||
} `json:"GoodsInfo"`
|
||||
}
|
||||
|
||||
// XPayRefundNotify 退款推送。
|
||||
// XPayRefundNotify 退款推送(xpay_refund_notify)。
|
||||
//
|
||||
// 字段以官方《虚拟支付:企业、个体户 → 消息推送 → 退款推送》为准:
|
||||
// 官方报文中**没有** OutTradeNo / RefundStatus / Env,订单号取 MchOrderId(原支付单商户单号,
|
||||
// 即本地 orders.order_no),退款结果取 RetCode(0=成功,非 0=失败,原因见 RetMsg)。
|
||||
// 同时保留 RefundOrderId / RefundStatus 等旧版式字段与非官方版式的 OutTradeNo,避免版式差异导致静默失败。
|
||||
type XPayRefundNotify struct {
|
||||
XPayNotifyBase
|
||||
RefundStatus string `json:"RefundStatus"` // 退款状态(以官方枚举为准)
|
||||
RefundFee int64 `json:"RefundFee"` // 退款金额(分)
|
||||
RefundOrderID string `json:"RefundOrderId"` // 商户退款单号
|
||||
WxRefundID string `json:"WxRefundId"` // 微信退款单号
|
||||
MchRefundID string `json:"MchRefundId"` // 商户退款单号
|
||||
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 退款问询推送。
|
||||
type XPayIOSRefundQueryNotify struct {
|
||||
XPayNotifyBase
|
||||
// 字段以官方文档为准;此处解析退款问询所需的最小信息。
|
||||
MchOrderID string `json:"MchOrderId"` // 兼容:部分版式用商户单号命名
|
||||
RefundOrderID string `json:"RefundOrderId"`
|
||||
RefundFee int64 `json:"RefundFee"`
|
||||
}
|
||||
|
||||
// OrderNo 取商户订单号:公共字段 OutTradeNo 优先(保持既有行为),为空时回退 MchOrderId。
|
||||
func (n *XPayIOSRefundQueryNotify) OrderNo() string {
|
||||
if n.OutTradeNo != "" {
|
||||
return n.OutTradeNo
|
||||
}
|
||||
return n.MchOrderID
|
||||
}
|
||||
|
||||
// ParseNotify 解析消息推送 JSON 并返回事件类型与公共字段。
|
||||
// 返回值为 base(公共字段)+ 原始 body,调用方再按 Event 分发到具体结构。
|
||||
func ParseNotify(raw []byte) (*XPayNotifyBase, error) {
|
||||
|
||||
@@ -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 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。
|
||||
func TestResolveEnvKey(t *testing.T) {
|
||||
// 只填现网 -> 现网
|
||||
|
||||
@@ -228,7 +228,7 @@ Authorization: Bearer <access_token>
|
||||
| 衣物 AI 处理 | `GET /clothes/:uuid/status` | `4` 成功 / `5` 失败(`1/2/3` 继续轮询) | 1~2 秒,建议上限 60 次 |
|
||||
| AI 推荐生成 | `GET /ai-recommend/status` | `2` 成功 / `3` 失败(`1` 继续轮询) | 2 秒,建议上限 90 次 |
|
||||
| 支付结果 | `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 /notification/unread` | 无终态,按需刷新 | 进入页面时 / 30~60 秒 |
|
||||
|
||||
@@ -452,7 +452,7 @@ Authorization: Bearer <access_token>
|
||||
| unit_price | int64 | 单价(**分**) |
|
||||
| pay_amount | int64 | 实付金额(**分**) |
|
||||
| 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 | 订单快照:会员天数 |
|
||||
| expire_time | int64 | 支付截止时间(秒),前端倒计时用(15 分钟) |
|
||||
| pay_time | int64 | 支付时间(秒),未支付为 0 |
|
||||
@@ -639,7 +639,7 @@ Authorization: Bearer <access_token>
|
||||
| 3 | 社区分享额度(搭配分享) | 每日 | 10 | 穿搭首次公开扣 1 |
|
||||
| 4 | 衣柜分享额度 | 每日 | 5 | 创建一次衣柜分享扣 1 |
|
||||
|
||||
**② 额度凭证**(`quota_grants`)—— 有有效期的赠送额度,按来源分三类:
|
||||
**② 额度凭证**(`quota_grants`)—— 有有效期的赠送额度,按来源分四类:
|
||||
|
||||
| source | 来源 | 有效期 |
|
||||
| --- | --- | --- |
|
||||
@@ -650,7 +650,8 @@ Authorization: Bearer <access_token>
|
||||
|
||||
> 新用户注册时,`NewUserGift` 段配置的体验会员额度以 **source=4** 凭证发放;`GiftVIPDay` 配 0(不赠送会员)时这部分额度一律不发放。
|
||||
> ⚠️ 体验额度**不会**被后续充值「继承」:用户 3 天体验期后再购买会员,体验额度照常失效,只剩免费额度 + 新购会员包额度。
|
||||
> Cos 消费先扣「即将过期凭证」再扣免费额度;每日额度总额 = 免费配置 + 有效凭证赠送额。
|
||||
> ⚠️ **会员包来源(source=1)的额度不叠加**:月度包与年度包同时持有时,同类额度只取 **amount 最大的一份**计入(月/年额度相同时即等价于只按一份计算),避免额度翻倍;该份**用尽后自动顺延**到下一份有效会员包凭证,已购额度不浪费。加量包(source=2)、广告激励(source=3)、体验额度(source=4)仍按原逻辑**叠加求和**。
|
||||
> Cos 消费先扣「非会员包凭证(先到期先用)」再扣「会员包生效份」,最后扣免费额度;未生效的会员包份不参与扣减与回退。每日额度总额 = 免费配置 + 会员包生效份 + 其他来源凭证赠送额。
|
||||
|
||||
#### GET `/quota/info` — 额度概览(鉴权)
|
||||
|
||||
@@ -1707,7 +1708,7 @@ Authorization: Bearer <access_token>
|
||||
|
||||
### 5.12 订单与退款模块
|
||||
|
||||
**`order_status` 枚举**:`1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款
|
||||
**`order_status` 枚举**:`1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 `6`=申请退款中(已受理退款申请,待运营审核并手动发起退款)
|
||||
**退款 `status` 枚举**:`0`=待处理 `1`=退款中 `2`=退款成功 `3`=退款失败
|
||||
|
||||
#### POST `/order` — 创建订单并发起微信支付(鉴权)
|
||||
@@ -1798,7 +1799,7 @@ wx.requestVirtualPayment({
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 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)
|
||||
|
||||
@@ -1833,11 +1834,15 @@ wx.requestVirtualPayment({
|
||||
**业务逻辑(重要:人工审核模式)**
|
||||
|
||||
- **iOS 虚拟支付订单不可主动退款** → `71004`,请引导用户走 App Store 申请退款(Apple 通过退款问询推送回收权益)。
|
||||
- **仅 `2` 已支付**订单可申请,其它状态 → `71001`。
|
||||
- ⚠️ **系统不会自动调微信退款**:接口只是登记退款申请(`status=0` 待处理),**需运营人工审核并手动发起退款**——普通支付订单在微信支付商户后台退款,**虚拟支付订单(`pay_channel=2`)请在虚拟支付后台(Midas)发起退款**,退款结果由推送回调同步。
|
||||
- 前端应提示"**退款审核中**",并允许轮询订单状态直到 `5` 已退款;**不要承诺实时到账**。
|
||||
- **仅 `2` 已支付**订单可申请,其它状态(含申请中的 `6`)→ `71001`。
|
||||
- ✅ **受理成功后订单立即置 `6` 申请退款中**:订单状态流转与退款流水(`status=0` 待处理)在同一事务内完成;重复/并发申请只有一次受理成功,其余返回 `71001`。前端刷新订单列表即可看到"退款审核中"。
|
||||
- ⚠️ **系统不会自动调微信退款**:接口只是登记退款申请(流水 `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`;上次失败可重新申请(复用记录、换新退款单号)。
|
||||
- 退款成功后,订单置 `5`,**会员天数与已发放额度会被回收**(已消耗部分不回收),并发退款通知。
|
||||
- 退款成功后,**会员天数与已发放额度会被回收**(已消耗部分不回收),并发退款通知。
|
||||
- 🛟 **兜底**:虚拟支付退款结果除推送外还有**查单兜底任务**(每 60 秒扫描停留超 10 分钟的「申请退款中」订单反查 `query_order`)——微信侧已退款会自动订正为 `5` 并回收权益;微信侧仍未退款则保持 `6`,超过 24 小时输出告警交人工。**推送丢失也不会导致订单永久卡单**。
|
||||
- ⚠️ **运营线下拒绝**(未发起微信退款)不会产生回调,订单会停留在 `6` 申请退款中:兜底任务会持续核对并对超期订单告警,需运营在后台发起退款,或由研发人工订正。
|
||||
|
||||
**请求**(json)
|
||||
|
||||
@@ -1865,7 +1870,28 @@ wx.requestVirtualPayment({
|
||||
|
||||
- 不走统一 `{code,msg,data}` 响应包装,按微信 V3 规范返回:成功 HTTP 200 `{"code":"SUCCESS","message":"成功"}`;失败 HTTP 500 `{"code":"FAIL","message":"<原因>"}`。
|
||||
- 服务端处理:验签 + 解密 → 幂等锁(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. 业务逻辑与状态机
|
||||
@@ -1987,6 +2013,9 @@ stateDiagram-v2
|
||||
pending --> cancelled : 用户主动取消(同步关闭微信订单)
|
||||
expired --> paid : 过期后付款到账(补发货)
|
||||
cancelled --> refunded : 已取消但已付款 → 生成待审核退款单,管理员手动退款
|
||||
paid --> refunding : 用户申请退款(POST /order/refund,同事务写退款流水)
|
||||
refunding --> refunded : 退款成功回调(回收权益 → 通知)
|
||||
refunding --> paid : 退款失败/被驳回(流水置失败,可重新申请)
|
||||
paid --> refunded : 退款成功回调(回收权益 → 通知)
|
||||
|
||||
state "1 待支付" as pending
|
||||
@@ -1994,14 +2023,16 @@ stateDiagram-v2
|
||||
state "3 已取消" as cancelled
|
||||
state "4 已过期" as expired
|
||||
state "5 已退款" as refunded
|
||||
state "6 申请退款中" as refunding
|
||||
```
|
||||
|
||||
**关键规则**
|
||||
|
||||
- **金额校验**:回调金额与订单 `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 次仍失败则标记失败并告警人工介入。
|
||||
- **退款回收**:会员天数按快照回退(不回退到过去);赠送额度通过**失效凭证**回收未用部分,**已消耗部分不追回**。
|
||||
- **退款状态流转**:`POST /order/refund` 受理后订单 `2 → 6`(申请退款中,同一事务写入退款流水,行锁防重复受理);退款成功回调 `6 → 5` 并回收权益;微信退款失败回调 `6 → 2` 且流水置失败,用户可重新申请。运营线下直接拒绝不会产生回调,订单会停留在 `6`,需人工介入。
|
||||
|
||||
---
|
||||
|
||||
@@ -2315,7 +2346,7 @@ sequenceDiagram
|
||||
| 79002 | 更新订单失败 | 200 |
|
||||
| 79003 | 创建虚拟支付订单失败 | 200 |
|
||||
| 71000 | 退款记录不存在 | 200 |
|
||||
| 71001 | 当前状态不允许退款 | 200 |
|
||||
| 71001 | 当前状态不允许退款(非已支付或已有进行中的退款申请) | 200 |
|
||||
| 71002 | 退款金额不合法 | 200 |
|
||||
| 71003 | 退款申请失败 | 200 |
|
||||
| 71004 | iOS 订单请通过 App Store 申请退款 | 200 |
|
||||
@@ -2383,7 +2414,10 @@ A:不能。额度在**生成时**已消耗,只有生成失败才回退。前
|
||||
A:以服务端为准。微信回调是异步的,**轮询 `GET /order` 直到 `order_status=2`**。
|
||||
|
||||
**Q:退款申请后一直没到账?**
|
||||
A:退款是**人工审核模式**,接口只登记申请(`status=0`)。前端提示"退款审核中",并允许轮询订单状态到 `5`。
|
||||
A:退款是**人工审核模式**,接口只登记申请(退款流水 `status=0`)。申请成功后订单为 `6` 申请退款中,前端提示"退款审核中"并轮询订单状态:`5` 已退款为终态;若回到 `2` 已支付,说明退款失败/被驳回,可引导用户重新申请。
|
||||
|
||||
**Q:虚拟支付退款推送丢了 / 解析失败,会不会一直卡在「申请退款中」?**
|
||||
A:不会。退款推送处理对无法判定的报文会应答失败触发微信重试(间隔 2/4/8/16… 最多 15 次);同时后台**查单兜底任务**每 60 秒反查停留超 10 分钟的「申请退款中」订单——微信侧已退款会自动订正为 `5 已退款` 并回收权益;若微信侧确实未退款则保持 `6`,超过 24 小时输出告警交人工介入。
|
||||
|
||||
**Q:衣柜分享列表里显示"正常",但对方打不开?**
|
||||
A:过期是**惰性**的。前端需同时判断 `status` 与 `expire_time`。
|
||||
@@ -2402,7 +2436,7 @@ A:调用 `GET /outfit/community` 分页获取其他用户已公开(公开 +
|
||||
- [ ] 金额展示 `÷100`,时间展示 `×1000`
|
||||
- [ ] 上传后轮询状态(1~2 秒,最多 60 次),展示处理中/失败态
|
||||
- [ ] 支付后轮询订单状态,不依赖支付组件回调
|
||||
- [ ] 退款提示"人工审核中"
|
||||
- [ ] 退款提示"人工审核中"(申请后订单状态 `6`,轮询到 `5` 为终态,回到 `2` 表示失败可重申请)
|
||||
- [ ] 公开穿搭前提示将消耗 1 次搭配分享额度
|
||||
- [ ] AI 推荐前提示将消耗 1 次 AI 额度,且**不保存不退**
|
||||
- [ ] 点赞/收藏按钮做乐观更新 + 失败回滚(接口幂等,可重试)
|
||||
|
||||
Reference in New Issue
Block a user