Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77377ed27b | |||
| 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)
|
||||
|
||||
@@ -69,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)
|
||||
}
|
||||
@@ -90,6 +90,54 @@ 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()
|
||||
@@ -111,7 +159,7 @@ func TestEvaluatePromoRejectsInactiveRuleWhenUserHasNoSubscription(t *testing.T)
|
||||
WithArgs(int64(51640), time.UnixMilli(0), 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
|
||||
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: db, PromoModel: model}, 51640, 1, 30)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,21 +35,19 @@ func calculatePurchasePrice(
|
||||
PayableBase: originalPrice,
|
||||
}
|
||||
|
||||
if allowPromo {
|
||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
|
||||
result.PayableBase = promoResult.PromoPrice
|
||||
result.PromoRuleId = promoResult.RuleID
|
||||
result.PromoDiscount = originalPrice - result.PayableBase
|
||||
result.PromoPrice = promoResult.PromoPrice
|
||||
if result.PromoDiscount < 0 {
|
||||
result.PromoDiscount = 0
|
||||
}
|
||||
return result, nil
|
||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity, isFirstPurchase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
|
||||
result.PayableBase = promoResult.PromoPrice
|
||||
result.PromoRuleId = promoResult.RuleID
|
||||
result.PromoDiscount = originalPrice - result.PayableBase
|
||||
result.PromoPrice = promoResult.PromoPrice
|
||||
if result.PromoDiscount < 0 {
|
||||
result.PromoDiscount = 0
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
discount := float64(1)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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