修复(#16): 修复订单退款状态与后台恢复冲突 (#16)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-06-09 11:31:31 -07:00
committed by GitHub
parent 21811f4d63
commit e74958e17f
9 changed files with 302 additions and 63 deletions
@@ -55,6 +55,8 @@ func orderStatusName(status uint8) string {
case 5: case 5:
return "finished" return "finished"
case 6: case 6:
return "claimed"
case 7:
return "refunded" return "refunded"
default: default:
return "unknown" return "unknown"
+16 -12
View File
@@ -20,7 +20,7 @@ import (
) )
const ( const (
orderStatusRefunded = 6 orderStatusRefunded = 7
) )
type RefundOrderLogic struct { type RefundOrderLogic struct {
@@ -67,15 +67,12 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status) return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status)
} }
// 幂等校验:若该 order_no 已存在 333 退款日志,拒绝再次退款。
// HIF-131 案例:订单状态被外部入口(stuckOrderRecovery 把 6 视为卡住的 claim)回退到 5,
// 让 lockCommissionSource 误抓到原始 331/332 amount 再次扣减佣金。
refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo) refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo)
if err != nil { if err != nil {
return err return err
} }
if refunded { if refunded {
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund commission log", orderInfo.Id) return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund log", orderInfo.Id)
} }
userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo) userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
@@ -183,17 +180,19 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
return err return err
} }
if len(cachesToClear) > 0 { if len(cachesToClear) > 0 && l.svcCtx.UserModel != nil {
if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, cachesToClear...); clearErr != nil { if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, cachesToClear...); clearErr != nil {
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id)) l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id))
} }
} }
for _, subscribeID := range planCacheIDs { for _, subscribeID := range planCacheIDs {
if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil { if l.svcCtx.SubscribeModel != nil {
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID)) if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil {
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID))
}
} }
} }
if len(userCacheTargets) > 0 { if len(userCacheTargets) > 0 && l.svcCtx.UserModel != nil {
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil { if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil {
l.Errorw("[RefundOrder] clear user cache failed", logger.Field("error", clearErr.Error())) l.Errorw("[RefundOrder] clear user cache failed", logger.Field("error", clearErr.Error()))
} }
@@ -375,10 +374,15 @@ func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, ord
return nil, 0, nil return nil, 0, nil
} }
// hasRefundLog 检查指定 order_no 是否已有 333 (CommissionTypeRefund) 退款佣金日志。 // hasRefundLog checks refund audit logs first, then falls back to legacy commission refund logs.
// 仅扫 type=33 + 内容含 order_no 的命中项,再用 JSON 二次确认 content.type==333
// 防止 content.order_no 子串误判。
func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) { func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) {
refunded, err := log.HasOrderRefundLog(tx, orderNo)
if err != nil {
return false, err
}
if refunded {
return true, nil
}
return log.HasRefundCommissionLog(tx, orderNo) return log.HasRefundCommissionLog(tx, orderNo)
} }
@@ -27,8 +27,8 @@ func TestOrderStatusName(t *testing.T) {
3: "closed", 3: "closed",
4: "failed", 4: "failed",
5: "finished", 5: "finished",
6: "refunded", 6: "claimed",
7: "unknown", 7: "refunded",
} }
for input, want := range tests { for input, want := range tests {
@@ -96,14 +96,64 @@ func TestBuildRefundAuditLog(t *testing.T) {
} }
} }
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-131 / HIF-132 修复: func TestRefundOrder_SetsStatusRefunded(t *testing.T) {
// 当 system_logs 已存在该订单的 333 退款佣金日志时,再次调用 RefundOrder 必须: const (
orderID = int64(1000)
orderNo = "ORD-REFUND-SUCCESS"
operatorUID = int64(519)
userID = int64(7000)
subscribeID = int64(8000)
userSubID = int64(9000)
)
db, mock, cleanup := newRefundOrderTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `order`").
WithArgs(orderID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}).
AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID))
mock.ExpectQuery("FROM `system_logs`").
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
mock.ExpectQuery("FROM `system_logs`").
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
mock.ExpectQuery("FROM `user_subscribe`").
WithArgs(orderID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time"}).
AddRow(userSubID, userID, orderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour)))
mock.ExpectQuery("FROM `system_logs`").
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
mock.ExpectExec("UPDATE `order`").
WithArgs(orderStatusRefunded, sqlmock.AnyArg(), orderID, 2, 5).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE `user_subscribe`").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO `system_logs`").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
logic := newTestRefundOrderLogic(t, db, operatorUID)
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "manual refund"})
if err != nil {
t.Fatalf("RefundOrder error: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-16 修复:
// 当 system_logs 已存在该订单的 24 退款审计日志时,再次调用 RefundOrder 必须:
// 1. 返回 OrderAlreadyRefunded 错误码; // 1. 返回 OrderAlreadyRefunded 错误码;
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发); // 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。 // 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
// //
// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE / // 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE /
// SELECT system_logs(命中 333/ ROLLBACK,不允许出现 commission 锁/更新/插入。 // SELECT system_logs(命中 24/ ROLLBACK,不允许出现 commission 锁/更新/插入。
func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) { func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
const ( const (
orderID = int64(53647) orderID = int64(53647)
@@ -120,9 +170,9 @@ func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id"}). WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id"}).
AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028))) AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028)))
mock.ExpectQuery("FROM `system_logs`"). mock.ExpectQuery("FROM `system_logs`").
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
AddRow(1, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-2250,"timestamp":0}`, orderNo))) AddRow(1, fmt.Sprintf(`{"order_id":%d,"order_no":"%s","order_status_before":5,"order_status_after":7}`, orderID, orderNo)))
mock.ExpectRollback() mock.ExpectRollback()
logic := newTestRefundOrderLogic(t, db, operatorUID) logic := newTestRefundOrderLogic(t, db, operatorUID)
@@ -138,12 +188,12 @@ func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
} }
} }
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==6 拒绝路径, // TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==7 拒绝路径,
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。 // 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) { func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
const ( const (
orderID = int64(1001) orderID = int64(1001)
orderNo = "ORD-STATUS-6" orderNo = "ORD-STATUS-7"
operatorUID = int64(519) operatorUID = int64(519)
) )
@@ -306,6 +356,9 @@ func TestRefundOrder_NoTargetSubscriptionDoesNotRefundOrder(t *testing.T) {
WithArgs(orderID, 1). WithArgs(orderID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}). WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}).
AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID)) AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID))
mock.ExpectQuery("FROM `system_logs`").
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
mock.ExpectQuery("FROM `system_logs`"). mock.ExpectQuery("FROM `system_logs`").
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
WillReturnRows(sqlmock.NewRows([]string{"id", "content"})) WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
+23 -3
View File
@@ -7,9 +7,6 @@ import (
) )
// HasRefundCommissionLog 判断指定订单号是否已写入 333 退款佣金日志。 // HasRefundCommissionLog 判断指定订单号是否已写入 333 退款佣金日志。
// 用于 refund 主流程做幂等校验,以及 stuck-order recovery / activate worker
// 区分「已退款」(terminal)与「短暂 claimed」(transient)这两种共用 status=6
// 的语义。
// //
// 实现细节: // 实现细节:
// 1. type=33 + content LIKE '%"order_no":"<orderNo>"%' 先走索引粗筛; // 1. type=33 + content LIKE '%"order_no":"<orderNo>"%' 先走索引粗筛;
@@ -36,3 +33,26 @@ func HasRefundCommissionLog(tx *gorm.DB, orderNo string) (bool, error) {
} }
return false, nil return false, nil
} }
// HasOrderRefundLog 判断指定订单号是否已写入 24 订单退款审计日志。
func HasOrderRefundLog(tx *gorm.DB, orderNo string) (bool, error) {
if orderNo == "" {
return false, nil
}
var logs []SystemLog
if err := tx.Model(&SystemLog{}).
Where("type = ? AND content LIKE ?", TypeOrderRefund.Uint8(), fmt.Sprintf("%%\"order_no\":\"%s\"%%", orderNo)).
Find(&logs).Error; err != nil {
return false, fmt.Errorf("query order refund log failed: %w", err)
}
for _, item := range logs {
var content OrderRefund
if err := content.Unmarshal([]byte(item.Content)); err != nil {
continue
}
if content.OrderNo == orderNo {
return true, nil
}
}
return false, nil
}
+56
View File
@@ -140,6 +140,62 @@ func TestHasRefundCommissionLog(t *testing.T) {
}) })
} }
func TestHasOrderRefundLog(t *testing.T) {
const orderNo = "ORD-REFUND-AUDIT-1"
t.Run("returns true when order refund audit log exists", func(t *testing.T) {
db, mock, cleanup := newRefundLogTestDB(t)
defer cleanup()
mock.ExpectQuery("FROM `system_logs`").
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
AddRow(1, fmt.Sprintf(`{"order_id":100,"order_no":"%s","order_status_after":7}`, orderNo)))
got, err := HasOrderRefundLog(db, orderNo)
if err != nil {
t.Fatalf("HasOrderRefundLog error: %v", err)
}
if !got {
t.Fatalf("HasOrderRefundLog = false, want true")
}
assertRefundLogExpectations(t, mock)
})
t.Run("returns false when audit log order_no does not match", func(t *testing.T) {
db, mock, cleanup := newRefundLogTestDB(t)
defer cleanup()
mock.ExpectQuery("FROM `system_logs`").
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
AddRow(1, `{"order_id":100,"order_no":"OTHER","order_status_after":7}`))
got, err := HasOrderRefundLog(db, orderNo)
if err != nil {
t.Fatalf("HasOrderRefundLog error: %v", err)
}
if got {
t.Fatalf("HasOrderRefundLog = true, want false")
}
assertRefundLogExpectations(t, mock)
})
t.Run("returns false for empty order_no without querying", func(t *testing.T) {
db, mock, cleanup := newRefundLogTestDB(t)
defer cleanup()
got, err := HasOrderRefundLog(db, "")
if err != nil {
t.Fatalf("HasOrderRefundLog error: %v", err)
}
if got {
t.Fatalf("HasOrderRefundLog = true, want false")
}
assertRefundLogExpectations(t, mock)
})
}
func newRefundLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { func newRefundLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper() t.Helper()
+64
View File
@@ -0,0 +1,64 @@
## 关联 Issue
Closes HIF-16
## 改动摘要
拆分订单退款终态与队列 claim 临时态:退款成功后写入 `status=7``status=6` 仅保留为 worker claimed 状态,避免后台恢复任务把退款订单重新入队。
## 改动细节
- `internal/logic/admin/order/refundOrderLogic.go`:退款状态改为 `7`,重复退款优先检查 `type=24` 退款审计日志,再兼容旧的 `333` 佣金退款日志。
- `queue/logic/order/activateOrderLogic.go``queue/logic/order/stuckOrderRecoveryLogic.go`release/recovery 只按真实 `claimed(6)` 回退,不再依赖 `333` 日志猜测状态语义。
- `internal/logic/admin/order/getOrderListLogic.go`:管理端状态展示改为 `6=claimed``7=refunded`
- `internal/model/log/refund.go`:新增 `HasOrderRefundLog`,用于基于退款审计日志做幂等判断。
- `internal/logic/admin/order/refundOrderLogic_test.go``internal/model/log/refund_test.go``queue/logic/order/order_status_recovery_test.go`:补充退款写 `7`、重复退款、退款日志判断、claim release/recovery 行为测试。
## 测试计划
- [ ] `go build ./...` 通过
- [x] `go vet ./...` 通过
- [ ] `go test -race ./... -count=1` 通过
- [ ] golangci-lint 通过
- [x] 新增/修改的逻辑有对应单测覆盖
- [x] (如涉及 DB 变更)无 DB migration
- [ ] (如涉及 APIcurl / Postman 验证命令贴在下面
```
go test ./internal/logic/admin/order ./internal/model/log ./queue/logic/order
ok github.com/perfect-panel/server/internal/logic/admin/order
ok github.com/perfect-panel/server/internal/model/log
ok github.com/perfect-panel/server/queue/logic/order
go test ./...
PASS
go test -v ./...
PASS
go vet ./...
PASS
golangci-lint run --new-from-rev=origin/internal
PASS
```
说明:本地 `lefthook run pre-commit` 无法执行,因为环境缺少 `lefthook` 命令;已手动执行 hook 配置中的等价检查。全量 `golangci-lint run` 仍会失败在 internal 上已有历史 lint 问题,本分支使用 `--new-from-rev=origin/internal` 验证未新增 lint 问题。
未做 curl 验证:本地没有可用服务配置与测试数据库,且该改动主要通过事务单测覆盖退款状态与队列恢复行为。
## 风险 / 回滚
- 风险:线上若已经存在 `status=6` 表示已退款的历史订单,本改动不会自动迁移,后续查询会显示为 `claimed`。建议上线前评估历史数据,可按退款审计日志或旧 `333` 佣金退款日志筛出并迁移到 `status=7`
- 回滚:回滚本 PR 后恢复旧的 `status=6` 退款语义和 `333` 日志守卫;不涉及 schema 变更。
## Reviewer 自检清单
- [x] PR 标题符合 commitlint 规范(`修复/新功能/重构/文档/配置(#<num>): ...`
- [x] 分支命名 `fix/<num>-…` / `feat/<num>-…` / `chore/…`
- [x] 目标分支 = `internal`
- [x] 改动 scope 与 Issue 描述一致,无 scope creep
- [x] **无无关代码改动**(架构师红线)
- [x] 无密钥/凭证泄露
- [ ] CI 全绿
- [ ] 测试工程师已验收(如涉及业务逻辑)
+1 -19
View File
@@ -49,6 +49,7 @@ const (
OrderStatusClose = 3 // Order closed/cancelled OrderStatusClose = 3 // Order closed/cancelled
OrderStatusFailed = 4 // Order processing failed OrderStatusFailed = 4 // Order processing failed
OrderStatusClaimed = 6 // Internal transient claim while a worker processes the order OrderStatusClaimed = 6 // Internal transient claim while a worker processes the order
OrderStatusRefunded = 7 // Order refunded terminal state
OrderStatusFinished = 5 // Order successfully completed OrderStatusFinished = 5 // Order successfully completed
) )
@@ -272,25 +273,6 @@ func (l *ActivateOrderLogic) claimAndGetOrder(ctx context.Context, orderNo strin
} }
func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) error { func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) error {
// 终态守卫:OrderStatusClaimed(6) 与 orderStatusRefunded(6) 共用同一枚举值。
// 若已存在 333 退款日志,说明此处的 status=6 是「已退款」,不能再降回 5,
// 否则下次 activate 会重新激活订阅、且管理员可二次触发退款导致佣金被多次扣减。
// 详见 HIF-131 / HIF-132。
refunded, err := log.HasRefundCommissionLog(l.svc.DB.WithContext(ctx), orderNo)
if err != nil {
logger.WithContext(ctx).Error("Check refund log before release claim failed",
logger.Field("error", err.Error()),
logger.Field("order_no", orderNo),
)
return fmt.Errorf("check refund log failed for order %s: %w", orderNo, err)
}
if refunded {
logger.WithContext(ctx).Info("Skip release claim for refunded order (status=6 + refund log)",
logger.Field("order_no", orderNo),
)
return nil
}
if err := l.svc.DB.WithContext(ctx). if err := l.svc.DB.WithContext(ctx).
Model(&order.Order{}). Model(&order.Order{}).
Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed). Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed).
@@ -0,0 +1,78 @@
package orderLogic
import (
"context"
"fmt"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/perfect-panel/server/internal/svc"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestReleaseClaimOnlyReleasesClaimedStatus(t *testing.T) {
const orderNo = "ORD-CLAIMED-1"
db, mock, cleanup := newOrderQueueTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order`").
WithArgs(OrderStatusPaid, sqlmock.AnyArg(), orderNo, OrderStatusClaimed).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
logic := NewActivateOrderLogic(&svc.ServiceContext{DB: db})
if err := logic.releaseClaim(context.Background(), orderNo); err != nil {
t.Fatalf("releaseClaim error: %v", err)
}
assertOrderQueueExpectations(t, mock)
}
func TestStuckOrderRecoveryScansOnlyClaimedStatus(t *testing.T) {
db, mock, cleanup := newOrderQueueTestDB(t)
defer cleanup()
mock.ExpectQuery("FROM `order`").
WithArgs(OrderStatusClaimed, sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"}))
logic := NewStuckOrderRecoveryLogic(&svc.ServiceContext{DB: db})
if err := logic.ProcessTask(context.Background(), nil); err != nil {
t.Fatalf("ProcessTask error: %v", err)
}
assertOrderQueueExpectations(t, mock)
}
func newOrderQueueTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if strings.Contains(actualSQL, expectedSQL) {
return nil
}
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
func assertOrderQueueExpectations(t *testing.T, mock sqlmock.Sqlmock) {
t.Helper()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
@@ -6,7 +6,6 @@ import (
"time" "time"
"github.com/hibiken/asynq" "github.com/hibiken/asynq"
logmodel "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@@ -55,25 +54,6 @@ func (l *StuckOrderRecoveryLogic) ProcessTask(ctx context.Context, _ *asynq.Task
for i := range stuckOrders { for i := range stuckOrders {
o := &stuckOrders[i] o := &stuckOrders[i]
// 终态守卫:OrderStatusClaimed(6) 与 orderStatusRefunded(6) 共用同一枚举值,
// 若该订单已写入 333 退款佣金日志,说明状态 6 表示「已退款」而非「短暂 claim」,
// 必须跳过,否则会把已退款订单重置为 5 + 重新入队 activate,导致重复退款。
// 详见 HIF-131 / HIF-132。
refunded, err := logmodel.HasRefundCommissionLog(l.svc.DB.WithContext(ctx), o.OrderNo)
if err != nil {
logger.WithContext(ctx).Error("[StuckOrderRecovery] Failed to check refund log",
logger.Field("order_no", o.OrderNo),
logger.Field("error", err.Error()),
)
continue
}
if refunded {
logger.WithContext(ctx).Info("[StuckOrderRecovery] Skip refunded order (status=6 + refund log)",
logger.Field("order_no", o.OrderNo),
)
continue
}
result := l.svc.DB.WithContext(ctx). result := l.svc.DB.WithContext(ctx).
Model(&order.Order{}). Model(&order.Order{}).
Where("order_no = ? AND status = ?", o.OrderNo, OrderStatusClaimed). Where("order_no = ? AND status = ?", o.OrderNo, OrderStatusClaimed).