bindbox-game/internal/service/user/order_timeout.go
Zuncle 535106f158 fix(coupon): 修复订单超时取消时金额券未退还的bug
问题:
  commit b9a40df 修复了 CancelOrder()(用户/管理员主动取消)的券退回逻辑,
  去掉了 `AND status = 4` 条件,但遗漏了 order_timeout.go 中超时取消的
  同一逻辑,导致次数卡包(game_pass_package)订单超时取消时金额券余额丢失。

根因:
  game_pass_package 下单时,金额券(type=1)通过 applyCouponToGamePassOrder()
  直接扣减 balance_amount 并保持 status=1(有余额)或 status=2(用完),
  不会设置 status=4(预扣中)。而 cancelExpiredOrder() 的 UPDATE 语句带有
  `WHERE id = ? AND status = 4` 条件,导致匹配不到行,退券静默失败。

  生产已确认影响:用户9110的券1690(订单28229)和券1532(订单26743)
  因此bug各丢失10元余额。

修复:
  - 去掉 `AND status = 4` 条件,改为 `WHERE id = ?`,兼容所有券状态
  - 新增幂等校验:先查 timeout_refund 流水是否已存在,防止重复退还
  - 新增兜底逻辑:order_coupons 无记录时,从 user_coupon_ledger 流水
    回推预扣金额,与 CancelOrder() 的修复方案完全对齐
2026-03-20 20:32:30 +08:00

153 lines
4.6 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package user
import (
"context"
"fmt"
"time"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm/clause"
)
// StartOrderTimeoutTask 启动订单超时清理任务(每分钟执行)
func (s *service) StartOrderTimeoutTask(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.cleanupExpiredOrders()
}
}
}
// cleanupExpiredOrders 清理超时未支付的订单
// 规则待支付订单超过30分钟自动取消并恢复已预扣的优惠券
func (s *service) cleanupExpiredOrders() {
ctx := context.Background()
cutoff := time.Now().Add(-30 * time.Minute)
// 查找所有超时的待支付订单(排除一番赏订单,因为有专门的清理任务)
var expiredOrders []struct {
ID int64
UserID int64
CouponID int64
OrderNo string
PointsAmount int64
}
err := s.readDB.Orders.WithContext(ctx).UnderlyingDB().Raw(`
SELECT id, user_id, coupon_id, order_no, points_amount
FROM orders
WHERE status = 1 AND created_at < ? AND source_type != 3
LIMIT 100
`, cutoff).Scan(&expiredOrders).Error
if err != nil {
fmt.Printf("OrderTimeoutTask: 查询超时订单失败: %v\n", err)
return
}
for _, order := range expiredOrders {
s.cancelExpiredOrder(ctx, order.ID, order.UserID, order.CouponID, order.PointsAmount)
}
if len(expiredOrders) > 0 {
fmt.Printf("OrderTimeoutTask: 已处理 %d 个超时订单\n", len(expiredOrders))
}
}
// cancelExpiredOrder 取消超时订单并恢复优惠券
func (s *service) cancelExpiredOrder(ctx context.Context, orderID int64, userID int64, couponID int64, pointsAmount int64) {
// 1. 恢复优惠券
if couponID > 0 {
// 幂等校验:若已记录过 timeout_refund 流水则跳过
var refundCount int64
s.readDB.UserCouponLedger.WithContext(ctx).UnderlyingDB().Raw(`
SELECT COUNT(*) FROM user_coupon_ledger
WHERE user_coupon_id = ? AND order_id = ? AND action = 'timeout_refund'
`, couponID, orderID).Scan(&refundCount)
if refundCount == 0 {
// 优先从 order_coupons 获取实际抵扣金额
var appliedAmount int64
s.readDB.OrderCoupons.WithContext(ctx).UnderlyingDB().Raw(`
SELECT applied_amount FROM order_coupons
WHERE order_id = ? AND user_coupon_id = ?
`, orderID, couponID).Scan(&appliedAmount)
// 兜底order_coupons 无记录时,从流水中回推预扣金额
if appliedAmount <= 0 {
s.readDB.UserCouponLedger.WithContext(ctx).UnderlyingDB().Raw(`
SELECT COALESCE(SUM(CASE WHEN change_amount < 0 THEN -change_amount ELSE 0 END), 0)
FROM user_coupon_ledger
WHERE user_id = ? AND user_coupon_id = ? AND order_id = ? AND action IN ('reserve', 'usage')
`, userID, couponID, orderID).Scan(&appliedAmount)
}
if appliedAmount > 0 {
// 恢复余额 + 重置状态(不依赖 status 条件,兼容金额券 status=1/2 和冻结券 status=4
res := s.writeDB.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount + ?,
status = 1,
used_order_id = NULL,
used_at = NULL
WHERE id = ?
`, appliedAmount, couponID)
if res.RowsAffected > 0 {
// 记录流水
s.writeDB.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{
UserID: userID,
UserCouponID: couponID,
ChangeAmount: appliedAmount,
OrderID: orderID,
Action: "timeout_refund",
CreatedAt: time.Now(),
})
}
}
}
}
// 2. 退还积分(如有)
if pointsAmount > 0 {
existing, _ := s.writeDB.UserPoints.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where(s.writeDB.UserPoints.UserID.Eq(userID)).First()
if existing != nil {
s.writeDB.UserPoints.WithContext(ctx).
Where(s.writeDB.UserPoints.ID.Eq(existing.ID)).
Updates(map[string]any{"points": existing.Points + pointsAmount})
} else {
s.writeDB.UserPoints.WithContext(ctx).
Omit(s.writeDB.UserPoints.ValidEnd).
Create(&model.UserPoints{UserID: userID, Points: pointsAmount})
}
s.writeDB.UserPointsLedger.WithContext(ctx).Create(&model.UserPointsLedger{
UserID: userID,
Action: "timeout_refund",
Points: pointsAmount,
RefTable: "orders",
RefID: fmt.Sprintf("%d", orderID),
Remark: "order_timeout",
})
}
// 3. 更新订单状态为已取消
res := s.writeDB.Orders.WithContext(ctx).UnderlyingDB().Exec(`
UPDATE orders SET status = 3, cancelled_at = NOW() WHERE id = ? AND status = 1
`, orderID)
if res.RowsAffected > 0 {
fmt.Printf("OrderTimeoutTask: 订单 %d 已超时取消\n", orderID)
}
}