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 额度,且**不保存不退** - [ ] 点赞/收藏按钮做乐观更新 + 失败回滚(接口幂等,可重试)