Compare commits

...

8 Commits

Author SHA1 Message Date
shanshanzhong147 77377ed27b 修复(#129): 新注册用户不命中沉默促销
Co-authored-by: multica-agent <github@multica.ai>
2026-05-31 22:34:15 -07:00
shanshanzhong147 c3050821d5 x
Build docker and publish / build (20.15.1) (push) Failing after 19m50s
Build docker and publish / build (20.15.1) (pull_request) Failing after 20m32s
2026-05-31 21:15:57 -07:00
shanshanzhong147 5b9f384f81 修复(#132): 退款幂等校验 + 已退款订单防重新激活
Build docker and publish / build (20.15.1) (push) Failing after 21m4s
Build docker and publish / build (20.15.1) (pull_request) Failing after 21m47s
P01:refundOrderLogic.RefundOrder 在事务内 FOR UPDATE 后、lockCommissionSource 前新增
333 退款日志扫描,命中即返回 OrderAlreadyRefunded(61006),不再写日志/扣 commission/
改 order.status。

P02:堵住已退款订单状态被回退入口
- queue/logic/order/stuckOrderRecoveryLogic.go:批扫 status=6 时新增 333 日志守卫,
  已退款订单不再被重置为 5 + 重新入队 activate(HIF-131 trace 中订单 53647 被刷回 5
  的真凶)
- queue/logic/order/activateOrderLogic.go:releaseClaim 同步加守卫做防御性兜底

新增 internal/model/log/refund.go 共享 helper HasRefundCommissionLog:
type=33 + content LIKE 走索引粗筛,再 JSON 反序列化确认 content.type==333 AND
content.order_no==orderNo,防 LIKE 子串误判。

测试:单元测试覆盖正常退款 / 已有 333 日志拒绝 / 子串误判防御 / 脏 JSON 容错;
sqlmock 严格断言命中后事务序列只含 BEGIN/SELECT order FOR UPDATE/SELECT
system_logs/ROLLBACK,无任何 commission 写入。

不做:calculateCommission、status 枚举拆分、表结构变更、支付通道 notify、用户余额回补。

Co-authored-by: multica-agent <github@multica.ai>
2026-05-31 20:19:35 -07:00
shanshanzhong147 7236ca4cf2 修复(#128): 按规则类型决定促销资格,让 InactiveUser/Campaign 对老用户生效
Build docker and publish / build (20.15.1) (push) Has been cancelled
Build docker and publish / build (20.15.1) (pull_request) Has been cancelled
此前 calculatePurchasePrice 把 allowPromo 绑死在 orderType == 1,导致只要用户
有任何过往付费订阅(含已过期),就会被 paidSubscriptionQuery 路由为续费
(orderType=2),跳过所有促销评估。后果:

  - InactiveUser 召回促销永远无法触发(其目标人群恰好就是有过期订阅的用户)
  - Campaign 全员活动对老用户 / 升级加购场景完全失效
  - 套餐列表(loadSubscribePromoMap)直接调 EvaluatePromo 不感知 orderType,
    可能显示促销价但下单时却拿到原价

修复方式:把 isFirstPurchase 下放给 EvaluatePromo,由规则类型决定 gating:

  - NewUser    要求 isFirstPurchase=true(保留首购语义)
  - InactiveUser 由规则自身的"上次订阅过期 N 月以上"条件判定
  - Campaign   时间窗内对任意用户生效

新增 common.HasPaidSubscription 助手,套餐列表与下单走同一份 isFirstPurchase
判定,确保展示价与实际下单价口径一致。

测试:补充 EvaluatePromo / calculatePurchasePrice 的 NewUser 屏蔽 + Campaign
放开用例;更新 loadSubscribePromoMap 测试覆盖新增 HasPaidSubscription 查询。

注:renewalLogic.go 仍未接入促销(属于方向 B 的彻底统一,本次未涵盖)。
2026-05-31 19:00:30 -07:00
shanshanzhong147 ae126296e3 修复(#128): 统一续费场景促销判断
Build docker and publish / build (20.15.1) (push) Failing after 23m3s
Build docker and publish / build (20.15.1) (pull_request) Failing after 19m4s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-30 23:12:39 -07:00
shanshanzhong147 1e99cfb83c 修复(#128): 兼容促销规则毫秒时间戳
Build docker and publish / build (20.15.1) (push) Failing after 19m21s
Build docker and publish / build (20.15.1) (pull_request) Failing after 19m48s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-30 04:16:21 -07:00
shanshanzhong147 0659a930f8 修复(#128): 修复家庭成员邀请流水可见性
Build docker and publish / build (20.15.1) (push) Failing after 18m41s
Build docker and publish / build (20.15.1) (pull_request) Failing after 19m16s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-30 03:27:04 -07:00
shanshanzhong147 c2d1b5a0d8 修复(#130): 统一家庭成员促销资格口径
Build docker and publish / build (20.15.1) (push) Failing after 22m15s
Build docker and publish / build (20.15.1) (pull_request) Failing after 18m6s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-30 01:58:15 -07:00
24 changed files with 1032 additions and 56 deletions
+2 -2
View File
@@ -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)
}
}
+34 -4
View File
@@ -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 {
+33
View File
@@ -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
}
+14 -4
View File
@@ -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, &currentUser, now)
eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, isFirstPurchase, &currentUser, now)
if err != nil {
return nil, err
}
@@ -96,12 +105,13 @@ func evaluatePromoRule(
rule *promo.RuleWithPrice,
params promoRuleParams,
userID int64,
isFirstPurchase bool,
currentUser *user.User,
now time.Time,
) (bool, time.Time, error) {
switch rule.Type {
case promo.RuleTypeNewUser:
if userID <= 0 {
if userID <= 0 || !isFirstPurchase {
return false, time.Time{}, nil
}
return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now)
@@ -165,7 +175,7 @@ func evaluateInactiveUserPromo(
Take(&lastSub).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, ruleExpiresAt, nil
return false, time.Time{}, nil
}
return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed")
}
+108 -1
View File
@@ -2,9 +2,13 @@ package common
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"github.com/perfect-panel/server/internal/model/promo"
"github.com/perfect-panel/server/internal/svc"
"gorm.io/gorm"
@@ -65,7 +69,7 @@ func TestEvaluatePromoAllowsAnonymousCampaign(t *testing.T) {
},
}}
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 0, 7, 12)
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 0, 7, 12, true)
if err != nil {
t.Fatalf("EvaluatePromo returned error: %v", err)
}
@@ -86,6 +90,90 @@ func TestEvaluatePromoAllowsAnonymousCampaign(t *testing.T) {
}
}
func TestEvaluatePromoCampaignAppliesToReturningUsers(t *testing.T) {
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 4,
Name: "campaign",
Type: promo.RuleTypeCampaign,
Enabled: true,
},
PromoPrice: 299,
},
}}
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 42, 7, 1, false)
if err != nil {
t.Fatalf("EvaluatePromo returned error: %v", err)
}
if !got.Eligible {
t.Fatal("campaign promo should remain eligible for returning users (isFirstPurchase=false)")
}
if got.PromoPrice != 299 {
t.Fatalf("PromoPrice = %d, want 299", got.PromoPrice)
}
}
func TestEvaluatePromoNewUserRequiresFirstPurchase(t *testing.T) {
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 5,
Name: "new user",
Type: promo.RuleTypeNewUser,
Enabled: true,
Params: `{"window_hours": 72}`,
},
PromoPrice: 99,
},
}}
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 42, 7, 1, false)
if err != nil {
t.Fatalf("EvaluatePromo returned error: %v", err)
}
if got.Eligible {
t.Fatal("new-user promo must be gated out when isFirstPurchase=false (user already has paid subscriptions)")
}
}
func TestEvaluatePromoRejectsInactiveRuleWhenUserHasNoSubscription(t *testing.T) {
db, mock, cleanup := newCommonPromoTestDB(t)
defer cleanup()
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 9,
Name: "inactive",
Type: promo.RuleTypeInactiveUser,
Params: `{"inactive_months":3}`,
Enabled: true,
},
PromoPrice: 100,
},
}}
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_subscribe` WHERE user_id = ? ORDER BY CASE WHEN expire_time = ? THEN 0 ELSE 1 END, expire_time DESC LIMIT ?")).
WithArgs(int64(51640), time.UnixMilli(0), 1).
WillReturnError(gorm.ErrRecordNotFound)
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: db, PromoModel: model}, 51640, 1, 30, true)
if err != nil {
t.Fatalf("EvaluatePromo returned error: %v", err)
}
if got.Eligible {
t.Fatal("new user without subscription history should not be eligible for inactive promo")
}
if got.RuleID != 0 || got.PromoPrice != 0 {
t.Fatalf("promo fields = (%d, %d), want zero values", got.RuleID, got.PromoPrice)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet db expectations: %v", err)
}
}
type fakePromoModel struct {
rules []*promo.RuleWithPrice
lastSubscribeID int64
@@ -145,3 +233,22 @@ func (m *fakePromoModel) QueryUsageList(context.Context, promo.UsageFilter) (int
func (m *fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error {
return nil
}
func newCommonPromoTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
@@ -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"),
+2 -4
View File
@@ -27,7 +27,7 @@ func calculatePurchasePrice(
quantity int64,
discounts []types.SubscribeDiscount,
eligibleForDiscount bool,
allowPromo bool,
isFirstPurchase bool,
) (*orderPriceResult, error) {
originalPrice := unitPrice * quantity
result := &orderPriceResult{
@@ -35,8 +35,7 @@ func calculatePurchasePrice(
PayableBase: originalPrice,
}
if allowPromo {
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity)
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity, isFirstPurchase)
if err != nil {
return nil, err
}
@@ -50,7 +49,6 @@ func calculatePurchasePrice(
}
return result, nil
}
}
discount := float64(1)
if len(discounts) > 0 {
@@ -221,3 +221,79 @@ func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) {
t.Fatalf("PromoRuleId = %d, want 11", result.PromoRuleId)
}
}
func TestCalculatePurchasePriceCampaignAppliesToReturningUsers(t *testing.T) {
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 12,
Name: "campaign",
Type: promo.RuleTypeCampaign,
Enabled: true,
},
PromoPrice: 400,
},
}}
svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}
// isFirstPurchase=false simulates a returning user routed to renewal; the
// Campaign promo must still apply because it has no first-purchase gate.
result, err := calculatePurchasePrice(
context.Background(),
svcCtx,
42,
2,
1000,
1,
nil,
false,
false,
)
if err != nil {
t.Fatalf("calculatePurchasePrice returned error: %v", err)
}
if result.PromoRuleId != 12 {
t.Fatalf("PromoRuleId = %d, want 12 (campaign should apply to returning users)", result.PromoRuleId)
}
if result.PayableBase != 400 {
t.Fatalf("PayableBase = %d, want 400", result.PayableBase)
}
}
func TestCalculatePurchasePriceNewUserGatedByFirstPurchase(t *testing.T) {
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 13,
Name: "new user",
Type: promo.RuleTypeNewUser,
Enabled: true,
Params: `{"window_hours": 72}`,
},
PromoPrice: 200,
},
}}
svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}
// isFirstPurchase=false → NewUser rule must be skipped, regular discount applies.
result, err := calculatePurchasePrice(
context.Background(),
svcCtx,
42,
2,
1000,
1,
[]types.SubscribeDiscount{{Quantity: 1, Discount: 90}},
true,
false,
)
if err != nil {
t.Fatalf("calculatePurchasePrice returned error: %v", err)
}
if result.PromoRuleId != 0 || result.PromoDiscount != 0 {
t.Fatalf("promo fields = (%d, %d), want (0, 0) when isFirstPurchase=false", result.PromoRuleId, result.PromoDiscount)
}
if result.PayableBase != 900 {
t.Fatalf("PayableBase = %d, want 900 (regular 90%% discount)", result.PayableBase)
}
}
+2 -7
View File
@@ -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
+17 -5
View File
@@ -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()
+38
View File
@@ -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
}
+172
View File
@@ -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)
}
}
+1 -1
View File
@@ -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").
+19
View File
@@ -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).