From 5b9f384f818c0f8735d976d9a3727455eb6b9a08 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Sun, 31 May 2026 20:19:35 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(#132):=20=E9=80=80=E6=AC=BE?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E6=A0=A1=E9=AA=8C=20+=20=E5=B7=B2=E9=80=80?= =?UTF-8?q?=E6=AC=BE=E8=AE=A2=E5=8D=95=E9=98=B2=E9=87=8D=E6=96=B0=E6=BF=80?= =?UTF-8?q?=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P01:refundOrderLogic.RefundOrder 在事务内 FOR UPDATE 后、lockCommissionSource 前新增 333 退款日志扫描,命中即返回 OrderAlreadyRefunded(61006),不再写日志/扣 commission/ 改 order.status。 P02:堵住已退款订单状态被回退入口 - queue/logic/order/stuckOrderRecoveryLogic.go:批扫 status=6 时新增 333 日志守卫, 已退款订单不再被重置为 5 + 重新入队 activate(HIF-131 trace 中订单 53647 被刷回 5 的真凶) - queue/logic/order/activateOrderLogic.go:releaseClaim 同步加守卫做防御性兜底 新增 internal/model/log/refund.go 共享 helper HasRefundCommissionLog: type=33 + content LIKE 走索引粗筛,再 JSON 反序列化确认 content.type==333 AND content.order_no==orderNo,防 LIKE 子串误判。 测试:单元测试覆盖正常退款 / 已有 333 日志拒绝 / 子串误判防御 / 脏 JSON 容错; sqlmock 严格断言命中后事务序列只含 BEGIN/SELECT order FOR UPDATE/SELECT system_logs/ROLLBACK,无任何 commission 写入。 不做:calculateCommission、status 枚举拆分、表结构变更、支付通道 notify、用户余额回补。 Co-authored-by: multica-agent --- .../logic/admin/order/refundOrderLogic.go | 18 ++ .../admin/order/refundOrderLogic_test.go | 164 +++++++++++++++++ internal/model/log/refund.go | 38 ++++ internal/model/log/refund_test.go | 172 ++++++++++++++++++ queue/logic/order/activateOrderLogic.go | 19 ++ queue/logic/order/stuckOrderRecoveryLogic.go | 20 ++ 6 files changed, 431 insertions(+) create mode 100644 internal/model/log/refund.go create mode 100644 internal/model/log/refund_test.go diff --git a/internal/logic/admin/order/refundOrderLogic.go b/internal/logic/admin/order/refundOrderLogic.go index 6113b90..feb105c 100644 --- a/internal/logic/admin/order/refundOrderLogic.go +++ b/internal/logic/admin/order/refundOrderLogic.go @@ -67,6 +67,17 @@ 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) } + // 幂等校验:若该 order_no 已存在 333 退款日志,拒绝再次退款。 + // HIF-131 案例:订单状态被外部入口(stuckOrderRecovery 把 6 视为卡住的 claim)回退到 5, + // 让 lockCommissionSource 误抓到原始 331/332 amount 再次扣减佣金。 + refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo) + if err != nil { + return err + } + if refunded { + return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund commission log", orderInfo.Id) + } + userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo) if err != nil { return err @@ -256,6 +267,13 @@ func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, ord return nil, 0, nil } +// hasRefundLog 检查指定 order_no 是否已有 333 (CommissionTypeRefund) 退款佣金日志。 +// 仅扫 type=33 + 内容含 order_no 的命中项,再用 JSON 二次确认 content.type==333, +// 防止 content.order_no 子串误判。 +func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) { + return log.HasRefundCommissionLog(tx, orderNo) +} + func (l *RefundOrderLogic) buildRefundAuditLog( operator *modeluser.User, orderInfo *modelorder.Order, diff --git a/internal/logic/admin/order/refundOrderLogic_test.go b/internal/logic/admin/order/refundOrderLogic_test.go index 7c1d7dc..e673f15 100644 --- a/internal/logic/admin/order/refundOrderLogic_test.go +++ b/internal/logic/admin/order/refundOrderLogic_test.go @@ -1,11 +1,23 @@ package order import ( + "context" + "fmt" + "strings" "testing" "time" + "github.com/DATA-DOG/go-sqlmock" + "github.com/perfect-panel/server/internal/svc" modelorder "github.com/perfect-panel/server/internal/model/order" modeluser "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/driver/mysql" + "gorm.io/gorm" ) func TestOrderStatusName(t *testing.T) { @@ -83,3 +95,155 @@ func TestBuildRefundAuditLog(t *testing.T) { t.Fatalf("unexpected commission transition: %+v", got) } } + +// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-131 / HIF-132 修复: +// 当 system_logs 已存在该订单的 333 退款佣金日志时,再次调用 RefundOrder 必须: +// 1. 返回 OrderAlreadyRefunded 错误码; +// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发); +// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。 +// +// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE / +// SELECT system_logs(命中 333)/ ROLLBACK,不允许出现 commission 锁/更新/插入。 +func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) { + const ( + orderID = int64(53647) + orderNo = "202605301925431836075753253" + operatorUID = int64(519) + ) + + 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"}). + AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028))) + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-2250,"timestamp":0}`, orderNo))) + mock.ExpectRollback() + + logic := newTestRefundOrderLogic(t, db, operatorUID) + err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "duplicate"}) + if err == nil { + t.Fatalf("RefundOrder expected error, got nil") + } + if !isErrCode(err, xerr.OrderAlreadyRefunded) { + t.Fatalf("RefundOrder error code = %v, want OrderAlreadyRefunded; raw=%v", errCodeOf(err), err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==6 拒绝路径, +// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。 +func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) { + const ( + orderID = int64(1001) + orderNo = "ORD-STATUS-6" + operatorUID = int64(519) + ) + + db, mock, cleanup := newRefundOrderTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `order`"). + WithArgs(orderID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"}). + AddRow(orderID, orderNo, uint8(orderStatusRefunded))) + mock.ExpectRollback() + + logic := newTestRefundOrderLogic(t, db, operatorUID) + err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID}) + if !isErrCode(err, xerr.OrderAlreadyRefunded) { + t.Fatalf("RefundOrder error code = %v, want OrderAlreadyRefunded; raw=%v", errCodeOf(err), err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestRefundOrder_RejectsWhenStatusNotRefundable 覆盖非 2/5 状态短路。 +func TestRefundOrder_RejectsWhenStatusNotRefundable(t *testing.T) { + const ( + orderID = int64(1002) + orderNo = "ORD-STATUS-1" + operatorUID = int64(519) + ) + + db, mock, cleanup := newRefundOrderTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `order`"). + WithArgs(orderID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"}). + AddRow(orderID, orderNo, uint8(1))) + mock.ExpectRollback() + + logic := newTestRefundOrderLogic(t, db, operatorUID) + err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID}) + if !isErrCode(err, xerr.OrderStatusError) { + t.Fatalf("RefundOrder error code = %v, want OrderStatusError; raw=%v", errCodeOf(err), err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func newRefundOrderTestDB(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 newTestRefundOrderLogic(t *testing.T, db *gorm.DB, operatorID int64) *RefundOrderLogic { + t.Helper() + ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &modeluser.User{Id: operatorID}) + return &RefundOrderLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: &svc.ServiceContext{DB: db}, + } +} + +// errCodeOf / isErrCode 用于绕开 wrapped error 检查内层 xerr 错误码。 +func errCodeOf(err error) uint32 { + if err == nil { + return 0 + } + type coder interface { + GetErrCode() uint32 + } + cause := errors.Cause(err) + if c, ok := cause.(coder); ok { + return c.GetErrCode() + } + return 0 +} + +func isErrCode(err error, code uint32) bool { + return errCodeOf(err) == code +} diff --git a/internal/model/log/refund.go b/internal/model/log/refund.go new file mode 100644 index 0000000..c138029 --- /dev/null +++ b/internal/model/log/refund.go @@ -0,0 +1,38 @@ +package log + +import ( + "fmt" + + "gorm.io/gorm" +) + +// HasRefundCommissionLog 判断指定订单号是否已写入 333 退款佣金日志。 +// 用于 refund 主流程做幂等校验,以及 stuck-order recovery / activate worker +// 区分「已退款」(terminal)与「短暂 claimed」(transient)这两种共用 status=6 +// 的语义。 +// +// 实现细节: +// 1. type=33 + content LIKE '%"order_no":""%' 先走索引粗筛; +// 2. 命中项再用 JSON 反序列化精确比对 content.type==333 与 content.order_no, +// 避免 order_no 出现在其它字段子串里产生误判。 +func HasRefundCommissionLog(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 ?", TypeCommission.Uint8(), fmt.Sprintf("%%\"order_no\":\"%s\"%%", orderNo)). + Find(&logs).Error; err != nil { + return false, fmt.Errorf("query refund commission log failed: %w", err) + } + for _, item := range logs { + var content Commission + if err := content.Unmarshal([]byte(item.Content)); err != nil { + continue + } + if content.Type == CommissionTypeRefund && content.OrderNo == orderNo { + return true, nil + } + } + return false, nil +} diff --git a/internal/model/log/refund_test.go b/internal/model/log/refund_test.go new file mode 100644 index 0000000..54528ec --- /dev/null +++ b/internal/model/log/refund_test.go @@ -0,0 +1,172 @@ +package log + +import ( + "fmt" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestHasRefundCommissionLog(t *testing.T) { + const orderNo = "ORD-REFUND-1" + + t.Run("returns true when 333 log exists for the order", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, fmt.Sprintf(`{"type":331,"order_no":"%s","amount":100}`, orderNo)). + AddRow(2, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-100}`, orderNo))) + + got, err := HasRefundCommissionLog(db, orderNo) + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if !got { + t.Fatalf("HasRefundCommissionLog = false, want true") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("returns false when only 331/332 logs exist", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, fmt.Sprintf(`{"type":331,"order_no":"%s","amount":100}`, orderNo)). + AddRow(2, fmt.Sprintf(`{"type":332,"order_no":"%s","amount":50}`, orderNo))) + + got, err := HasRefundCommissionLog(db, orderNo) + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if got { + t.Fatalf("HasRefundCommissionLog = true, want false") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("returns false when no log exists for the order", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"})) + + got, err := HasRefundCommissionLog(db, orderNo) + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if got { + t.Fatalf("HasRefundCommissionLog = 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 := HasRefundCommissionLog(db, "") + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if got { + t.Fatalf("HasRefundCommissionLog = true, want false") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("ignores 333 log when order_no in content does not match", func(t *testing.T) { + // Defensive: LIKE pattern may match a substring; JSON match catches it. + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, `{"type":333,"order_no":"OTHER","amount":-100}`)) + + got, err := HasRefundCommissionLog(db, orderNo) + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if got { + t.Fatalf("HasRefundCommissionLog = true, want false (different order_no)") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("ignores malformed json content", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, `not a json`). + AddRow(2, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-100}`, orderNo))) + + got, err := HasRefundCommissionLog(db, orderNo) + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if !got { + t.Fatalf("HasRefundCommissionLog = false, want true") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("returns error when db query fails", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnError(fmt.Errorf("connection lost")) + + if _, err := HasRefundCommissionLog(db, orderNo); err == nil { + t.Fatalf("expected error, got nil") + } + assertRefundLogExpectations(t, mock) + }) +} + +func newRefundLogTestDB(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 assertRefundLogExpectations(t *testing.T, mock sqlmock.Sqlmock) { + t.Helper() + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 6bd964e..cd46fbf 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -272,6 +272,25 @@ func (l *ActivateOrderLogic) claimAndGetOrder(ctx context.Context, orderNo strin } 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). Model(&order.Order{}). Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed). diff --git a/queue/logic/order/stuckOrderRecoveryLogic.go b/queue/logic/order/stuckOrderRecoveryLogic.go index 20e5d40..45c802d 100644 --- a/queue/logic/order/stuckOrderRecoveryLogic.go +++ b/queue/logic/order/stuckOrderRecoveryLogic.go @@ -6,6 +6,7 @@ import ( "time" "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/svc" "github.com/perfect-panel/server/pkg/logger" @@ -54,6 +55,25 @@ func (l *StuckOrderRecoveryLogic) ProcessTask(ctx context.Context, _ *asynq.Task for i := range stuckOrders { 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). Model(&order.Order{}). Where("order_no = ? AND status = ?", o.OrderNo, OrderStatusClaimed).