Files
hi-server/internal/logic/admin/order/refundOrderLogic_test.go
T
shanshanzhong147 5b9f384f81
Build docker and publish / build (20.15.1) (push) Failing after 21m4s
Build docker and publish / build (20.15.1) (pull_request) Failing after 21m47s
修复(#132): 退款幂等校验 + 已退款订单防重新激活
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 <github@multica.ai>
2026-05-31 20:19:35 -07:00

250 lines
7.6 KiB
Go
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 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) {
tests := map[uint8]string{
1: "pending",
2: "paid",
3: "closed",
4: "failed",
5: "finished",
6: "refunded",
7: "unknown",
}
for input, want := range tests {
if got := orderStatusName(input); got != want {
t.Fatalf("status %d: got %q want %q", input, got, want)
}
}
}
func TestBuildRefundAuditLog(t *testing.T) {
logic := &RefundOrderLogic{}
now := time.Unix(1710000000, 0)
expireBefore := now.Add(24 * time.Hour)
expireAfter := now.Add(-time.Second)
operator := &modeluser.User{
Id: 100,
AuthMethods: []modeluser.AuthMethods{
{AuthIdentifier: "admin@example.com"},
},
}
orderInfo := &modelorder.Order{Id: 10, OrderNo: "ORD-1", Status: orderStatusRefunded}
userSub := &modeluser.Subscribe{Id: 20, UserId: 30, Status: 3, ExpireTime: expireAfter}
referer := &modeluser.User{Id: 40}
got, err := logic.buildRefundAuditLog(
operator,
orderInfo,
userSub,
referer,
500,
"manual refund",
5,
1,
expireBefore,
900,
400,
now,
)
if err != nil {
t.Fatalf("buildRefundAuditLog error: %v", err)
}
if got.OrderId != 10 || got.OrderNo != "ORD-1" {
t.Fatalf("unexpected order info: %+v", got)
}
if got.OperatorUserId != 100 || got.OperatorAuthIdentifier != "admin@example.com" {
t.Fatalf("unexpected operator info: %+v", got)
}
if got.TargetUserId != 30 || got.UserSubscribeId != 20 {
t.Fatalf("unexpected subscription info: %+v", got)
}
if got.RefererUserId != 40 || got.CommissionAmount != 500 {
t.Fatalf("unexpected commission info: %+v", got)
}
if got.OrderStatusBefore != 5 || got.OrderStatusAfter != orderStatusRefunded {
t.Fatalf("unexpected order status: %+v", got)
}
if got.SubscribeStatusBefore != 1 || got.SubscribeStatusAfter != 3 {
t.Fatalf("unexpected subscribe status: %+v", got)
}
if got.SubscribeExpireBefore != expireBefore.UnixMilli() || got.SubscribeExpireAfter != expireAfter.UnixMilli() {
t.Fatalf("unexpected expire transition: %+v", got)
}
if got.CommissionBefore != 900 || got.CommissionAfter != 400 {
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
}