问题:
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() 的修复方案完全对齐
153 lines
4.6 KiB
Go
Executable File
153 lines
4.6 KiB
Go
Executable File
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)
|
||
}
|
||
}
|