修复(#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:
return "finished"
case 6:
return "claimed"
case 7:
return "refunded"
default:
return "unknown"
+16 -12
View File
@@ -20,7 +20,7 @@ import (
)
const (
orderStatusRefunded = 6
orderStatusRefunded = 7
)
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)
}
// 幂等校验:若该 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)
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund log", orderInfo.Id)
}
userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
@@ -183,17 +180,19 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
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 {
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id))
}
}
for _, subscribeID := range planCacheIDs {
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 l.svcCtx.SubscribeModel != nil {
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 {
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
}
// hasRefundLog 检查指定 order_no 是否已有 333 (CommissionTypeRefund) 退款佣金日志。
// 仅扫 type=33 + 内容含 order_no 的命中项,再用 JSON 二次确认 content.type==333
// 防止 content.order_no 子串误判。
// hasRefundLog checks refund audit logs first, then falls back to legacy commission refund logs.
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)
}
@@ -27,8 +27,8 @@ func TestOrderStatusName(t *testing.T) {
3: "closed",
4: "failed",
5: "finished",
6: "refunded",
7: "unknown",
6: "claimed",
7: "refunded",
}
for input, want := range tests {
@@ -96,14 +96,64 @@ func TestBuildRefundAuditLog(t *testing.T) {
}
}
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-131 / HIF-132 修复:
// 当 system_logs 已存在该订单的 333 退款佣金日志时,再次调用 RefundOrder 必须:
func TestRefundOrder_SetsStatusRefunded(t *testing.T) {
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 错误码;
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
//
// 通过 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) {
const (
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"}).
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)).
WithArgs(uint8(24), 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)))
AddRow(1, fmt.Sprintf(`{"order_id":%d,"order_no":"%s","order_status_before":5,"order_status_after":7}`, orderID, orderNo)))
mock.ExpectRollback()
logic := newTestRefundOrderLogic(t, db, operatorUID)
@@ -138,12 +188,12 @@ func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
}
}
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==6 拒绝路径,
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==7 拒绝路径,
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
const (
orderID = int64(1001)
orderNo = "ORD-STATUS-6"
orderNo = "ORD-STATUS-7"
operatorUID = int64(519)
)
@@ -306,6 +356,9 @@ func TestRefundOrder_NoTargetSubscriptionDoesNotRefundOrder(t *testing.T) {
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"}))
+23 -3
View File
@@ -7,9 +7,6 @@ import (
)
// HasRefundCommissionLog 判断指定订单号是否已写入 333 退款佣金日志。
// 用于 refund 主流程做幂等校验,以及 stuck-order recovery / activate worker
// 区分「已退款」(terminal)与「短暂 claimed」(transient)这两种共用 status=6
// 的语义。
//
// 实现细节:
// 1. type=33 + content LIKE '%"order_no":"<orderNo>"%' 先走索引粗筛;
@@ -36,3 +33,26 @@ func HasRefundCommissionLog(tx *gorm.DB, orderNo string) (bool, error) {
}
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()) {
t.Helper()