Compare commits
8 Commits
3d1a004fa1
...
77377ed27b
| 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
|
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||||
|
|
||||||
MySQL:
|
MySQL:
|
||||||
Addr: 154.12.35.103:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
||||||
Username: root # MySQL用户名
|
Username: root # MySQL用户名
|
||||||
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
||||||
Dbname: ppanel # MySQL数据库名
|
Dbname: hifast # MySQL数据库名
|
||||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||||
MaxIdleConns: 10
|
MaxIdleConns: 10
|
||||||
MaxOpenConns: 100
|
MaxOpenConns: 100
|
||||||
|
|||||||
@@ -67,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)
|
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 幂等校验:若该 order_no 已存在 333 退款日志,拒绝再次退款。
|
||||||
|
// HIF-131 案例:订单状态被外部入口(stuckOrderRecovery 把 6 视为卡住的 claim)回退到 5,
|
||||||
|
// 让 lockCommissionSource 误抓到原始 331/332 amount 再次扣减佣金。
|
||||||
|
refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if refunded {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund commission log", orderInfo.Id)
|
||||||
|
}
|
||||||
|
|
||||||
userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
|
userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -256,6 +267,13 @@ func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, ord
|
|||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasRefundLog 检查指定 order_no 是否已有 333 (CommissionTypeRefund) 退款佣金日志。
|
||||||
|
// 仅扫 type=33 + 内容含 order_no 的命中项,再用 JSON 二次确认 content.type==333,
|
||||||
|
// 防止 content.order_no 子串误判。
|
||||||
|
func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) {
|
||||||
|
return log.HasRefundCommissionLog(tx, orderNo)
|
||||||
|
}
|
||||||
|
|
||||||
func (l *RefundOrderLogic) buildRefundAuditLog(
|
func (l *RefundOrderLogic) buildRefundAuditLog(
|
||||||
operator *modeluser.User,
|
operator *modeluser.User,
|
||||||
orderInfo *modelorder.Order,
|
orderInfo *modelorder.Order,
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
package order
|
package order
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
modelorder "github.com/perfect-panel/server/internal/model/order"
|
modelorder "github.com/perfect-panel/server/internal/model/order"
|
||||||
modeluser "github.com/perfect-panel/server/internal/model/user"
|
modeluser "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOrderStatusName(t *testing.T) {
|
func TestOrderStatusName(t *testing.T) {
|
||||||
@@ -83,3 +95,155 @@ func TestBuildRefundAuditLog(t *testing.T) {
|
|||||||
t.Fatalf("unexpected commission transition: %+v", got)
|
t.Fatalf("unexpected commission transition: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-131 / HIF-132 修复:
|
||||||
|
// 当 system_logs 已存在该订单的 333 退款佣金日志时,再次调用 RefundOrder 必须:
|
||||||
|
// 1. 返回 OrderAlreadyRefunded 错误码;
|
||||||
|
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
|
||||||
|
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
|
||||||
|
//
|
||||||
|
// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE /
|
||||||
|
// SELECT system_logs(命中 333)/ ROLLBACK,不允许出现 commission 锁/更新/插入。
|
||||||
|
func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
||||||
|
const (
|
||||||
|
orderID = int64(53647)
|
||||||
|
orderNo = "202605301925431836075753253"
|
||||||
|
operatorUID = int64(519)
|
||||||
|
)
|
||||||
|
|
||||||
|
db, mock, cleanup := newRefundOrderTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("FROM `order`").
|
||||||
|
WithArgs(orderID, 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id"}).
|
||||||
|
AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028)))
|
||||||
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
|
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
||||||
|
AddRow(1, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-2250,"timestamp":0}`, orderNo)))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||||
|
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "duplicate"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("RefundOrder expected error, got nil")
|
||||||
|
}
|
||||||
|
if !isErrCode(err, xerr.OrderAlreadyRefunded) {
|
||||||
|
t.Fatalf("RefundOrder error code = %v, want OrderAlreadyRefunded; raw=%v", errCodeOf(err), err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==6 拒绝路径,
|
||||||
|
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
|
||||||
|
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
|
||||||
|
const (
|
||||||
|
orderID = int64(1001)
|
||||||
|
orderNo = "ORD-STATUS-6"
|
||||||
|
operatorUID = int64(519)
|
||||||
|
)
|
||||||
|
|
||||||
|
db, mock, cleanup := newRefundOrderTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("FROM `order`").
|
||||||
|
WithArgs(orderID, 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"}).
|
||||||
|
AddRow(orderID, orderNo, uint8(orderStatusRefunded)))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||||
|
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID})
|
||||||
|
if !isErrCode(err, xerr.OrderAlreadyRefunded) {
|
||||||
|
t.Fatalf("RefundOrder error code = %v, want OrderAlreadyRefunded; raw=%v", errCodeOf(err), err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefundOrder_RejectsWhenStatusNotRefundable 覆盖非 2/5 状态短路。
|
||||||
|
func TestRefundOrder_RejectsWhenStatusNotRefundable(t *testing.T) {
|
||||||
|
const (
|
||||||
|
orderID = int64(1002)
|
||||||
|
orderNo = "ORD-STATUS-1"
|
||||||
|
operatorUID = int64(519)
|
||||||
|
)
|
||||||
|
|
||||||
|
db, mock, cleanup := newRefundOrderTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("FROM `order`").
|
||||||
|
WithArgs(orderID, 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"}).
|
||||||
|
AddRow(orderID, orderNo, uint8(1)))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||||
|
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID})
|
||||||
|
if !isErrCode(err, xerr.OrderStatusError) {
|
||||||
|
t.Fatalf("RefundOrder error code = %v, want OrderStatusError; raw=%v", errCodeOf(err), err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRefundOrderTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||||
|
if strings.Contains(actualSQL, expectedSQL) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("open gorm db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, mock, func() {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestRefundOrderLogic(t *testing.T, db *gorm.DB, operatorID int64) *RefundOrderLogic {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &modeluser.User{Id: operatorID})
|
||||||
|
return &RefundOrderLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: &svc.ServiceContext{DB: db},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errCodeOf / isErrCode 用于绕开 wrapped error 检查内层 xerr 错误码。
|
||||||
|
func errCodeOf(err error) uint32 {
|
||||||
|
if err == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
type coder interface {
|
||||||
|
GetErrCode() uint32
|
||||||
|
}
|
||||||
|
cause := errors.Cause(err)
|
||||||
|
if c, ok := cause.(coder); ok {
|
||||||
|
return c.GetErrCode()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isErrCode(err error, code uint32) bool {
|
||||||
|
return errCodeOf(err) == code
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakePromoModel struct{}
|
type fakePromoModel struct {
|
||||||
|
insertRule func(context.Context, *promomodel.Rule) error
|
||||||
|
findRule func(context.Context, int64) (*promomodel.Rule, error)
|
||||||
|
updateRule func(context.Context, *promomodel.Rule) error
|
||||||
|
}
|
||||||
|
|
||||||
func (fakePromoModel) QueryEligibleRules(context.Context, int64, int64) ([]*promomodel.RuleWithPrice, error) {
|
func (fakePromoModel) QueryEligibleRules(context.Context, int64, int64) ([]*promomodel.RuleWithPrice, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -22,15 +26,24 @@ func (fakePromoModel) InsertUsage(context.Context, *promomodel.Usage, ...*gorm.D
|
|||||||
return nil
|
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
|
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
|
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
|
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:"
|
subscribeCachePref = "promo:subscribe:"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
minPromoRuleTime = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
maxPromoRuleTime = time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
)
|
||||||
|
|
||||||
func validateRuleInput(ruleType string, params map[string]interface{}, priority int64, startTime, endTime *int64) error {
|
func validateRuleInput(ruleType string, params map[string]interface{}, priority int64, startTime, endTime *int64) error {
|
||||||
if priority < 0 {
|
if priority < 0 {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "priority must be greater than or equal to 0")
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "priority must be greater than or equal to 0")
|
||||||
}
|
}
|
||||||
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")
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "start_time must be less than end_time")
|
||||||
}
|
}
|
||||||
switch ruleType {
|
switch ruleType {
|
||||||
@@ -93,12 +106,29 @@ func parseParams(data string) map[string]interface{} {
|
|||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
func unixPtrToTimePtr(ts *int64) *time.Time {
|
func normalizeRuleTimestamp(ts *int64) (*time.Time, error) {
|
||||||
if ts == nil || *ts == 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
t := time.Unix(*ts, 0)
|
return t
|
||||||
return &t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func timePtrToUnixPtr(t *time.Time) *int64 {
|
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"`
|
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{}
|
result := &PromoResult{}
|
||||||
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || subscribeID <= 0 || quantity <= 0 {
|
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || subscribeID <= 0 || quantity <= 0 {
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -96,12 +105,13 @@ func evaluatePromoRule(
|
|||||||
rule *promo.RuleWithPrice,
|
rule *promo.RuleWithPrice,
|
||||||
params promoRuleParams,
|
params promoRuleParams,
|
||||||
userID int64,
|
userID int64,
|
||||||
|
isFirstPurchase bool,
|
||||||
currentUser *user.User,
|
currentUser *user.User,
|
||||||
now time.Time,
|
now time.Time,
|
||||||
) (bool, time.Time, error) {
|
) (bool, time.Time, error) {
|
||||||
switch rule.Type {
|
switch rule.Type {
|
||||||
case promo.RuleTypeNewUser:
|
case promo.RuleTypeNewUser:
|
||||||
if userID <= 0 {
|
if userID <= 0 || !isFirstPurchase {
|
||||||
return false, time.Time{}, nil
|
return false, time.Time{}, nil
|
||||||
}
|
}
|
||||||
return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now)
|
return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now)
|
||||||
@@ -165,7 +175,7 @@ func evaluateInactiveUserPromo(
|
|||||||
Take(&lastSub).Error
|
Take(&lastSub).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
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")
|
return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/model/promo"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"gorm.io/gorm"
|
"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 {
|
if err != nil {
|
||||||
t.Fatalf("EvaluatePromo returned error: %v", err)
|
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 {
|
type fakePromoModel struct {
|
||||||
rules []*promo.RuleWithPrice
|
rules []*promo.RuleWithPrice
|
||||||
lastSubscribeID int64
|
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 {
|
func (m *fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error {
|
||||||
return nil
|
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.
|
// routes the request to renewal semantics, where first-purchase promos are disabled.
|
||||||
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
||||||
var existSub user.Subscribe
|
var existSub user.Subscribe
|
||||||
if e := l.svcCtx.DB.WithContext(l.ctx).
|
if e := paidSubscriptionQuery(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID).
|
||||||
Model(&user.Subscribe{}).
|
First(&existSub).Error; e == nil && existSub.Id > 0 {
|
||||||
Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%')", entitlement.EffectiveUserID).
|
|
||||||
Order("expire_time DESC").
|
|
||||||
Order("updated_at DESC").
|
|
||||||
Order("id DESC").
|
|
||||||
First(&existSub).Error; e == nil && existSub.Id > 0 && existSub.Token != "" {
|
|
||||||
orderType = 2
|
orderType = 2
|
||||||
l.Infow("[PreCreateOrder] purchase preview routed to renewal because an existing subscription was found",
|
l.Infow("[PreCreateOrder] purchase preview routed to renewal because an existing subscription was found",
|
||||||
logger.Field("route_mode", "global_single_subscription"),
|
logger.Field("route_mode", "global_single_subscription"),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func calculatePurchasePrice(
|
|||||||
quantity int64,
|
quantity int64,
|
||||||
discounts []types.SubscribeDiscount,
|
discounts []types.SubscribeDiscount,
|
||||||
eligibleForDiscount bool,
|
eligibleForDiscount bool,
|
||||||
allowPromo bool,
|
isFirstPurchase bool,
|
||||||
) (*orderPriceResult, error) {
|
) (*orderPriceResult, error) {
|
||||||
originalPrice := unitPrice * quantity
|
originalPrice := unitPrice * quantity
|
||||||
result := &orderPriceResult{
|
result := &orderPriceResult{
|
||||||
@@ -35,21 +35,19 @@ func calculatePurchasePrice(
|
|||||||
PayableBase: originalPrice,
|
PayableBase: originalPrice,
|
||||||
}
|
}
|
||||||
|
|
||||||
if allowPromo {
|
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity, isFirstPurchase)
|
||||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity)
|
if err != nil {
|
||||||
if err != nil {
|
return nil, err
|
||||||
return nil, err
|
}
|
||||||
}
|
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
|
||||||
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
|
result.PayableBase = promoResult.PromoPrice
|
||||||
result.PayableBase = promoResult.PromoPrice
|
result.PromoRuleId = promoResult.RuleID
|
||||||
result.PromoRuleId = promoResult.RuleID
|
result.PromoDiscount = originalPrice - result.PayableBase
|
||||||
result.PromoDiscount = originalPrice - result.PayableBase
|
result.PromoPrice = promoResult.PromoPrice
|
||||||
result.PromoPrice = promoResult.PromoPrice
|
if result.PromoDiscount < 0 {
|
||||||
if result.PromoDiscount < 0 {
|
result.PromoDiscount = 0
|
||||||
result.PromoDiscount = 0
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
discount := float64(1)
|
discount := float64(1)
|
||||||
|
|||||||
@@ -221,3 +221,79 @@ func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) {
|
|||||||
t.Fatalf("PromoRuleId = %d, want 11", result.PromoRuleId)
|
t.Fatalf("PromoRuleId = %d, want 11", result.PromoRuleId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalculatePurchasePriceCampaignAppliesToReturningUsers(t *testing.T) {
|
||||||
|
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||||
|
{
|
||||||
|
Rule: promo.Rule{
|
||||||
|
Id: 12,
|
||||||
|
Name: "campaign",
|
||||||
|
Type: promo.RuleTypeCampaign,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
PromoPrice: 400,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}
|
||||||
|
|
||||||
|
// isFirstPurchase=false simulates a returning user routed to renewal; the
|
||||||
|
// Campaign promo must still apply because it has no first-purchase gate.
|
||||||
|
result, err := calculatePurchasePrice(
|
||||||
|
context.Background(),
|
||||||
|
svcCtx,
|
||||||
|
42,
|
||||||
|
2,
|
||||||
|
1000,
|
||||||
|
1,
|
||||||
|
nil,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calculatePurchasePrice returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.PromoRuleId != 12 {
|
||||||
|
t.Fatalf("PromoRuleId = %d, want 12 (campaign should apply to returning users)", result.PromoRuleId)
|
||||||
|
}
|
||||||
|
if result.PayableBase != 400 {
|
||||||
|
t.Fatalf("PayableBase = %d, want 400", result.PayableBase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculatePurchasePriceNewUserGatedByFirstPurchase(t *testing.T) {
|
||||||
|
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||||
|
{
|
||||||
|
Rule: promo.Rule{
|
||||||
|
Id: 13,
|
||||||
|
Name: "new user",
|
||||||
|
Type: promo.RuleTypeNewUser,
|
||||||
|
Enabled: true,
|
||||||
|
Params: `{"window_hours": 72}`,
|
||||||
|
},
|
||||||
|
PromoPrice: 200,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}
|
||||||
|
|
||||||
|
// isFirstPurchase=false → NewUser rule must be skipped, regular discount applies.
|
||||||
|
result, err := calculatePurchasePrice(
|
||||||
|
context.Background(),
|
||||||
|
svcCtx,
|
||||||
|
42,
|
||||||
|
2,
|
||||||
|
1000,
|
||||||
|
1,
|
||||||
|
[]types.SubscribeDiscount{{Quantity: 1, Discount: 90}},
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calculatePurchasePrice returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.PromoRuleId != 0 || result.PromoDiscount != 0 {
|
||||||
|
t.Fatalf("promo fields = (%d, %d), want (0, 0) when isFirstPurchase=false", result.PromoRuleId, result.PromoDiscount)
|
||||||
|
}
|
||||||
|
if result.PayableBase != 900 {
|
||||||
|
t.Fatalf("PayableBase = %d, want 900 (regular 90%% discount)", result.PayableBase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,13 +129,8 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
// 防止不同套餐购买创建第二条订阅。
|
// 防止不同套餐购买创建第二条订阅。
|
||||||
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
||||||
var existSub user.Subscribe
|
var existSub user.Subscribe
|
||||||
if e := l.svcCtx.DB.WithContext(l.ctx).
|
if e := paidSubscriptionQuery(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID).
|
||||||
Model(&user.Subscribe{}).
|
First(&existSub).Error; e == nil && existSub.Id > 0 {
|
||||||
Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%')", entitlement.EffectiveUserID).
|
|
||||||
Order("expire_time DESC").
|
|
||||||
Order("updated_at DESC").
|
|
||||||
Order("id DESC").
|
|
||||||
First(&existSub).Error; e == nil && existSub.Id > 0 && existSub.Token != "" {
|
|
||||||
orderType = 2
|
orderType = 2
|
||||||
parentOrderID = existSub.OrderId
|
parentOrderID = existSub.OrderId
|
||||||
subscribeToken = existSub.Token
|
subscribeToken = existSub.Token
|
||||||
|
|||||||
@@ -41,6 +41,22 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
|||||||
}
|
}
|
||||||
|
|
||||||
userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User)
|
userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
|
userID := int64(0)
|
||||||
|
isFirstPurchase := true
|
||||||
|
if userInfo != nil {
|
||||||
|
entitlement, err := commonLogic.ResolveEntitlementUser(ctx, svcCtx.DB, userInfo.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userID = entitlement.EffectiveUserID
|
||||||
|
|
||||||
|
hasPaid, err := commonLogic.HasPaidSubscription(ctx, svcCtx.DB, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
isFirstPurchase = !hasPaid
|
||||||
|
}
|
||||||
|
|
||||||
candidates, err := querySubscribePromoCandidates(ctx, svcCtx, subscribeIDs, userInfo != nil)
|
candidates, err := querySubscribePromoCandidates(ctx, svcCtx, subscribeIDs, userInfo != nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isMissingPromoTableError(err) {
|
if isMissingPromoTableError(err) {
|
||||||
@@ -49,10 +65,6 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := int64(0)
|
|
||||||
if userInfo != nil {
|
|
||||||
userID = userInfo.Id
|
|
||||||
}
|
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if candidate.Quantity <= 0 {
|
if candidate.Quantity <= 0 {
|
||||||
continue
|
continue
|
||||||
@@ -63,7 +75,7 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
|||||||
if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
|
if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, candidate.SubscribeId, candidate.Quantity)
|
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, candidate.SubscribeId, candidate.Quantity, isFirstPurchase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
|
|
||||||
"github.com/DATA-DOG/go-sqlmock"
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
"github.com/perfect-panel/server/internal/model/promo"
|
"github.com/perfect-panel/server/internal/model/promo"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -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 {
|
type fakeSubscribePromoModel struct {
|
||||||
rules []*promo.RuleWithPrice
|
rules []*promo.RuleWithPrice
|
||||||
lastSubscribeID int64
|
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())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query orders failed: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
visibleUserIdSet := make(map[int64]struct{}, len(visibleUserIds))
|
||||||
|
for _, userId := range visibleUserIds {
|
||||||
|
visibleUserIdSet[userId] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
allRecords := make([]types.InviteRecord, 0, len(parsedLogs))
|
allRecords := make([]types.InviteRecord, 0, len(parsedLogs))
|
||||||
for _, parsed := range parsedLogs {
|
for _, parsed := range parsedLogs {
|
||||||
content := parsed.content
|
content := parsed.content
|
||||||
logItem := parsed.log
|
logItem := parsed.log
|
||||||
orderInfo, hasOrder := orders[content.OrderNo]
|
orderInfo, hasOrder := orders[content.OrderNo]
|
||||||
if !l.canViewInviteRecord(u.Id, logItem.ObjectId, hasOrder, orderInfo) {
|
if !l.canViewInviteRecord(u.Id, logItem.ObjectId, visibleUserIdSet, hasOrder, orderInfo) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,17 +182,17 @@ func (l *GetInviteRecordsLogic) resolveInviteRecordVisibleUserIds(currentUserId
|
|||||||
return nil, err
|
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 logObjectId == currentUserId {
|
||||||
if hasOrder {
|
if hasOrder {
|
||||||
return orderInfo.UserId == currentUserId || orderInfo.RefererId == currentUserId
|
return orderInfo.UserId == currentUserId || orderInfo.RefererId == currentUserId
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if !hasOrder {
|
return true
|
||||||
return false
|
|
||||||
}
|
|
||||||
return orderInfo.UserId == currentUserId && orderInfo.SubscriptionUserId == logObjectId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeInviteRecordsPagination(req *types.GetInviteRecordsRequest) {
|
func normalizeInviteRecordsPagination(req *types.GetInviteRecordsRequest) {
|
||||||
|
|||||||
@@ -123,6 +123,33 @@ func TestGetInviteRecordsFamilyMemberSeesOwnerGiftLog(t *testing.T) {
|
|||||||
assertInviteRecordsExpectations(t, mock)
|
assertInviteRecordsExpectations(t, mock)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetInviteRecordsFamilyMemberSeesAllOwnerGiftLogs(t *testing.T) {
|
||||||
|
svcCtx, mock, cleanup := newInviteRecordsTestSvc(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
expectInviteRecordsFamilyMember(t, mock, 51637, 510)
|
||||||
|
mock.ExpectQuery("SELECT id, object_id, content").
|
||||||
|
WithArgs(34, int64(51637), int64(510), "邀请赠送").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}).
|
||||||
|
AddRow(6, 510, `{"order_no":"owner-order","amount":7,"remark":"邀请赠送"}`, 1779934630000))
|
||||||
|
mock.ExpectQuery("SELECT `order`.order_no, `order`.user_id, `order`.subscription_user_id, invitee.referer_id FROM `order`").
|
||||||
|
WithArgs("owner-order").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id", "subscription_user_id", "referer_id"}).AddRow("owner-order", 571, 571, 510))
|
||||||
|
|
||||||
|
resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(51637, 0), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInviteRecords returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertInviteRecordResponse(t, resp, types.InviteRecord{
|
||||||
|
Role: inviteRecordRoleInviter,
|
||||||
|
PeerHash: hash.InvitePeerHash(571),
|
||||||
|
GiftDays: 7,
|
||||||
|
OrderNo: "owner-order",
|
||||||
|
CreatedAt: 1779934630000,
|
||||||
|
})
|
||||||
|
assertInviteRecordsExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetInviteRecordsOwnerDoesNotSeeMemberGiftLog(t *testing.T) {
|
func TestGetInviteRecordsOwnerDoesNotSeeMemberGiftLog(t *testing.T) {
|
||||||
svcCtx, mock, cleanup := newInviteRecordsTestSvc(t)
|
svcCtx, mock, cleanup := newInviteRecordsTestSvc(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -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
|
var data Subscribe
|
||||||
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, _ interface{}) error {
|
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, _ interface{}) error {
|
||||||
return conn.Model(&Subscribe{}).
|
return conn.Model(&Subscribe{}).
|
||||||
Where("user_id = ? AND 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("expire_time DESC").
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Order("id 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 {
|
func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) error {
|
||||||
|
// 终态守卫:OrderStatusClaimed(6) 与 orderStatusRefunded(6) 共用同一枚举值。
|
||||||
|
// 若已存在 333 退款日志,说明此处的 status=6 是「已退款」,不能再降回 5,
|
||||||
|
// 否则下次 activate 会重新激活订阅、且管理员可二次触发退款导致佣金被多次扣减。
|
||||||
|
// 详见 HIF-131 / HIF-132。
|
||||||
|
refunded, err := log.HasRefundCommissionLog(l.svc.DB.WithContext(ctx), orderNo)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithContext(ctx).Error("Check refund log before release claim failed",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("order_no", orderNo),
|
||||||
|
)
|
||||||
|
return fmt.Errorf("check refund log failed for order %s: %w", orderNo, err)
|
||||||
|
}
|
||||||
|
if refunded {
|
||||||
|
logger.WithContext(ctx).Info("Skip release claim for refunded order (status=6 + refund log)",
|
||||||
|
logger.Field("order_no", orderNo),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := l.svc.DB.WithContext(ctx).
|
if err := l.svc.DB.WithContext(ctx).
|
||||||
Model(&order.Order{}).
|
Model(&order.Order{}).
|
||||||
Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed).
|
Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed).
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
|
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/order"
|
"github.com/perfect-panel/server/internal/model/order"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@@ -54,6 +55,25 @@ func (l *StuckOrderRecoveryLogic) ProcessTask(ctx context.Context, _ *asynq.Task
|
|||||||
for i := range stuckOrders {
|
for i := range stuckOrders {
|
||||||
o := &stuckOrders[i]
|
o := &stuckOrders[i]
|
||||||
|
|
||||||
|
// 终态守卫:OrderStatusClaimed(6) 与 orderStatusRefunded(6) 共用同一枚举值,
|
||||||
|
// 若该订单已写入 333 退款佣金日志,说明状态 6 表示「已退款」而非「短暂 claim」,
|
||||||
|
// 必须跳过,否则会把已退款订单重置为 5 + 重新入队 activate,导致重复退款。
|
||||||
|
// 详见 HIF-131 / HIF-132。
|
||||||
|
refunded, err := logmodel.HasRefundCommissionLog(l.svc.DB.WithContext(ctx), o.OrderNo)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithContext(ctx).Error("[StuckOrderRecovery] Failed to check refund log",
|
||||||
|
logger.Field("order_no", o.OrderNo),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if refunded {
|
||||||
|
logger.WithContext(ctx).Info("[StuckOrderRecovery] Skip refunded order (status=6 + refund log)",
|
||||||
|
logger.Field("order_no", o.OrderNo),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
result := l.svc.DB.WithContext(ctx).
|
result := l.svc.DB.WithContext(ctx).
|
||||||
Model(&order.Order{}).
|
Model(&order.Order{}).
|
||||||
Where("order_no = ? AND status = ?", o.OrderNo, OrderStatusClaimed).
|
Where("order_no = ? AND status = ?", o.OrderNo, OrderStatusClaimed).
|
||||||
|
|||||||
Reference in New Issue
Block a user