From b94dd6dffbc6d65afdc020cc13950aae04f62e42 Mon Sep 17 00:00:00 2001
From: zhengchiliu <857493511@qq.com>
Date: Wed, 16 Sep 2026 03:44:09 +0800
Subject: [PATCH] =?UTF-8?q?feat(order):=20=E9=87=8D=E6=9E=84=E9=80=80?=
=?UTF-8?q?=E6=AC=BE=E5=85=A8=E6=B5=81=E7=A8=8B=E5=B9=B6=E6=96=B0=E5=A2=9E?=
=?UTF-8?q?=E7=94=B3=E8=AF=B7=E9=80=80=E6=AC=BE=E4=B8=AD=E7=8A=B6=E6=80=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
---
.codebuddy/plans/会员额度不叠加_76777dd5.md | 93 +++++++
...‹Ÿ支付退款推送字段修复与查单兜底_6024f92b.md | 156 ++++++++++++
.../退款申请订单状态流转修复_7d9218ad.md | 148 +++++++++++
README.md | 21 +-
Stitch.md | 2 +-
internal/dto/order.go | 4 +-
internal/mysqldao/benefit_revoke.go | 6 +-
internal/mysqldao/order.go | 109 +++++++-
internal/mysqldao/order_refund_test.go | 225 +++++++++++++++++
internal/mysqlmodel/order.go | 2 +-
internal/redisdao/virtualpay.go | 41 +++
internal/redismodel/lock.go | 19 +-
internal/redismodel/virtualpay.go | 7 +
internal/service/constants.go | 1 +
internal/service/cos_quota_test.go | 193 ++++++++++++++
internal/service/order.go | 128 ++++++----
internal/service/quota_select_test.go | 113 +++++++++
internal/service/refund_apply_test.go | 96 +++++++
internal/service/service.go | 219 ++++++++++++++--
internal/service/virtual_refund_confirm.go | 239 ++++++++++++++++++
internal/service/virtual_refund_test.go | 29 +++
internal/service/virtualpay.go | 173 +++++++++++--
main.go | 2 +
pkg/migration/000001_init.up.sql | 2 +-
.../000002_order_status_refunding.down.sql | 3 +
.../000002_order_status_refunding.up.sql | 3 +
pkg/wechat/virtualpay.go | 78 +++++-
pkg/wechat/virtualpay_test.go | 123 +++++++++
接口文档.md | 64 +++--
29 files changed, 2141 insertions(+), 158 deletions(-)
create mode 100644 .codebuddy/plans/会员额度不叠加_76777dd5.md
create mode 100644 .codebuddy/plans/虚拟支付退款推送字段修复与查单兜底_6024f92b.md
create mode 100644 .codebuddy/plans/退款申请订单状态流转修复_7d9218ad.md
create mode 100644 internal/mysqldao/order_refund_test.go
create mode 100644 internal/service/cos_quota_test.go
create mode 100644 internal/service/quota_select_test.go
create mode 100644 internal/service/refund_apply_test.go
create mode 100644 internal/service/virtual_refund_confirm.go
create mode 100644 internal/service/virtual_refund_test.go
create mode 100644 pkg/migration/000002_order_status_refunding.down.sql
create mode 100644 pkg/migration/000002_order_status_refunding.up.sql
diff --git a/.codebuddy/plans/会员额度不叠加_76777dd5.md b/.codebuddy/plans/会员额度不叠加_76777dd5.md
new file mode 100644
index 0000000..94344bb
--- /dev/null
+++ b/.codebuddy/plans/会员额度不叠加_76777dd5.md
@@ -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 ./...` 全量通过,确保现有发放幂等与补偿测试不受影响。
\ No newline at end of file
diff --git a/.codebuddy/plans/虚拟支付退款推送字段修复与查单兜底_6024f92b.md b/.codebuddy/plans/虚拟支付退款推送字段修复与查单兜底_6024f92b.md
new file mode 100644
index 0000000..c5ccc96
--- /dev/null
+++ b/.codebuddy/plans/虚拟支付退款推送字段修复与查单兜底_6024f92b.md
@@ -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: 输出完整的引用清单与受影响的文件/调用点,确保字段替换无遗漏、无编译期破坏,并避免与既有命名重复
\ No newline at end of file
diff --git a/.codebuddy/plans/退款申请订单状态流转修复_7d9218ad.md b/.codebuddy/plans/退款申请订单状态流转修复_7d9218ad.md
new file mode 100644
index 0000000..b79b77c
--- /dev/null
+++ b/.codebuddy/plans/退款申请订单状态流转修复_7d9218ad.md
@@ -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 事务
锁订单行 + 2→6 + 写/复用流水]
+ C --> D[(orders.order_status=6)]
+ D --> E{微信退款回调}
+ E -->|SUCCESS| F[RefundSettleWithRevokeTask
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: 输出完整的引用清单与需同步修改的位置,避免遗漏消费点导致状态机断裂
\ No newline at end of file
diff --git a/README.md b/README.md
index 83292e7..660a8b1 100644
--- a/README.md
+++ b/README.md
@@ -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 小时告警人工 |
## 部署提示
diff --git a/Stitch.md b/Stitch.md
index 21d038d..f3e931b 100644
--- a/Stitch.md
+++ b/Stitch.md
@@ -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` 表示退款失败可重新申请)。空态:票据线稿插画 +「还没有订单」+ 描边按钮「去看看额度包」。
---
diff --git a/internal/dto/order.go b/internal/dto/order.go
index 78f560f..1b6ac4d 100644
--- a/internal/dto/order.go
+++ b/internal/dto/order.go
@@ -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 订单列表
diff --git a/internal/mysqldao/benefit_revoke.go b/internal/mysqldao/benefit_revoke.go
index f6944a2..caf9358 100644
--- a/internal/mysqldao/benefit_revoke.go
+++ b/internal/mysqldao/benefit_revoke.go
@@ -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
}
diff --git a/internal/mysqldao/order.go b/internal/mysqldao/order.go
index 9039913..090e78f 100644
--- a/internal/mysqldao/order.go
+++ b/internal/mysqldao/order.go
@@ -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")
diff --git a/internal/mysqldao/order_refund_test.go b/internal/mysqldao/order_refund_test.go
new file mode 100644
index 0000000..ff167ce
--- /dev/null
+++ b/internal/mysqldao/order_refund_test.go
@@ -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)
+ }
+}
diff --git a/internal/mysqlmodel/order.go b/internal/mysqlmodel/order.go
index 9551c41..3e0b688 100644
--- a/internal/mysqlmodel/order.go
+++ b/internal/mysqlmodel/order.go
@@ -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"` // 商品类型(快照)
diff --git a/internal/redisdao/virtualpay.go b/internal/redisdao/virtualpay.go
index 13a3fe0..929c69f 100644
--- a/internal/redisdao/virtualpay.go
+++ b/internal/redisdao/virtualpay.go
@@ -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) {
diff --git a/internal/redismodel/lock.go b/internal/redismodel/lock.go
index 9b0637c..fca14eb 100644
--- a/internal/redismodel/lock.go
+++ b/internal/redismodel/lock.go
@@ -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" // 虚拟支付退款查单兜底定时任务防重(无参数,全局单实例)
)
diff --git a/internal/redismodel/virtualpay.go b/internal/redismodel/virtualpay.go
index 6e14007..6758ccd 100644
--- a/internal/redismodel/virtualpay.go
+++ b/internal/redismodel/virtualpay.go
@@ -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"
)
diff --git a/internal/service/constants.go b/internal/service/constants.go
index 846e1b0..0c95746 100644
--- a/internal/service/constants.go
+++ b/internal/service/constants.go
@@ -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)
diff --git a/internal/service/cos_quota_test.go b/internal/service/cos_quota_test.go
new file mode 100644
index 0000000..5d626b5
--- /dev/null
+++ b/internal/service/cos_quota_test.go
@@ -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)
+ }
+}
diff --git a/internal/service/order.go b/internal/service/order.go
index 9d1c2f4..696f774 100644
--- a/internal/service/order.go
+++ b/internal/service/order.go
@@ -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: "退款回收未使用的赠送额度",
diff --git a/internal/service/quota_select_test.go b/internal/service/quota_select_test.go
new file mode 100644
index 0000000..81931be
--- /dev/null
+++ b/internal/service/quota_select_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/service/refund_apply_test.go b/internal/service/refund_apply_test.go
new file mode 100644
index 0000000..9d465c3
--- /dev/null
+++ b/internal/service/refund_apply_test.go
@@ -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)
+ }
+}
diff --git a/internal/service/service.go b/internal/service/service.go
index b341b97..c2a74ba 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -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,退不满时数字虚高。现统一按实际回退量记账,
// 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。
diff --git a/internal/service/virtual_refund_confirm.go b/internal/service/virtual_refund_confirm.go
new file mode 100644
index 0000000..92d796f
--- /dev/null
+++ b/internal/service/virtual_refund_confirm.go
@@ -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
+}
diff --git a/internal/service/virtual_refund_test.go b/internal/service/virtual_refund_test.go
new file mode 100644
index 0000000..26be406
--- /dev/null
+++ b/internal/service/virtual_refund_test.go
@@ -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))])
+ }
+}
diff --git a/internal/service/virtualpay.go b/internal/service/virtualpay.go
index 5db4919..a658a98 100644
--- a/internal/service/virtualpay.go
+++ b/internal/service/virtualpay.go
@@ -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 回收(回收链路保证「已消耗部分不回退」)。
diff --git a/main.go b/main.go
index e526aa6..621c6ff 100644
--- a/main.go
+++ b/main.go
@@ -67,6 +67,8 @@ func main() {
service.StartClothesProcessingRecoveryScheduler()
service.StartOrderExpireScheduler()
service.StartVirtualPayConfirmScheduler()
+ // 虚拟支付退款结果查单兜底:退款推送丢失/未识别时避免订单永久停留在「申请退款中」。
+ service.StartVirtualRefundConfirmScheduler()
// 清理超期未保存的 AI 推荐穿搭(status=3),避免长期堆积。
service.StartAIOutfitCleanupScheduler()
diff --git a/pkg/migration/000001_init.up.sql b/pkg/migration/000001_init.up.sql
index 25c6459..a4fa48b 100644
--- a/pkg/migration/000001_init.up.sql
+++ b/pkg/migration/000001_init.up.sql
@@ -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 '商品类型快照',
diff --git a/pkg/migration/000002_order_status_refunding.down.sql b/pkg/migration/000002_order_status_refunding.down.sql
new file mode 100644
index 0000000..a0feea5
--- /dev/null
+++ b/pkg/migration/000002_order_status_refunding.down.sql
@@ -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=已退款';
diff --git a/pkg/migration/000002_order_status_refunding.up.sql b/pkg/migration/000002_order_status_refunding.up.sql
new file mode 100644
index 0000000..28638ac
--- /dev/null
+++ b/pkg/migration/000002_order_status_refunding.up.sql
@@ -0,0 +1,3 @@
+-- 订单状态新增 6=申请退款中:仅更新列注释,便于运营按状态筛选待处理退款单,无数据回填。
+ALTER TABLE `orders`
+ MODIFY COLUMN `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中';
diff --git a/pkg/wechat/virtualpay.go b/pkg/wechat/virtualpay.go
index 29ab6d0..61d4664 100644
--- a/pkg/wechat/virtualpay.go
+++ b/pkg/wechat/virtualpay.go
@@ -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) {
diff --git a/pkg/wechat/virtualpay_test.go b/pkg/wechat/virtualpay_test.go
index c8b2b5f..943e9a2 100644
--- a/pkg/wechat/virtualpay_test.go
+++ b/pkg/wechat/virtualpay_test.go
@@ -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) {
// 只填现网 -> 现网
diff --git a/接口文档.md b/接口文档.md
index cdb0466..3414194 100644
--- a/接口文档.md
+++ b/接口文档.md
@@ -228,7 +228,7 @@ Authorization: Bearer
| 衣物 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
| 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
| 3 | 社区分享额度(搭配分享) | 每日 | 10 | 穿搭首次公开扣 1 |
| 4 | 衣柜分享额度 | 每日 | 5 | 创建一次衣柜分享扣 1 |
-**② 额度凭证**(`quota_grants`)—— 有有效期的赠送额度,按来源分三类:
+**② 额度凭证**(`quota_grants`)—— 有有效期的赠送额度,按来源分四类:
| source | 来源 | 有效期 |
| --- | --- | --- |
@@ -650,7 +650,8 @@ Authorization: Bearer
> 新用户注册时,`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
### 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 额度,且**不保存不退**
- [ ] 点赞/收藏按钮做乐观更新 + 失败回滚(接口幂等,可重试)