Compare commits

..

1 Commits

Author SHA1 Message Date
shanshanzhong147 3d1a004fa1 修复(#129): 新注册用户不命中沉默促销
Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 23:25:22 -07:00
28 changed files with 87 additions and 1494 deletions
+2 -2
View File
@@ -15,10 +15,10 @@ Logger: # 日志配置
Level: debug # 日志级别: debug, info, warn, error, panic, fatal Level: debug # 日志级别: debug, info, warn, error, panic, fatal
MySQL: MySQL:
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306 Addr: 154.12.35.103:3306 # host 网络模式; bridge 模式改为 mysql:3306
Username: root # MySQL用户名 Username: root # MySQL用户名
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致 Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
Dbname: hifast # MySQL数据库名 Dbname: ppanel # MySQL数据库名
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
MaxIdleConns: 10 MaxIdleConns: 10
MaxOpenConns: 100 MaxOpenConns: 100
@@ -67,17 +67,6 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status) return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status)
} }
// 幂等校验:若该 order_no 已存在 333 退款日志,拒绝再次退款。
// HIF-131 案例:订单状态被外部入口(stuckOrderRecovery 把 6 视为卡住的 claim)回退到 5,
// 让 lockCommissionSource 误抓到原始 331/332 amount 再次扣减佣金。
refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo)
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) userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
if err != nil { if err != nil {
return err return err
@@ -267,13 +256,6 @@ func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, ord
return nil, 0, nil 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( func (l *RefundOrderLogic) buildRefundAuditLog(
operator *modeluser.User, operator *modeluser.User,
orderInfo *modelorder.Order, orderInfo *modelorder.Order,
@@ -1,23 +1,11 @@
package order package order
import ( import (
"context"
"fmt"
"strings"
"testing" "testing"
"time" "time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/perfect-panel/server/internal/svc"
modelorder "github.com/perfect-panel/server/internal/model/order" modelorder "github.com/perfect-panel/server/internal/model/order"
modeluser "github.com/perfect-panel/server/internal/model/user" 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) { func TestOrderStatusName(t *testing.T) {
@@ -95,155 +83,3 @@ func TestBuildRefundAuditLog(t *testing.T) {
t.Fatalf("unexpected commission transition: %+v", got) 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,11 +12,7 @@ import (
"gorm.io/gorm" "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) { func (fakePromoModel) QueryEligibleRules(context.Context, int64, int64) ([]*promomodel.RuleWithPrice, error) {
return nil, nil return nil, nil
@@ -26,24 +22,15 @@ func (fakePromoModel) InsertUsage(context.Context, *promomodel.Usage, ...*gorm.D
return nil return nil
} }
func (m fakePromoModel) InsertRule(ctx context.Context, rule *promomodel.Rule) error { func (fakePromoModel) InsertRule(context.Context, *promomodel.Rule) error {
if m.insertRule != nil {
return m.insertRule(ctx, rule)
}
return nil return nil
} }
func (m fakePromoModel) FindRule(ctx context.Context, id int64) (*promomodel.Rule, error) { func (fakePromoModel) FindRule(context.Context, int64) (*promomodel.Rule, error) {
if m.findRule != nil {
return m.findRule(ctx, id)
}
return nil, gorm.ErrRecordNotFound return nil, gorm.ErrRecordNotFound
} }
func (m fakePromoModel) UpdateRule(ctx context.Context, rule *promomodel.Rule) error { func (fakePromoModel) UpdateRule(context.Context, *promomodel.Rule) error {
if m.updateRule != nil {
return m.updateRule(ctx, rule)
}
return nil return nil
} }
@@ -1,133 +0,0 @@
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)
}
}
+4 -34
View File
@@ -17,24 +17,11 @@ const (
subscribeCachePref = "promo:subscribe:" 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 { func validateRuleInput(ruleType string, params map[string]interface{}, priority int64, startTime, endTime *int64) error {
if priority < 0 { if priority < 0 {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "priority must be greater than or equal to 0") return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "priority must be greater than or equal to 0")
} }
startAt, err := normalizeRuleTimestamp(startTime) if startTime != nil && endTime != nil && *startTime >= *endTime {
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") return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "start_time must be less than end_time")
} }
switch ruleType { switch ruleType {
@@ -106,29 +93,12 @@ func parseParams(data string) map[string]interface{} {
return params return params
} }
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 { func unixPtrToTimePtr(ts *int64) *time.Time {
t, err := normalizeRuleTimestamp(ts) if ts == nil || *ts == 0 {
if err != nil {
return nil return nil
} }
return t t := time.Unix(*ts, 0)
return &t
} }
func timePtrToUnixPtr(t *time.Time) *int64 { func timePtrToUnixPtr(t *time.Time) *int64 {
-33
View File
@@ -1,33 +0,0 @@
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
}
+3 -13
View File
@@ -28,16 +28,7 @@ type promoRuleParams struct {
InactiveMonths int `json:"inactive_months"` InactiveMonths int `json:"inactive_months"`
} }
// EvaluatePromo decides whether the given (user, subscribe, quantity) tuple func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64) (*PromoResult, error) {
// 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{} result := &PromoResult{}
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || subscribeID <= 0 || quantity <= 0 { if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || subscribeID <= 0 || quantity <= 0 {
return result, nil return result, nil
@@ -68,7 +59,7 @@ func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64
} }
} }
eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, isFirstPurchase, &currentUser, now) eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, &currentUser, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -105,13 +96,12 @@ func evaluatePromoRule(
rule *promo.RuleWithPrice, rule *promo.RuleWithPrice,
params promoRuleParams, params promoRuleParams,
userID int64, userID int64,
isFirstPurchase bool,
currentUser *user.User, currentUser *user.User,
now time.Time, now time.Time,
) (bool, time.Time, error) { ) (bool, time.Time, error) {
switch rule.Type { switch rule.Type {
case promo.RuleTypeNewUser: case promo.RuleTypeNewUser:
if userID <= 0 || !isFirstPurchase { if userID <= 0 {
return false, time.Time{}, nil return false, time.Time{}, nil
} }
return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now) return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now)
+2 -50
View File
@@ -69,7 +69,7 @@ func TestEvaluatePromoAllowsAnonymousCampaign(t *testing.T) {
}, },
}} }}
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 0, 7, 12, true) got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 0, 7, 12)
if err != nil { if err != nil {
t.Fatalf("EvaluatePromo returned error: %v", err) t.Fatalf("EvaluatePromo returned error: %v", err)
} }
@@ -90,54 +90,6 @@ 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) { func TestEvaluatePromoRejectsInactiveRuleWhenUserHasNoSubscription(t *testing.T) {
db, mock, cleanup := newCommonPromoTestDB(t) db, mock, cleanup := newCommonPromoTestDB(t)
defer cleanup() defer cleanup()
@@ -159,7 +111,7 @@ func TestEvaluatePromoRejectsInactiveRuleWhenUserHasNoSubscription(t *testing.T)
WithArgs(int64(51640), time.UnixMilli(0), 1). WithArgs(int64(51640), time.UnixMilli(0), 1).
WillReturnError(gorm.ErrRecordNotFound) WillReturnError(gorm.ErrRecordNotFound)
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: db, PromoModel: model}, 51640, 1, 30, true) got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: db, PromoModel: model}, 51640, 1, 30)
if err != nil { if err != nil {
t.Fatalf("EvaluatePromo returned error: %v", err) t.Fatalf("EvaluatePromo returned error: %v", err)
} }
+7 -89
View File
@@ -13,7 +13,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hibiken/asynq" "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/model/payment"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
@@ -23,47 +22,6 @@ import (
queueType "github.com/perfect-panel/server/queue/types" 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 { type EPayNotifyLogic struct {
logger.Logger logger.Logger
ctx *gin.Context ctx *gin.Context
@@ -89,8 +47,6 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
} }
orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OutTradeNo) orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OutTradeNo)
if err != nil { 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)) 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) return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", req.OutTradeNo)
} }
@@ -109,54 +65,17 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
} }
// Verify sign // Verify sign
client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type) client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type)
signValid := client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) if !client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) && !l.svcCtx.Config.Debug {
l.Logger.Error("[EPayNotify] Verify sign failed")
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 return nil
case epayNotifyDecisionAckIdempotent: }
// Order already Finished — repeat callback is expected, ack with 200. if req.TradeStatus != "TRADE_SUCCESS" {
l.Logger.Error("[EPayNotify] Trade status is not success", logger.Field("orderNo", req.OutTradeNo), logger.Field("tradeStatus", req.TradeStatus))
return nil return nil
case epayNotifyDecisionProcess:
// fall through
} }
if orderInfo.Status == 5 {
// HIF-136 P03: persist gateway trade_no BEFORE flipping status so post-mortems can return nil
// 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 // Update order status
err = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OutTradeNo, 2) err = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OutTradeNo, 2)
if err != nil { if err != nil {
@@ -167,7 +86,6 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
"[SubscriptionFlow] epay notify marked order as paid", "[SubscriptionFlow] epay notify marked order as paid",
append(commonLogic.OrderTraceFields(orderInfo), append(commonLogic.OrderTraceFields(orderInfo),
logger.Field("payment_platform", data.Platform), logger.Field("payment_platform", data.Platform),
logger.Field("trade_no", req.TradeNo),
)..., )...,
) )
// Create activate order task // Create activate order task
@@ -1,182 +0,0 @@
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()
}
}
@@ -1,17 +0,0 @@
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")
}
@@ -1,32 +0,0 @@
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,8 +88,13 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
// routes the request to renewal semantics, where first-purchase promos are disabled. // routes the request to renewal semantics, where first-purchase promos are disabled.
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 { if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
var existSub user.Subscribe var existSub user.Subscribe
if e := paidSubscriptionQuery(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID). if e := l.svcCtx.DB.WithContext(l.ctx).
First(&existSub).Error; e == nil && existSub.Id > 0 { 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 != "" {
orderType = 2 orderType = 2
l.Infow("[PreCreateOrder] purchase preview routed to renewal because an existing subscription was found", l.Infow("[PreCreateOrder] purchase preview routed to renewal because an existing subscription was found",
logger.Field("route_mode", "global_single_subscription"), logger.Field("route_mode", "global_single_subscription"),
+15 -13
View File
@@ -27,7 +27,7 @@ func calculatePurchasePrice(
quantity int64, quantity int64,
discounts []types.SubscribeDiscount, discounts []types.SubscribeDiscount,
eligibleForDiscount bool, eligibleForDiscount bool,
isFirstPurchase bool, allowPromo bool,
) (*orderPriceResult, error) { ) (*orderPriceResult, error) {
originalPrice := unitPrice * quantity originalPrice := unitPrice * quantity
result := &orderPriceResult{ result := &orderPriceResult{
@@ -35,19 +35,21 @@ func calculatePurchasePrice(
PayableBase: originalPrice, PayableBase: originalPrice,
} }
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity, isFirstPurchase) if allowPromo {
if err != nil { promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity)
return nil, err if err != nil {
} return nil, err
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice { }
result.PayableBase = promoResult.PromoPrice if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
result.PromoRuleId = promoResult.RuleID result.PayableBase = promoResult.PromoPrice
result.PromoDiscount = originalPrice - result.PayableBase result.PromoRuleId = promoResult.RuleID
result.PromoPrice = promoResult.PromoPrice result.PromoDiscount = originalPrice - result.PayableBase
if result.PromoDiscount < 0 { result.PromoPrice = promoResult.PromoPrice
result.PromoDiscount = 0 if result.PromoDiscount < 0 {
result.PromoDiscount = 0
}
return result, nil
} }
return result, nil
} }
discount := float64(1) discount := float64(1)
@@ -221,79 +221,3 @@ func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) {
t.Fatalf("PromoRuleId = %d, want 11", result.PromoRuleId) 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)
}
}
+7 -2
View File
@@ -129,8 +129,13 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
// 防止不同套餐购买创建第二条订阅。 // 防止不同套餐购买创建第二条订阅。
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 { if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
var existSub user.Subscribe var existSub user.Subscribe
if e := paidSubscriptionQuery(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID). if e := l.svcCtx.DB.WithContext(l.ctx).
First(&existSub).Error; e == nil && existSub.Id > 0 { 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 != "" {
orderType = 2 orderType = 2
parentOrderID = existSub.OrderId parentOrderID = existSub.OrderId
subscribeToken = existSub.Token subscribeToken = existSub.Token
+5 -17
View File
@@ -41,22 +41,6 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
} }
userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User) 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) candidates, err := querySubscribePromoCandidates(ctx, svcCtx, subscribeIDs, userInfo != nil)
if err != nil { if err != nil {
if isMissingPromoTableError(err) { if isMissingPromoTableError(err) {
@@ -65,6 +49,10 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
return nil, err return nil, err
} }
userID := int64(0)
if userInfo != nil {
userID = userInfo.Id
}
for _, candidate := range candidates { for _, candidate := range candidates {
if candidate.Quantity <= 0 { if candidate.Quantity <= 0 {
continue continue
@@ -75,7 +63,7 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists { if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
continue continue
} }
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, candidate.SubscribeId, candidate.Quantity, isFirstPurchase) promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, candidate.SubscribeId, candidate.Quantity)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -9,10 +9,8 @@ import (
"github.com/DATA-DOG/go-sqlmock" "github.com/DATA-DOG/go-sqlmock"
"github.com/perfect-panel/server/internal/model/promo" "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/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -90,66 +88,6 @@ 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 { type fakeSubscribePromoModel struct {
rules []*promo.RuleWithPrice rules []*promo.RuleWithPrice
lastSubscribeID int64 lastSubscribeID int64
@@ -11,7 +11,6 @@ import (
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type CommissionWithdrawLogic struct { type CommissionWithdrawLogic struct {
@@ -30,7 +29,7 @@ func NewCommissionWithdrawLogic(ctx context.Context, svcCtx *svc.ServiceContext)
} }
func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdrawRequest) (resp *types.WithdrawalLog, err error) { func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdrawRequest) (resp *types.WithdrawalLog, err error) {
ctxUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok { if !ok {
logger.Error("current user is not found in context") logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
@@ -52,38 +51,28 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
} }
} }
// HIF-139: read commission and pending total directly from DB inside a // Sum all pending (status=0) withdrawals to compute available balance.
// transaction, FOR UPDATE on the user row. The ctxUser snapshot may be // Available = commission - pendingTotal; commission is only deducted on approval.
// served from cache and can be stale (the original bug allowed a user var pendingTotal int64
// with cached commission=996900 to submit a withdrawal while DB said 0, if err = l.svcCtx.DB.WithContext(l.ctx).
// which then failed admin approval with 20010). Approve already locks Model(&user.Withdrawal{}).
// the user row this way; aligning submission closes the gap. 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)
}
var w user.Withdrawal var w user.Withdrawal
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { 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{ w = user.Withdrawal{
UserId: ctxUser.Id, UserId: u.Id,
Amount: req.Amount, Amount: req.Amount,
Content: req.Content, Content: req.Content,
Status: user.WithdrawalStatusPending, Status: user.WithdrawalStatusPending,
@@ -92,19 +81,16 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
Account: req.Account, Account: req.Account,
QrCodeUrl: req.QrCodeUrl, QrCodeUrl: req.QrCodeUrl,
} }
if txErr := tx.Create(&w).Error; txErr != nil { return tx.Create(&w).Error
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 { if err != nil {
return nil, err 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 &types.WithdrawalLog{ return &types.WithdrawalLog{
Id: w.Id, Id: w.Id,
UserId: ctxUser.Id, UserId: u.Id,
Amount: req.Amount, Amount: req.Amount,
Content: req.Content, Content: req.Content,
Status: user.WithdrawalStatusPending, Status: user.WithdrawalStatusPending,
@@ -1,212 +0,0 @@
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,17 +113,12 @@ func (l *GetInviteRecordsLogic) GetInviteRecords(req *types.GetInviteRecordsRequ
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query orders failed: %v", err.Error()) 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)) allRecords := make([]types.InviteRecord, 0, len(parsedLogs))
for _, parsed := range parsedLogs { for _, parsed := range parsedLogs {
content := parsed.content content := parsed.content
logItem := parsed.log logItem := parsed.log
orderInfo, hasOrder := orders[content.OrderNo] orderInfo, hasOrder := orders[content.OrderNo]
if !l.canViewInviteRecord(u.Id, logItem.ObjectId, visibleUserIdSet, hasOrder, orderInfo) { if !l.canViewInviteRecord(u.Id, logItem.ObjectId, hasOrder, orderInfo) {
continue continue
} }
@@ -182,17 +177,17 @@ func (l *GetInviteRecordsLogic) resolveInviteRecordVisibleUserIds(currentUserId
return nil, err return nil, err
} }
func (l *GetInviteRecordsLogic) canViewInviteRecord(currentUserId, logObjectId int64, visibleUserIds map[int64]struct{}, hasOrder bool, orderInfo inviteOrderUser) bool { func (l *GetInviteRecordsLogic) canViewInviteRecord(currentUserId, logObjectId int64, hasOrder bool, orderInfo inviteOrderUser) bool {
if _, ok := visibleUserIds[logObjectId]; !ok {
return false
}
if logObjectId == currentUserId { if logObjectId == currentUserId {
if hasOrder { if hasOrder {
return orderInfo.UserId == currentUserId || orderInfo.RefererId == currentUserId return orderInfo.UserId == currentUserId || orderInfo.RefererId == currentUserId
} }
return true return true
} }
return true if !hasOrder {
return false
}
return orderInfo.UserId == currentUserId && orderInfo.SubscriptionUserId == logObjectId
} }
func normalizeInviteRecordsPagination(req *types.GetInviteRecordsRequest) { func normalizeInviteRecordsPagination(req *types.GetInviteRecordsRequest) {
@@ -123,33 +123,6 @@ func TestGetInviteRecordsFamilyMemberSeesOwnerGiftLog(t *testing.T) {
assertInviteRecordsExpectations(t, mock) 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) { func TestGetInviteRecordsOwnerDoesNotSeeMemberGiftLog(t *testing.T) {
svcCtx, mock, cleanup := newInviteRecordsTestSvc(t) svcCtx, mock, cleanup := newInviteRecordsTestSvc(t)
defer cleanup() defer cleanup()
-38
View File
@@ -1,38 +0,0 @@
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
}
-172
View File
@@ -1,172 +0,0 @@
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)
}
}
+1 -1
View File
@@ -67,7 +67,7 @@ func (m *defaultUserModel) FindSingleModeAnchorSubscribe(ctx context.Context, us
var data Subscribe var data Subscribe
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, _ interface{}) error { err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, _ interface{}) error {
return conn.Model(&Subscribe{}). return conn.Model(&Subscribe{}).
Where("user_id = ? AND (order_id > 0 OR token LIKE 'iap:%') AND `status` IN ?", userId, []int64{0, 1, 2, 3, 4, 5}). Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%') AND `status` IN ?", userId, []int64{0, 1, 2, 3, 4, 5}).
Order("expire_time DESC"). Order("expire_time DESC").
Order("updated_at DESC"). Order("updated_at DESC").
Order("id DESC"). Order("id DESC").
-19
View File
@@ -272,25 +272,6 @@ func (l *ActivateOrderLogic) claimAndGetOrder(ctx context.Context, orderNo strin
} }
func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) error { func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) error {
// 终态守卫:OrderStatusClaimed(6) 与 orderStatusRefunded(6) 共用同一枚举值。
// 若已存在 333 退款日志,说明此处的 status=6 是「已退款」,不能再降回 5,
// 否则下次 activate 会重新激活订阅、且管理员可二次触发退款导致佣金被多次扣减。
// 详见 HIF-131 / HIF-132。
refunded, err := log.HasRefundCommissionLog(l.svc.DB.WithContext(ctx), orderNo)
if err != nil {
logger.WithContext(ctx).Error("Check refund log before release claim failed",
logger.Field("error", err.Error()),
logger.Field("order_no", orderNo),
)
return fmt.Errorf("check refund log failed for order %s: %w", orderNo, err)
}
if refunded {
logger.WithContext(ctx).Info("Skip release claim for refunded order (status=6 + refund log)",
logger.Field("order_no", orderNo),
)
return nil
}
if err := l.svc.DB.WithContext(ctx). if err := l.svc.DB.WithContext(ctx).
Model(&order.Order{}). Model(&order.Order{}).
Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed). Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed).
@@ -6,7 +6,6 @@ import (
"time" "time"
"github.com/hibiken/asynq" "github.com/hibiken/asynq"
logmodel "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@@ -55,25 +54,6 @@ func (l *StuckOrderRecoveryLogic) ProcessTask(ctx context.Context, _ *asynq.Task
for i := range stuckOrders { for i := range stuckOrders {
o := &stuckOrders[i] o := &stuckOrders[i]
// 终态守卫:OrderStatusClaimed(6) 与 orderStatusRefunded(6) 共用同一枚举值,
// 若该订单已写入 333 退款佣金日志,说明状态 6 表示「已退款」而非「短暂 claim」,
// 必须跳过,否则会把已退款订单重置为 5 + 重新入队 activate,导致重复退款。
// 详见 HIF-131 / HIF-132。
refunded, err := logmodel.HasRefundCommissionLog(l.svc.DB.WithContext(ctx), o.OrderNo)
if err != nil {
logger.WithContext(ctx).Error("[StuckOrderRecovery] Failed to check refund log",
logger.Field("order_no", o.OrderNo),
logger.Field("error", err.Error()),
)
continue
}
if refunded {
logger.WithContext(ctx).Info("[StuckOrderRecovery] Skip refunded order (status=6 + refund log)",
logger.Field("order_no", o.OrderNo),
)
continue
}
result := l.svc.DB.WithContext(ctx). result := l.svc.DB.WithContext(ctx).
Model(&order.Order{}). Model(&order.Order{}).
Where("order_no = ? AND status = ?", o.OrderNo, OrderStatusClaimed). Where("order_no = ? AND status = ?", o.OrderNo, OrderStatusClaimed).