Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18df7e4d6b | |||
| 750b7be424 | |||
| aa11588c8f | |||
| c3050821d5 | |||
| 5b9f384f81 | |||
| 7236ca4cf2 | |||
| ae126296e3 | |||
| 1e99cfb83c | |||
| 0659a930f8 | |||
| c2d1b5a0d8 |
+2
-2
@@ -15,10 +15,10 @@ Logger: # 日志配置
|
||||
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||
|
||||
MySQL:
|
||||
Addr: 154.12.35.103:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
||||
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
||||
Username: root # MySQL用户名
|
||||
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
||||
Dbname: ppanel # MySQL数据库名
|
||||
Dbname: hifast # MySQL数据库名
|
||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||
MaxIdleConns: 10
|
||||
MaxOpenConns: 100
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakePromoModel struct{}
|
||||
type fakePromoModel struct {
|
||||
insertRule func(context.Context, *promomodel.Rule) error
|
||||
findRule func(context.Context, int64) (*promomodel.Rule, error)
|
||||
updateRule func(context.Context, *promomodel.Rule) error
|
||||
}
|
||||
|
||||
func (fakePromoModel) QueryEligibleRules(context.Context, int64, int64) ([]*promomodel.RuleWithPrice, error) {
|
||||
return nil, nil
|
||||
@@ -22,15 +26,24 @@ func (fakePromoModel) InsertUsage(context.Context, *promomodel.Usage, ...*gorm.D
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakePromoModel) InsertRule(context.Context, *promomodel.Rule) error {
|
||||
func (m fakePromoModel) InsertRule(ctx context.Context, rule *promomodel.Rule) error {
|
||||
if m.insertRule != nil {
|
||||
return m.insertRule(ctx, rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakePromoModel) FindRule(context.Context, int64) (*promomodel.Rule, error) {
|
||||
func (m fakePromoModel) FindRule(ctx context.Context, id int64) (*promomodel.Rule, error) {
|
||||
if m.findRule != nil {
|
||||
return m.findRule(ctx, id)
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func (fakePromoModel) UpdateRule(context.Context, *promomodel.Rule) error {
|
||||
func (m fakePromoModel) UpdateRule(ctx context.Context, rule *promomodel.Rule) error {
|
||||
if m.updateRule != nil {
|
||||
return m.updateRule(ctx, rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package promo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
promomodel "github.com/perfect-panel/server/internal/model/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
pkgerrors "github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestCreateRuleAcceptsMillisecondTimestamps(t *testing.T) {
|
||||
startTime := int64(1777618800000)
|
||||
endTime := int64(1782802800000)
|
||||
var inserted *promomodel.Rule
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
Redis: redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}),
|
||||
PromoModel: fakePromoModel{
|
||||
insertRule: func(_ context.Context, rule *promomodel.Rule) error {
|
||||
inserted = rule
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := NewCreateRuleLogic(context.Background(), svcCtx).CreateRule(&types.CreatePromoRuleRequest{
|
||||
Name: "618活动",
|
||||
Type: promomodel.RuleTypeInactiveUser,
|
||||
Params: map[string]interface{}{"inactive_months": float64(1)},
|
||||
Priority: 0,
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRule returned error: %v", err)
|
||||
}
|
||||
if inserted == nil {
|
||||
t.Fatal("rule was not inserted")
|
||||
}
|
||||
assertPromoRuleTime(t, inserted.StartTime, time.UnixMilli(startTime))
|
||||
assertPromoRuleTime(t, inserted.EndTime, time.UnixMilli(endTime))
|
||||
}
|
||||
|
||||
func TestUpdateRuleAcceptsMillisecondTimestamps(t *testing.T) {
|
||||
startTime := int64(1777618800000)
|
||||
endTime := int64(1782802800000)
|
||||
existing := &promomodel.Rule{Id: 9, Enabled: true}
|
||||
var updated *promomodel.Rule
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
Redis: redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}),
|
||||
PromoModel: fakePromoModel{
|
||||
findRule: func(_ context.Context, id int64) (*promomodel.Rule, error) {
|
||||
if id != existing.Id {
|
||||
t.Fatalf("FindRule id = %d, want %d", id, existing.Id)
|
||||
}
|
||||
return existing, nil
|
||||
},
|
||||
updateRule: func(_ context.Context, rule *promomodel.Rule) error {
|
||||
updated = rule
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := NewUpdateRuleLogic(context.Background(), svcCtx).UpdateRule(&types.UpdatePromoRuleRequest{
|
||||
Id: existing.Id,
|
||||
Name: "618活动",
|
||||
Type: promomodel.RuleTypeInactiveUser,
|
||||
Params: map[string]interface{}{"inactive_months": float64(1)},
|
||||
Priority: 0,
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateRule returned error: %v", err)
|
||||
}
|
||||
if updated == nil {
|
||||
t.Fatal("rule was not updated")
|
||||
}
|
||||
assertPromoRuleTime(t, updated.StartTime, time.UnixMilli(startTime))
|
||||
assertPromoRuleTime(t, updated.EndTime, time.UnixMilli(endTime))
|
||||
}
|
||||
|
||||
func TestRuleRejectsOutOfRangeTimestamp(t *testing.T) {
|
||||
startTime := int64(253402300800000)
|
||||
endTime := int64(253402304400000)
|
||||
svcCtx := &svc.ServiceContext{PromoModel: fakePromoModel{
|
||||
insertRule: func(context.Context, *promomodel.Rule) error {
|
||||
t.Fatal("InsertRule should not be called for invalid timestamp")
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
_, err := NewCreateRuleLogic(context.Background(), svcCtx).CreateRule(&types.CreatePromoRuleRequest{
|
||||
Name: "bad time",
|
||||
Type: promomodel.RuleTypeInactiveUser,
|
||||
Params: map[string]interface{}{"inactive_months": float64(1)},
|
||||
Priority: 0,
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
})
|
||||
assertInvalidParams(t, err)
|
||||
}
|
||||
|
||||
func assertPromoRuleTime(t *testing.T, got *time.Time, want time.Time) {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
t.Fatalf("time is nil, want %v", want)
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("time = %v, want %v", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertInvalidParams(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
codeErr, ok := pkgerrors.Cause(err).(*xerr.CodeError)
|
||||
if !ok {
|
||||
t.Fatalf("expected CodeError, got %T", pkgerrors.Cause(err))
|
||||
}
|
||||
if got := codeErr.GetErrCode(); got != xerr.InvalidParams {
|
||||
t.Fatalf("error code = %d, want %d", got, xerr.InvalidParams)
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,24 @@ const (
|
||||
subscribeCachePref = "promo:subscribe:"
|
||||
)
|
||||
|
||||
var (
|
||||
minPromoRuleTime = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
maxPromoRuleTime = time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
)
|
||||
|
||||
func validateRuleInput(ruleType string, params map[string]interface{}, priority int64, startTime, endTime *int64) error {
|
||||
if priority < 0 {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "priority must be greater than or equal to 0")
|
||||
}
|
||||
if startTime != nil && endTime != nil && *startTime >= *endTime {
|
||||
startAt, err := normalizeRuleTimestamp(startTime)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid start_time")
|
||||
}
|
||||
endAt, err := normalizeRuleTimestamp(endTime)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid end_time")
|
||||
}
|
||||
if startAt != nil && endAt != nil && !startAt.Before(*endAt) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "start_time must be less than end_time")
|
||||
}
|
||||
switch ruleType {
|
||||
@@ -93,12 +106,29 @@ func parseParams(data string) map[string]interface{} {
|
||||
return params
|
||||
}
|
||||
|
||||
func unixPtrToTimePtr(ts *int64) *time.Time {
|
||||
func normalizeRuleTimestamp(ts *int64) (*time.Time, error) {
|
||||
if ts == nil || *ts == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := *ts
|
||||
var t time.Time
|
||||
if value >= 1_000_000_000_000 || value <= -1_000_000_000_000 {
|
||||
t = time.UnixMilli(value)
|
||||
} else {
|
||||
t = time.Unix(value, 0)
|
||||
}
|
||||
if t.Before(minPromoRuleTime) || t.After(maxPromoRuleTime) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "timestamp out of range")
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func unixPtrToTimePtr(ts *int64) *time.Time {
|
||||
t, err := normalizeRuleTimestamp(ts)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(*ts, 0)
|
||||
return &t
|
||||
return t
|
||||
}
|
||||
|
||||
func timePtrToUnixPtr(t *time.Time) *int64 {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HasPaidSubscription reports whether the user owns at least one paid
|
||||
// subscription record — order-backed (order_id > 0) or Apple-IAP-backed
|
||||
// (token LIKE 'iap:%'). Returns false when userID or db are not usable so
|
||||
// callers can fall back to the "first purchase" branch safely.
|
||||
//
|
||||
// This mirrors the predicate used to route /v1/public/order/purchase requests
|
||||
// to renewal semantics. Keep both in sync.
|
||||
func HasPaidSubscription(ctx context.Context, db *gorm.DB, userID int64) (bool, error) {
|
||||
if userID <= 0 || db == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.WithContext(ctx).
|
||||
Model(&user.Subscribe{}).
|
||||
Where("user_id = ? AND (order_id > 0 OR token LIKE 'iap:%')", userID).
|
||||
Limit(1).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query paid subscription failed: %v", err.Error())
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
@@ -28,7 +28,16 @@ type promoRuleParams struct {
|
||||
InactiveMonths int `json:"inactive_months"`
|
||||
}
|
||||
|
||||
func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64) (*PromoResult, error) {
|
||||
// EvaluatePromo decides whether the given (user, subscribe, quantity) tuple
|
||||
// qualifies for any active promo rule. Each rule type has its own gating:
|
||||
// - new_user: requires isFirstPurchase=true (no prior paid subscription)
|
||||
// - inactive_user: requires a previously expired subscription (rule self-check)
|
||||
// - campaign: applies unconditionally within the configured time window
|
||||
//
|
||||
// Pass isFirstPurchase=true on order paths where the request is being routed
|
||||
// as a brand-new purchase; pass false when the user already has a paid
|
||||
// subscription (including expired ones) so NewUser cannot be reused.
|
||||
func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64, isFirstPurchase bool) (*PromoResult, error) {
|
||||
result := &PromoResult{}
|
||||
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || subscribeID <= 0 || quantity <= 0 {
|
||||
return result, nil
|
||||
@@ -59,7 +68,7 @@ func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64
|
||||
}
|
||||
}
|
||||
|
||||
eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, ¤tUser, now)
|
||||
eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, isFirstPurchase, ¤tUser, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -96,12 +105,13 @@ func evaluatePromoRule(
|
||||
rule *promo.RuleWithPrice,
|
||||
params promoRuleParams,
|
||||
userID int64,
|
||||
isFirstPurchase bool,
|
||||
currentUser *user.User,
|
||||
now time.Time,
|
||||
) (bool, time.Time, error) {
|
||||
switch rule.Type {
|
||||
case promo.RuleTypeNewUser:
|
||||
if userID <= 0 {
|
||||
if userID <= 0 || !isFirstPurchase {
|
||||
return false, time.Time{}, nil
|
||||
}
|
||||
return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now)
|
||||
@@ -165,7 +175,7 @@ func evaluateInactiveUserPromo(
|
||||
Take(&lastSub).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return true, ruleExpiresAt, nil
|
||||
return false, time.Time{}, nil
|
||||
}
|
||||
return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed")
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/mysql"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"gorm.io/gorm"
|
||||
@@ -65,7 +69,7 @@ func TestEvaluatePromoAllowsAnonymousCampaign(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
|
||||
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 0, 7, 12)
|
||||
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 0, 7, 12, true)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluatePromo returned error: %v", err)
|
||||
}
|
||||
@@ -86,6 +90,90 @@ func TestEvaluatePromoAllowsAnonymousCampaign(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePromoCampaignAppliesToReturningUsers(t *testing.T) {
|
||||
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||
{
|
||||
Rule: promo.Rule{
|
||||
Id: 4,
|
||||
Name: "campaign",
|
||||
Type: promo.RuleTypeCampaign,
|
||||
Enabled: true,
|
||||
},
|
||||
PromoPrice: 299,
|
||||
},
|
||||
}}
|
||||
|
||||
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 42, 7, 1, false)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluatePromo returned error: %v", err)
|
||||
}
|
||||
if !got.Eligible {
|
||||
t.Fatal("campaign promo should remain eligible for returning users (isFirstPurchase=false)")
|
||||
}
|
||||
if got.PromoPrice != 299 {
|
||||
t.Fatalf("PromoPrice = %d, want 299", got.PromoPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePromoNewUserRequiresFirstPurchase(t *testing.T) {
|
||||
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||
{
|
||||
Rule: promo.Rule{
|
||||
Id: 5,
|
||||
Name: "new user",
|
||||
Type: promo.RuleTypeNewUser,
|
||||
Enabled: true,
|
||||
Params: `{"window_hours": 72}`,
|
||||
},
|
||||
PromoPrice: 99,
|
||||
},
|
||||
}}
|
||||
|
||||
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 42, 7, 1, false)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluatePromo returned error: %v", err)
|
||||
}
|
||||
if got.Eligible {
|
||||
t.Fatal("new-user promo must be gated out when isFirstPurchase=false (user already has paid subscriptions)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePromoRejectsInactiveRuleWhenUserHasNoSubscription(t *testing.T) {
|
||||
db, mock, cleanup := newCommonPromoTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||
{
|
||||
Rule: promo.Rule{
|
||||
Id: 9,
|
||||
Name: "inactive",
|
||||
Type: promo.RuleTypeInactiveUser,
|
||||
Params: `{"inactive_months":3}`,
|
||||
Enabled: true,
|
||||
},
|
||||
PromoPrice: 100,
|
||||
},
|
||||
}}
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_subscribe` WHERE user_id = ? ORDER BY CASE WHEN expire_time = ? THEN 0 ELSE 1 END, expire_time DESC LIMIT ?")).
|
||||
WithArgs(int64(51640), time.UnixMilli(0), 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
|
||||
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: db, PromoModel: model}, 51640, 1, 30, true)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluatePromo returned error: %v", err)
|
||||
}
|
||||
if got.Eligible {
|
||||
t.Fatal("new user without subscription history should not be eligible for inactive promo")
|
||||
}
|
||||
if got.RuleID != 0 || got.PromoPrice != 0 {
|
||||
t.Fatalf("promo fields = (%d, %d), want zero values", got.RuleID, got.PromoPrice)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet db expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePromoModel struct {
|
||||
rules []*promo.RuleWithPrice
|
||||
lastSubscribeID int64
|
||||
@@ -145,3 +233,22 @@ func (m *fakePromoModel) QueryUsageList(context.Context, promo.UsageFilter) (int
|
||||
func (m *fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newCommonPromoTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/internal/model/payment"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
@@ -22,6 +23,47 @@ import (
|
||||
queueType "github.com/perfect-panel/server/queue/types"
|
||||
)
|
||||
|
||||
// epayNotifyTradeNotSuccess identifies the "buyer paid but trade not in TRADE_SUCCESS
|
||||
// terminal state" branch in metrics / log search. EPay is told 200 here so it will
|
||||
// not retry; we still want it visible for monitoring (HIF-135 / HIF-136).
|
||||
const epayNotifyTradeNotSuccess = "epay_notify_trade_not_success"
|
||||
|
||||
// epayNotifyDecision encodes the post-parse decision so the branching logic can be
|
||||
// unit-tested without spinning up a real OrderModel / Redis / asynq stack.
|
||||
type epayNotifyDecision int
|
||||
|
||||
const (
|
||||
// epayNotifyDecisionRejectSign: signature is invalid and debug bypass is off.
|
||||
// Caller MUST return an error so the handler responds non-200 and EPay retries.
|
||||
epayNotifyDecisionRejectSign epayNotifyDecision = iota
|
||||
// epayNotifyDecisionAckTradeNotSuccess: trade_status != TRADE_SUCCESS
|
||||
// (user cancelled / failed at gateway). Acknowledge with 200 "success" and emit
|
||||
// a metric-named log for monitoring.
|
||||
epayNotifyDecisionAckTradeNotSuccess
|
||||
// epayNotifyDecisionAckIdempotent: order already at Finished (status=5).
|
||||
// Repeat callback — ack with 200 "success", do not re-enqueue activation.
|
||||
epayNotifyDecisionAckIdempotent
|
||||
// epayNotifyDecisionProcess: happy path. Write trade_no, flip status to Paid,
|
||||
// enqueue activation task.
|
||||
epayNotifyDecisionProcess
|
||||
)
|
||||
|
||||
// evaluateEPayNotify is a pure decision function so each branch can be unit-tested
|
||||
// without DB/Redis. Caller has already located the order; orderStatus is the
|
||||
// current status from the DB row.
|
||||
func evaluateEPayNotify(signValid, debugBypass bool, tradeStatus string, orderStatus uint8) epayNotifyDecision {
|
||||
if !signValid && !debugBypass {
|
||||
return epayNotifyDecisionRejectSign
|
||||
}
|
||||
if tradeStatus != "TRADE_SUCCESS" {
|
||||
return epayNotifyDecisionAckTradeNotSuccess
|
||||
}
|
||||
if orderStatus == 5 {
|
||||
return epayNotifyDecisionAckIdempotent
|
||||
}
|
||||
return epayNotifyDecisionProcess
|
||||
}
|
||||
|
||||
type EPayNotifyLogic struct {
|
||||
logger.Logger
|
||||
ctx *gin.Context
|
||||
@@ -47,6 +89,8 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
|
||||
}
|
||||
orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OutTradeNo)
|
||||
if err != nil {
|
||||
// HIF-136 P02: order missing must propagate as error so EPay retries instead
|
||||
// of being silently lost (was previously masked by a `return nil` further down).
|
||||
l.Logger.Error("[EPayNotify] Find order failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", req.OutTradeNo)
|
||||
}
|
||||
@@ -65,17 +109,54 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
|
||||
}
|
||||
// Verify sign
|
||||
client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type)
|
||||
if !client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) && !l.svcCtx.Config.Debug {
|
||||
l.Logger.Error("[EPayNotify] Verify sign failed")
|
||||
signValid := client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery))
|
||||
|
||||
decision := evaluateEPayNotify(signValid, l.svcCtx.Config.Debug, req.TradeStatus, orderInfo.Status)
|
||||
switch decision {
|
||||
case epayNotifyDecisionRejectSign:
|
||||
// HIF-136 P01: previously `return nil` here let EPay see 200 and stop retrying;
|
||||
// caller still left order at status=1, which DeferCloseOrder then closed.
|
||||
// Return error so handler responds non-200 and EPay retries.
|
||||
l.Logger.Error("[EPayNotify] Verify sign failed",
|
||||
logger.Field("order_no", req.OutTradeNo),
|
||||
logger.Field("trade_no", req.TradeNo),
|
||||
logger.Field("raw_query", l.ctx.Request.URL.RawQuery),
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.SignatureInvalid), "verify sign failed: %v", req.OutTradeNo)
|
||||
case epayNotifyDecisionAckTradeNotSuccess:
|
||||
// User cancelled / gateway-side failure: ack 200 to stop retries, but keep
|
||||
// the path observable under metric name `epay_notify_trade_not_success`.
|
||||
l.Logger.Info("[EPayNotify] Trade status not success",
|
||||
logger.Field("order_no", req.OutTradeNo),
|
||||
logger.Field("trade_status", req.TradeStatus),
|
||||
logger.Field("metric", epayNotifyTradeNotSuccess),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if req.TradeStatus != "TRADE_SUCCESS" {
|
||||
l.Logger.Error("[EPayNotify] Trade status is not success", logger.Field("orderNo", req.OutTradeNo), logger.Field("tradeStatus", req.TradeStatus))
|
||||
case epayNotifyDecisionAckIdempotent:
|
||||
// Order already Finished — repeat callback is expected, ack with 200.
|
||||
return nil
|
||||
case epayNotifyDecisionProcess:
|
||||
// fall through
|
||||
}
|
||||
if orderInfo.Status == 5 {
|
||||
return nil
|
||||
|
||||
// HIF-136 P03: persist gateway trade_no BEFORE flipping status so post-mortems can
|
||||
// reverse-lookup EPay flows to ppanel orders. Raw gorm write is acceptable here —
|
||||
// the subsequent UpdateOrderStatus will invalidate the cache key (keys are by
|
||||
// order_no / id, both stable across this field write).
|
||||
if req.TradeNo != "" {
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&order.Order{}).
|
||||
Where("order_no = ?", req.OutTradeNo).
|
||||
Update("trade_no", req.TradeNo).Error; err != nil {
|
||||
l.Logger.Error("[EPayNotify] Update trade_no failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("order_no", req.OutTradeNo),
|
||||
logger.Field("trade_no", req.TradeNo),
|
||||
)
|
||||
return errors.Wrapf(err, "update trade_no failed: %v", req.OutTradeNo)
|
||||
}
|
||||
}
|
||||
|
||||
// Update order status
|
||||
err = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OutTradeNo, 2)
|
||||
if err != nil {
|
||||
@@ -86,6 +167,7 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
|
||||
"[SubscriptionFlow] epay notify marked order as paid",
|
||||
append(commonLogic.OrderTraceFields(orderInfo),
|
||||
logger.Field("payment_platform", data.Platform),
|
||||
logger.Field("trade_no", req.TradeNo),
|
||||
)...,
|
||||
)
|
||||
// Create activate order task
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestEvaluateEPayNotify_RejectsInvalidSign covers HIF-136 P01: sign invalid +
|
||||
// debug bypass off must return rejectSign so the handler responds non-200 and
|
||||
// EPay retries (was previously a silent `return nil` → 200 → no retry).
|
||||
func TestEvaluateEPayNotify_RejectsInvalidSign(t *testing.T) {
|
||||
got := evaluateEPayNotify(false, false, "TRADE_SUCCESS", 1)
|
||||
if got != epayNotifyDecisionRejectSign {
|
||||
t.Fatalf("evaluateEPayNotify(invalid sign): got %v want %v", got, epayNotifyDecisionRejectSign)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateEPayNotify_DebugBypassAllowsInvalidSign documents the debug-mode
|
||||
// escape hatch the issue calls out: production runs with Debug=false so this
|
||||
// branch is never taken in prod, but local/dev should still flow through.
|
||||
func TestEvaluateEPayNotify_DebugBypassAllowsInvalidSign(t *testing.T) {
|
||||
got := evaluateEPayNotify(false, true, "TRADE_SUCCESS", 1)
|
||||
if got != epayNotifyDecisionProcess {
|
||||
t.Fatalf("evaluateEPayNotify(invalid sign + debug): got %v want %v", got, epayNotifyDecisionProcess)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateEPayNotify_TradeNotSuccess covers the user-cancelled / gateway-failure
|
||||
// branch: behaviour unchanged (return nil → 200), but caller must now emit the
|
||||
// `epay_notify_trade_not_success` metric-named log instead of an Error-level one.
|
||||
func TestEvaluateEPayNotify_TradeNotSuccess(t *testing.T) {
|
||||
tests := []string{"TRADE_FAILED", "WAIT_BUYER_PAY", ""}
|
||||
for _, ts := range tests {
|
||||
t.Run(ts, func(t *testing.T) {
|
||||
got := evaluateEPayNotify(true, false, ts, 1)
|
||||
if got != epayNotifyDecisionAckTradeNotSuccess {
|
||||
t.Fatalf("evaluateEPayNotify(trade_status=%q): got %v want %v",
|
||||
ts, got, epayNotifyDecisionAckTradeNotSuccess)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateEPayNotify_IdempotentForFinishedOrder covers the only legitimate
|
||||
// `return nil` short-circuit retained by HIF-136: duplicate callback for an
|
||||
// already Finished (status=5) order is acknowledged with 200 instead of being
|
||||
// re-processed.
|
||||
func TestEvaluateEPayNotify_IdempotentForFinishedOrder(t *testing.T) {
|
||||
got := evaluateEPayNotify(true, false, "TRADE_SUCCESS", 5)
|
||||
if got != epayNotifyDecisionAckIdempotent {
|
||||
t.Fatalf("evaluateEPayNotify(status=5): got %v want %v", got, epayNotifyDecisionAckIdempotent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateEPayNotify_HappyPath covers the normal success branch: valid sign,
|
||||
// trade_status=TRADE_SUCCESS, order not yet Finished → proceed to write trade_no
|
||||
// + flip status + enqueue activation.
|
||||
func TestEvaluateEPayNotify_HappyPath(t *testing.T) {
|
||||
statuses := []uint8{1, 2, 3, 4} // anything but Finished(5)
|
||||
for _, s := range statuses {
|
||||
got := evaluateEPayNotify(true, false, "TRADE_SUCCESS", s)
|
||||
if got != epayNotifyDecisionProcess {
|
||||
t.Fatalf("evaluateEPayNotify(status=%d): got %v want %v", s, got, epayNotifyDecisionProcess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateEPayNotify_SignTakesPrecedenceOverIdempotency guards against a
|
||||
// regression where a duplicate callback with a forged signature gets quietly
|
||||
// acked because status==5 was checked before sign.
|
||||
func TestEvaluateEPayNotify_SignTakesPrecedenceOverIdempotency(t *testing.T) {
|
||||
got := evaluateEPayNotify(false, false, "TRADE_SUCCESS", 5)
|
||||
if got != epayNotifyDecisionRejectSign {
|
||||
t.Fatalf("evaluateEPayNotify(invalid sign + status=5): got %v want %v",
|
||||
got, epayNotifyDecisionRejectSign)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUrlParamsToMap verifies the helper used to feed VerifySign — collapses
|
||||
// each query parameter to its first value and treats absent params as missing.
|
||||
func TestUrlParamsToMap(t *testing.T) {
|
||||
got := urlParamsToMap("pid=1001&out_trade_no=ORD-1&trade_no=EPAY-9&sign=abc&trade_status=TRADE_SUCCESS")
|
||||
want := map[string]string{
|
||||
"pid": "1001",
|
||||
"out_trade_no": "ORD-1",
|
||||
"trade_no": "EPAY-9",
|
||||
"sign": "abc",
|
||||
"trade_status": "TRADE_SUCCESS",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("urlParamsToMap len = %d want %d (got=%v)", len(got), len(want), got)
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Fatalf("urlParamsToMap[%q] = %q want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEPayNotify_TradeNoRawWrite verifies HIF-136 P03 at the SQL boundary: the
|
||||
// raw gorm write hits the `order` table with the exact `trade_no` from the
|
||||
// EPay request, scoped by `order_no`, and surfaces driver errors back to the
|
||||
// caller (so they propagate as non-200 and EPay retries).
|
||||
func TestEPayNotify_TradeNoRawWrite(t *testing.T) {
|
||||
db, mock, cleanup := newEPayNotifyTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
const (
|
||||
orderNo = "202606010001"
|
||||
tradeNo = "EPAY-202606010001"
|
||||
)
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("UPDATE `order` SET `trade_no`").
|
||||
WithArgs(tradeNo, sqlmock.AnyArg(), orderNo).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
err := db.WithContext(context.Background()).
|
||||
Model(&order.Order{}).
|
||||
Where("order_no = ?", orderNo).
|
||||
Update("trade_no", tradeNo).Error
|
||||
if err != nil {
|
||||
t.Fatalf("raw trade_no update: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEPayNotify_TradeNoRawWritePropagatesError ensures a DB-side failure during
|
||||
// the trade_no backfill is not swallowed — caller returns the error so EPay
|
||||
// retries instead of silently flipping status without trade_no being written.
|
||||
func TestEPayNotify_TradeNoRawWritePropagatesError(t *testing.T) {
|
||||
db, mock, cleanup := newEPayNotifyTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("UPDATE `order` SET `trade_no`").
|
||||
WillReturnError(fmt.Errorf("deadlock detected"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
err := db.WithContext(context.Background()).
|
||||
Model(&order.Order{}).
|
||||
Where("order_no = ?", "ORD-2").
|
||||
Update("trade_no", "EPAY-2").Error
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from raw trade_no update, got nil")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newEPayNotifyTestDB(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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func paidSubscriptionQuery(ctx context.Context, db *gorm.DB, userID int64) *gorm.DB {
|
||||
return db.WithContext(ctx).
|
||||
Model(&user.Subscribe{}).
|
||||
Where("user_id = ? AND (order_id > 0 OR token LIKE 'iap:%')", userID).
|
||||
Order("expire_time DESC").
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestPaidSubscriptionQueryIncludesOrderBackedSubscriptionWithoutToken(t *testing.T) {
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{
|
||||
DSN: "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local",
|
||||
SkipInitializeWithVersion: true,
|
||||
}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
|
||||
if err != nil {
|
||||
t.Fatalf("open dry-run db: %v", err)
|
||||
}
|
||||
|
||||
var sub user.Subscribe
|
||||
tx := paidSubscriptionQuery(context.Background(), db, 510).First(&sub)
|
||||
sql := tx.Statement.SQL.String()
|
||||
|
||||
if strings.Contains(sql, "token != ''") {
|
||||
t.Fatalf("paid subscription query should not require non-empty token: %s", sql)
|
||||
}
|
||||
if !strings.Contains(sql, "order_id > 0 OR token LIKE 'iap:%'") {
|
||||
t.Fatalf("paid subscription query should include order-backed or iap-backed subscriptions: %s", sql)
|
||||
}
|
||||
}
|
||||
@@ -88,13 +88,8 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
||||
// routes the request to renewal semantics, where first-purchase promos are disabled.
|
||||
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
||||
var existSub user.Subscribe
|
||||
if e := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&user.Subscribe{}).
|
||||
Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%')", entitlement.EffectiveUserID).
|
||||
Order("expire_time DESC").
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
First(&existSub).Error; e == nil && existSub.Id > 0 && existSub.Token != "" {
|
||||
if e := paidSubscriptionQuery(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID).
|
||||
First(&existSub).Error; e == nil && existSub.Id > 0 {
|
||||
orderType = 2
|
||||
l.Infow("[PreCreateOrder] purchase preview routed to renewal because an existing subscription was found",
|
||||
logger.Field("route_mode", "global_single_subscription"),
|
||||
|
||||
@@ -27,7 +27,7 @@ func calculatePurchasePrice(
|
||||
quantity int64,
|
||||
discounts []types.SubscribeDiscount,
|
||||
eligibleForDiscount bool,
|
||||
allowPromo bool,
|
||||
isFirstPurchase bool,
|
||||
) (*orderPriceResult, error) {
|
||||
originalPrice := unitPrice * quantity
|
||||
result := &orderPriceResult{
|
||||
@@ -35,8 +35,7 @@ func calculatePurchasePrice(
|
||||
PayableBase: originalPrice,
|
||||
}
|
||||
|
||||
if allowPromo {
|
||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity)
|
||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity, isFirstPurchase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -50,7 +49,6 @@ func calculatePurchasePrice(
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
discount := float64(1)
|
||||
if len(discounts) > 0 {
|
||||
|
||||
@@ -221,3 +221,79 @@ func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) {
|
||||
t.Fatalf("PromoRuleId = %d, want 11", result.PromoRuleId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePurchasePriceCampaignAppliesToReturningUsers(t *testing.T) {
|
||||
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||
{
|
||||
Rule: promo.Rule{
|
||||
Id: 12,
|
||||
Name: "campaign",
|
||||
Type: promo.RuleTypeCampaign,
|
||||
Enabled: true,
|
||||
},
|
||||
PromoPrice: 400,
|
||||
},
|
||||
}}
|
||||
svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}
|
||||
|
||||
// isFirstPurchase=false simulates a returning user routed to renewal; the
|
||||
// Campaign promo must still apply because it has no first-purchase gate.
|
||||
result, err := calculatePurchasePrice(
|
||||
context.Background(),
|
||||
svcCtx,
|
||||
42,
|
||||
2,
|
||||
1000,
|
||||
1,
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calculatePurchasePrice returned error: %v", err)
|
||||
}
|
||||
if result.PromoRuleId != 12 {
|
||||
t.Fatalf("PromoRuleId = %d, want 12 (campaign should apply to returning users)", result.PromoRuleId)
|
||||
}
|
||||
if result.PayableBase != 400 {
|
||||
t.Fatalf("PayableBase = %d, want 400", result.PayableBase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePurchasePriceNewUserGatedByFirstPurchase(t *testing.T) {
|
||||
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||
{
|
||||
Rule: promo.Rule{
|
||||
Id: 13,
|
||||
Name: "new user",
|
||||
Type: promo.RuleTypeNewUser,
|
||||
Enabled: true,
|
||||
Params: `{"window_hours": 72}`,
|
||||
},
|
||||
PromoPrice: 200,
|
||||
},
|
||||
}}
|
||||
svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}
|
||||
|
||||
// isFirstPurchase=false → NewUser rule must be skipped, regular discount applies.
|
||||
result, err := calculatePurchasePrice(
|
||||
context.Background(),
|
||||
svcCtx,
|
||||
42,
|
||||
2,
|
||||
1000,
|
||||
1,
|
||||
[]types.SubscribeDiscount{{Quantity: 1, Discount: 90}},
|
||||
true,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calculatePurchasePrice returned error: %v", err)
|
||||
}
|
||||
if result.PromoRuleId != 0 || result.PromoDiscount != 0 {
|
||||
t.Fatalf("promo fields = (%d, %d), want (0, 0) when isFirstPurchase=false", result.PromoRuleId, result.PromoDiscount)
|
||||
}
|
||||
if result.PayableBase != 900 {
|
||||
t.Fatalf("PayableBase = %d, want 900 (regular 90%% discount)", result.PayableBase)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,13 +129,8 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
// 防止不同套餐购买创建第二条订阅。
|
||||
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
||||
var existSub user.Subscribe
|
||||
if e := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&user.Subscribe{}).
|
||||
Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%')", entitlement.EffectiveUserID).
|
||||
Order("expire_time DESC").
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
First(&existSub).Error; e == nil && existSub.Id > 0 && existSub.Token != "" {
|
||||
if e := paidSubscriptionQuery(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID).
|
||||
First(&existSub).Error; e == nil && existSub.Id > 0 {
|
||||
orderType = 2
|
||||
parentOrderID = existSub.OrderId
|
||||
subscribeToken = existSub.Token
|
||||
|
||||
@@ -41,6 +41,22 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
||||
}
|
||||
|
||||
userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
userID := int64(0)
|
||||
isFirstPurchase := true
|
||||
if userInfo != nil {
|
||||
entitlement, err := commonLogic.ResolveEntitlementUser(ctx, svcCtx.DB, userInfo.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userID = entitlement.EffectiveUserID
|
||||
|
||||
hasPaid, err := commonLogic.HasPaidSubscription(ctx, svcCtx.DB, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isFirstPurchase = !hasPaid
|
||||
}
|
||||
|
||||
candidates, err := querySubscribePromoCandidates(ctx, svcCtx, subscribeIDs, userInfo != nil)
|
||||
if err != nil {
|
||||
if isMissingPromoTableError(err) {
|
||||
@@ -49,10 +65,6 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userID := int64(0)
|
||||
if userInfo != nil {
|
||||
userID = userInfo.Id
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Quantity <= 0 {
|
||||
continue
|
||||
@@ -63,7 +75,7 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
||||
if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
|
||||
continue
|
||||
}
|
||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, candidate.SubscribeId, candidate.Quantity)
|
||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, candidate.SubscribeId, candidate.Quantity, isFirstPurchase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/promo"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -88,6 +90,66 @@ func TestLoadSubscribePromoMapUsesCommonPromoEvaluation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSubscribePromoMapUsesFamilyOwnerForInactivePromo(t *testing.T) {
|
||||
db, mock, cleanup := newSubscribePromoTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
memberUserID := int64(51637)
|
||||
ownerUserID := int64(510)
|
||||
subscribeID := int64(11)
|
||||
quantity := int64(30)
|
||||
now := time.Now()
|
||||
end := now.Add(24 * time.Hour)
|
||||
|
||||
mock.ExpectQuery("FROM `user_family_member`").
|
||||
WithArgs(memberUserID, user.FamilyMemberActive, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"role", "family_status", "owner_user_id"}).
|
||||
AddRow(user.FamilyRoleMember, user.FamilyStatusActive, ownerUserID))
|
||||
mock.ExpectQuery("SELECT count(*) FROM `user_subscribe`").
|
||||
WithArgs(ownerUserID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectQuery("FROM subscribe_promo AS sp").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"subscribe_id", "quantity", "rule_name", "rule_type", "promo_price", "params", "start_time", "end_time",
|
||||
}).AddRow(subscribeID, quantity, "回归用户01", promoRuleTypeInactiveUser, 100, `{"inactive_months":1}`, nil, end))
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(ownerUserID, time.UnixMilli(0), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscribe_id", "expire_time"}).
|
||||
AddRow(131, ownerUserID, 1, now.AddDate(0, 1, 0)))
|
||||
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &user.User{Id: memberUserID})
|
||||
promoModel := &fakeSubscribePromoModel{rules: []*promo.RuleWithPrice{
|
||||
{
|
||||
Rule: promo.Rule{
|
||||
Id: 8,
|
||||
Name: "回归用户01",
|
||||
Type: promo.RuleTypeInactiveUser,
|
||||
Enabled: true,
|
||||
Params: `{"inactive_months":1}`,
|
||||
EndTime: &end,
|
||||
},
|
||||
PromoPrice: 100,
|
||||
},
|
||||
}}
|
||||
|
||||
got, err := loadSubscribePromoMap(ctx, &svc.ServiceContext{DB: db, PromoModel: promoModel}, []int64{subscribeID})
|
||||
if err != nil {
|
||||
t.Fatalf("loadSubscribePromoMap returned error: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
if promoModel.lastSubscribeID != subscribeID {
|
||||
t.Fatalf("promo subscribe id = %d, want %d", promoModel.lastSubscribeID, subscribeID)
|
||||
}
|
||||
if promoModel.lastQuantity != quantity {
|
||||
t.Fatalf("promo quantity = %d, want %d", promoModel.lastQuantity, quantity)
|
||||
}
|
||||
if got[subscribeID][quantity] != nil {
|
||||
t.Fatalf("family member should not receive inactive promo when owner has active subscription, got %+v", got[subscribeID][quantity])
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSubscribePromoModel struct {
|
||||
rules []*promo.RuleWithPrice
|
||||
lastSubscribeID int64
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type CommissionWithdrawLogic struct {
|
||||
@@ -29,7 +30,7 @@ func NewCommissionWithdrawLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
||||
}
|
||||
|
||||
func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdrawRequest) (resp *types.WithdrawalLog, err error) {
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
ctxUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
logger.Error("current user is not found in context")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
@@ -51,28 +52,38 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
||||
}
|
||||
}
|
||||
|
||||
// Sum all pending (status=0) withdrawals to compute available balance.
|
||||
// Available = commission - pendingTotal; commission is only deducted on approval.
|
||||
var pendingTotal int64
|
||||
if err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&user.Withdrawal{}).
|
||||
Where("user_id = ? AND status = ?", u.Id, user.WithdrawalStatusPending).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&pendingTotal).Error; err != nil {
|
||||
l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to query pending withdrawals for user %d", u.Id)
|
||||
}
|
||||
|
||||
if u.Commission < req.Amount+pendingTotal {
|
||||
logger.Errorf("User %d insufficient available commission: total=%d pending=%d requested=%d",
|
||||
u.Id, u.Commission, pendingTotal, req.Amount)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", u.Id)
|
||||
}
|
||||
|
||||
// HIF-139: read commission and pending total directly from DB inside a
|
||||
// transaction, FOR UPDATE on the user row. The ctxUser snapshot may be
|
||||
// served from cache and can be stale (the original bug allowed a user
|
||||
// with cached commission=996900 to submit a withdrawal while DB said 0,
|
||||
// which then failed admin approval with 20010). Approve already locks
|
||||
// the user row this way; aligning submission closes the gap.
|
||||
var w user.Withdrawal
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var dbUser user.User
|
||||
if txErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", ctxUser.Id).First(&dbUser).Error; txErr != nil {
|
||||
l.Errorf("Failed to lock user %d for withdrawal: %v", ctxUser.Id, txErr)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to lock user %d: %v", ctxUser.Id, txErr)
|
||||
}
|
||||
|
||||
var pendingTotal int64
|
||||
if txErr := tx.Model(&user.Withdrawal{}).
|
||||
Where("user_id = ? AND status = ?", ctxUser.Id, user.WithdrawalStatusPending).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&pendingTotal).Error; txErr != nil {
|
||||
l.Errorf("Failed to query pending withdrawals for user %d: %v", ctxUser.Id, txErr)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to query pending withdrawals for user %d", ctxUser.Id)
|
||||
}
|
||||
|
||||
if dbUser.Commission < req.Amount+pendingTotal {
|
||||
logger.Errorf("User %d insufficient available commission: db_commission=%d pending=%d requested=%d",
|
||||
ctxUser.Id, dbUser.Commission, pendingTotal, req.Amount)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", ctxUser.Id)
|
||||
}
|
||||
|
||||
w = user.Withdrawal{
|
||||
UserId: u.Id,
|
||||
UserId: ctxUser.Id,
|
||||
Amount: req.Amount,
|
||||
Content: req.Content,
|
||||
Status: user.WithdrawalStatusPending,
|
||||
@@ -81,16 +92,19 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
||||
Account: req.Account,
|
||||
QrCodeUrl: req.QrCodeUrl,
|
||||
}
|
||||
return tx.Create(&w).Error
|
||||
if txErr := tx.Create(&w).Error; txErr != nil {
|
||||
l.Errorf("Failed to create withdrawal for user %d: %v", ctxUser.Id, txErr)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", ctxUser.Id, txErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorf("Failed to create withdrawal for user %d: %v", u.Id, err)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", u.Id, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.WithdrawalLog{
|
||||
Id: w.Id,
|
||||
UserId: u.Id,
|
||||
UserId: ctxUser.Id,
|
||||
Amount: req.Amount,
|
||||
Content: req.Content,
|
||||
Status: user.WithdrawalStatusPending,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
modeluser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestCommissionWithdraw_StaleCacheRejected 覆盖 HIF-139 修复:
|
||||
// ctxUser(来自 auth middleware 的 cache-aside FindOne)即使 Commission=996900,
|
||||
// 只要事务内 FOR UPDATE 读出的 DB 真值 Commission < 申请金额 + pendingTotal,
|
||||
// 申请就必须被拒(UserCommissionNotEnough),且不得 INSERT 任何 withdrawal。
|
||||
func TestCommissionWithdraw_StaleCacheRejected(t *testing.T) {
|
||||
const userID = int64(510)
|
||||
|
||||
db, mock, cleanup := newWithdrawTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
// FOR UPDATE 锁 user 行 → DB 真值 commission=0
|
||||
mock.ExpectQuery("FROM `user`").
|
||||
WithArgs(userID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(0)))
|
||||
// pendingTotal=0
|
||||
mock.ExpectQuery("FROM `withdrawals`").
|
||||
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(0)))
|
||||
// 校验失败 → ROLLBACK,不得 INSERT
|
||||
mock.ExpectRollback()
|
||||
|
||||
// ctxUser 故意带一个虚高 commission,模拟陈旧 cache
|
||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 996900})
|
||||
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
||||
Amount: 3000,
|
||||
Method: modeluser.WithdrawalMethodBank,
|
||||
Account: "222",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("CommissionWithdraw expected error, got nil")
|
||||
}
|
||||
if !isWithdrawErrCode(err, xerr.UserCommissionNotEnough) {
|
||||
t.Fatalf("CommissionWithdraw error code = %v, want UserCommissionNotEnough; raw=%v", withdrawErrCodeOf(err), err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommissionWithdraw_HappyPath 覆盖申请成功路径:DB 真值充足 → INSERT withdrawal。
|
||||
func TestCommissionWithdraw_HappyPath(t *testing.T) {
|
||||
const userID = int64(72)
|
||||
|
||||
db, mock, cleanup := newWithdrawTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `user`").
|
||||
WithArgs(userID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(10000)))
|
||||
mock.ExpectQuery("FROM `withdrawals`").
|
||||
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(2000)))
|
||||
// 10000 >= 3000 + 2000 → INSERT
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `withdrawals`")).
|
||||
WillReturnResult(sqlmock.NewResult(99, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 10000})
|
||||
resp, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
||||
Amount: 3000,
|
||||
Method: modeluser.WithdrawalMethodBank,
|
||||
Account: "acc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CommissionWithdraw unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil || resp.Amount != 3000 || resp.Status != modeluser.WithdrawalStatusPending {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommissionWithdraw_PendingTotalExhausts 覆盖 pendingTotal 把可用额度吃光的场景:
|
||||
// DB commission=5000、pending=4000、申请=2000 → 5000 < 6000 → 20010,不得 INSERT。
|
||||
func TestCommissionWithdraw_PendingTotalExhausts(t *testing.T) {
|
||||
const userID = int64(88)
|
||||
|
||||
db, mock, cleanup := newWithdrawTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `user`").
|
||||
WithArgs(userID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(5000)))
|
||||
mock.ExpectQuery("FROM `withdrawals`").
|
||||
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(4000)))
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 5000})
|
||||
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
||||
Amount: 2000,
|
||||
Method: modeluser.WithdrawalMethodBank,
|
||||
Account: "acc",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("CommissionWithdraw expected error, got nil")
|
||||
}
|
||||
if !isWithdrawErrCode(err, xerr.UserCommissionNotEnough) {
|
||||
t.Fatalf("error code = %v, want UserCommissionNotEnough; raw=%v", withdrawErrCodeOf(err), err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommissionWithdraw_LockUserMissing 覆盖事务内 FOR UPDATE 找不到 user 的场景(user 已删/不存在)→ DatabaseQueryError。
|
||||
func TestCommissionWithdraw_LockUserMissing(t *testing.T) {
|
||||
const userID = int64(999999)
|
||||
|
||||
db, mock, cleanup := newWithdrawTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `user`").
|
||||
WithArgs(userID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}))
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 999})
|
||||
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
||||
Amount: 100,
|
||||
Method: modeluser.WithdrawalMethodBank,
|
||||
Account: "acc",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("CommissionWithdraw expected error, got nil")
|
||||
}
|
||||
if !isWithdrawErrCode(err, xerr.DatabaseQueryError) {
|
||||
t.Fatalf("error code = %v, want DatabaseQueryError; raw=%v", withdrawErrCodeOf(err), err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newWithdrawTestDB(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 newTestCommissionWithdrawLogic(t *testing.T, db *gorm.DB, ctxUser *modeluser.User) *CommissionWithdrawLogic {
|
||||
t.Helper()
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, ctxUser)
|
||||
return &CommissionWithdrawLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawErrCodeOf(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 isWithdrawErrCode(err error, code uint32) bool {
|
||||
return withdrawErrCodeOf(err) == code
|
||||
}
|
||||
@@ -113,12 +113,17 @@ func (l *GetInviteRecordsLogic) GetInviteRecords(req *types.GetInviteRecordsRequ
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query orders failed: %v", err.Error())
|
||||
}
|
||||
|
||||
visibleUserIdSet := make(map[int64]struct{}, len(visibleUserIds))
|
||||
for _, userId := range visibleUserIds {
|
||||
visibleUserIdSet[userId] = struct{}{}
|
||||
}
|
||||
|
||||
allRecords := make([]types.InviteRecord, 0, len(parsedLogs))
|
||||
for _, parsed := range parsedLogs {
|
||||
content := parsed.content
|
||||
logItem := parsed.log
|
||||
orderInfo, hasOrder := orders[content.OrderNo]
|
||||
if !l.canViewInviteRecord(u.Id, logItem.ObjectId, hasOrder, orderInfo) {
|
||||
if !l.canViewInviteRecord(u.Id, logItem.ObjectId, visibleUserIdSet, hasOrder, orderInfo) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -177,17 +182,17 @@ func (l *GetInviteRecordsLogic) resolveInviteRecordVisibleUserIds(currentUserId
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (l *GetInviteRecordsLogic) canViewInviteRecord(currentUserId, logObjectId int64, hasOrder bool, orderInfo inviteOrderUser) bool {
|
||||
func (l *GetInviteRecordsLogic) canViewInviteRecord(currentUserId, logObjectId int64, visibleUserIds map[int64]struct{}, hasOrder bool, orderInfo inviteOrderUser) bool {
|
||||
if _, ok := visibleUserIds[logObjectId]; !ok {
|
||||
return false
|
||||
}
|
||||
if logObjectId == currentUserId {
|
||||
if hasOrder {
|
||||
return orderInfo.UserId == currentUserId || orderInfo.RefererId == currentUserId
|
||||
}
|
||||
return true
|
||||
}
|
||||
if !hasOrder {
|
||||
return false
|
||||
}
|
||||
return orderInfo.UserId == currentUserId && orderInfo.SubscriptionUserId == logObjectId
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeInviteRecordsPagination(req *types.GetInviteRecordsRequest) {
|
||||
|
||||
@@ -123,6 +123,33 @@ func TestGetInviteRecordsFamilyMemberSeesOwnerGiftLog(t *testing.T) {
|
||||
assertInviteRecordsExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestGetInviteRecordsFamilyMemberSeesAllOwnerGiftLogs(t *testing.T) {
|
||||
svcCtx, mock, cleanup := newInviteRecordsTestSvc(t)
|
||||
defer cleanup()
|
||||
|
||||
expectInviteRecordsFamilyMember(t, mock, 51637, 510)
|
||||
mock.ExpectQuery("SELECT id, object_id, content").
|
||||
WithArgs(34, int64(51637), int64(510), "邀请赠送").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}).
|
||||
AddRow(6, 510, `{"order_no":"owner-order","amount":7,"remark":"邀请赠送"}`, 1779934630000))
|
||||
mock.ExpectQuery("SELECT `order`.order_no, `order`.user_id, `order`.subscription_user_id, invitee.referer_id FROM `order`").
|
||||
WithArgs("owner-order").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id", "subscription_user_id", "referer_id"}).AddRow("owner-order", 571, 571, 510))
|
||||
|
||||
resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(51637, 0), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetInviteRecords returned error: %v", err)
|
||||
}
|
||||
assertInviteRecordResponse(t, resp, types.InviteRecord{
|
||||
Role: inviteRecordRoleInviter,
|
||||
PeerHash: hash.InvitePeerHash(571),
|
||||
GiftDays: 7,
|
||||
OrderNo: "owner-order",
|
||||
CreatedAt: 1779934630000,
|
||||
})
|
||||
assertInviteRecordsExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestGetInviteRecordsOwnerDoesNotSeeMemberGiftLog(t *testing.T) {
|
||||
svcCtx, mock, cleanup := newInviteRecordsTestSvc(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -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":"<orderNo>"%' 先走索引粗筛;
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func (m *defaultUserModel) FindSingleModeAnchorSubscribe(ctx context.Context, us
|
||||
var data Subscribe
|
||||
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, _ interface{}) error {
|
||||
return conn.Model(&Subscribe{}).
|
||||
Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%') AND `status` IN ?", userId, []int64{0, 1, 2, 3, 4, 5}).
|
||||
Where("user_id = ? AND (order_id > 0 OR token LIKE 'iap:%') AND `status` IN ?", userId, []int64{0, 1, 2, 3, 4, 5}).
|
||||
Order("expire_time DESC").
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user