新功能(#3): 抽奖 Stage 1 handler 真实业务对接 + 邀请钩子

Closes HIF-3 (阶段 PR B)

PR B:handler 真实业务对接 + 邀请钩子(迭代含 R1 修复)

- 迁移 02157_lottery_grant_ledger:external_ref UNIQUE 作为发奖幂等键
- log.CommissionTypeLottery=339(架构师批准的新常量)
- DispatchRequest.IdempotencyKey(架构师 review 建议第 2 条)
- GrantLedger + LedgerService.Reserve:INSERT ON CONFLICT DO NOTHING 幂等 upsert
- VPNDurationHandler:ResolveEffectiveUser 归位家庭 owner + UpdateSubscribe,ExpireTime 三分支对齐 grantGiftDays
- CommissionHandler:UpdateCommission + WriteCommissionLog(339),发给中奖者本人,不做家庭组归位
- Handler 从 model 层迁到 logic 层(避免 model → logic 反向依赖);noop 保留在 model 层
- InviteHook:fire-and-forget 独立 goroutine + 10s timeout,扫 running 活动的 invite_success 源
- ServiceContext 新增 LotteryChance / LotteryLedger / LotteryInviteHook
- activateOrderLogic.handleCommission 两条分支通过 invokeInviteHookIfEligible 助手触发,助手内统一 gate IsNew(架构师 R1 打回后的修复)

架构师 R1 打回:branch B 未按"首次付款激活"gate,续费也会给 referer 发抽奖机会 → 已通过助手函数集中收拢,避免 branch A/B 判断漂移。

测试覆盖率:model 76.5% / handler 73.2% / hook 91.7%;补 4 个回归测试覆盖 IsNew 门槛(含关键的 DoesNotFireOnRenewal)。

线上仍零可见变更:无对外路由,钩子仅在活动 status=running 时生效,Stage 1 全流程无活动记录时 loadRunningActivities 返回空。
This commit is contained in:
2026-07-08 21:19:28 -07:00
committed by GitHub
parent 9933d34bdd
commit a46fb83054
18 changed files with 1585 additions and 61 deletions
@@ -0,0 +1,127 @@
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
logmodel "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/lottery"
"gorm.io/gorm"
)
// CommissionHandler 发放"抽奖佣金"。写 user.commission 增量 + system_logs
// (Type=Commission, CommissionType=339 Lottery) —— 用新增的 CommissionTypeLottery
// 常量与 Purchase/Renewal 区分,账目侧对账更清晰。
//
// 幂等模型:DispatchRequest.IdempotencyKey → lottery_grant_ledger.external_ref。
// Reserve 命中即幂等,未命中才走真实发放。commission 直接发给中奖者本人,不做
// 家庭组归位(family owner 不代收成员的抽奖佣金)。
type CommissionHandler struct {
deps CommissionDeps
}
// CommissionDeps 是 CommissionHandler 需要的最小依赖集。抽出到接口方便测试。
type CommissionDeps struct {
Ledger lottery.LedgerService
// UpdateCommission 对齐 UserModel.UpdateCommission 签名。
UpdateCommission func(ctx context.Context, userId int64, delta int64, tx ...*gorm.DB) error
// WriteCommissionLog 对齐 common.WriteCommissionLog 签名。
WriteCommissionLog func(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error
}
// NewCommissionHandler 构造真实的 commission handler。
func NewCommissionHandler(deps CommissionDeps) *CommissionHandler {
return &CommissionHandler{deps: deps}
}
func (*CommissionHandler) Type() string { return lottery.PrizeTypeCommission }
func (*CommissionHandler) IsAuto() bool { return true }
func (*CommissionHandler) ValidateClaim([]byte) error { return nil }
type commissionConfig struct {
// AmountCents 是"分"级别的金额(与 user.commission 存储单位对齐)。
AmountCents int64 `json:"amount_cents"`
}
type commissionPayload struct {
Amount int64 `json:"amount"`
LogType uint16 `json:"log_type"`
Message string `json:"message"`
OrderNo string `json:"order_no"`
}
// Dispatch 在 caller 的事务内为中奖人发放佣金。
func (h *CommissionHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
if tx == nil {
return lottery.DispatchResult{}, errors.New("commission handler requires a transaction")
}
if req.IdempotencyKey == "" {
return lottery.DispatchResult{}, errors.New("commission handler requires DispatchRequest.IdempotencyKey")
}
if h.deps.UpdateCommission == nil || h.deps.WriteCommissionLog == nil {
return lottery.DispatchResult{}, errors.New("commission handler deps not fully wired")
}
var cfg commissionConfig
if err := json.Unmarshal([]byte(req.Prize.Config), &cfg); err != nil {
return lottery.DispatchResult{}, fmt.Errorf("decode commission config: %w", err)
}
if cfg.AmountCents <= 0 {
return lottery.DispatchResult{}, fmt.Errorf("commission config amount_cents must be > 0, got %d", cfg.AmountCents)
}
// 佣金"发给中奖者本人"(不走家庭组归位)。
targetUserID := req.UserId
entry := lottery.GrantLedger{
ExternalRef: req.IdempotencyKey,
HandlerType: lottery.PrizeTypeCommission,
UserId: targetUserID,
ActivityId: req.ActivityId,
DrawId: req.DrawId,
Amount: cfg.AmountCents,
}
row, alreadyExisted, err := h.deps.Ledger.Reserve(ctx, tx, entry)
if err != nil {
return lottery.DispatchResult{}, fmt.Errorf("reserve grant ledger: %w", err)
}
if alreadyExisted {
var payload commissionPayload
if row.Payload != "" {
_ = json.Unmarshal([]byte(row.Payload), &payload)
}
if payload.Message == "" {
payload.Message = "佣金已到账"
}
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
}
// 未存在 → 真实发放。UpdateCommission 用 gorm.Expr 原子累加,避免丢更新。
if err := h.deps.UpdateCommission(ctx, targetUserID, cfg.AmountCents, tx); err != nil {
return lottery.DispatchResult{}, fmt.Errorf("update commission for user %d: %w", targetUserID, err)
}
// 传 external_ref 到 WriteCommissionLog 的 orderNo 位("lottery:*"),与业务 order 命名域天然区分。
if err := h.deps.WriteCommissionLog(tx, targetUserID, logmodel.CommissionTypeLottery, cfg.AmountCents, req.IdempotencyKey); err != nil {
return lottery.DispatchResult{}, fmt.Errorf("write commission log: %w", err)
}
payload := commissionPayload{
Amount: cfg.AmountCents,
LogType: logmodel.CommissionTypeLottery,
OrderNo: req.IdempotencyKey,
Message: fmt.Sprintf("佣金已到账 %d", cfg.AmountCents),
}
raw, err := json.Marshal(payload)
if err != nil {
return lottery.DispatchResult{}, fmt.Errorf("marshal ledger payload: %w", err)
}
if err := tx.WithContext(ctx).
Model(&lottery.GrantLedger{}).
Where("id = ?", row.Id).
UpdateColumn("payload", string(raw)).Error; err != nil {
return lottery.DispatchResult{}, fmt.Errorf("update ledger payload: %w", err)
}
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
}
@@ -0,0 +1,185 @@
package handler
import (
"context"
"strings"
"testing"
logmodel "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/lottery"
"gorm.io/gorm"
)
func TestCommission_RequiresIdempotencyKey(t *testing.T) {
db, _, cleanup := newHandlerTestDB(t)
defer cleanup()
h := NewCommissionHandler(CommissionDeps{
Ledger: &fakeLedger{},
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error {
t.Fatal("must NOT call UpdateCommission without idempotency key")
return nil
},
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
})
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{})
if err == nil || !strings.Contains(err.Error(), "IdempotencyKey") {
t.Fatalf("expected IdempotencyKey error, got %v", err)
}
}
func TestCommission_RequiresTx(t *testing.T) {
h := NewCommissionHandler(CommissionDeps{Ledger: &fakeLedger{}})
_, err := h.Dispatch(context.Background(), nil, lottery.DispatchRequest{IdempotencyKey: "k"})
if err == nil || !strings.Contains(err.Error(), "transaction") {
t.Fatalf("expected tx error, got %v", err)
}
}
func TestCommission_IdempotentHitDoesNotWrite(t *testing.T) {
db, _, cleanup := newHandlerTestDB(t)
defer cleanup()
ledger := &fakeLedger{
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{
Payload: `{"message":"佣金已到账 300"}`,
}, true, nil
},
}
h := NewCommissionHandler(CommissionDeps{
Ledger: ledger,
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error {
t.Fatal("must NOT UpdateCommission on idempotent hit")
return nil
},
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error {
t.Fatal("must NOT WriteCommissionLog on idempotent hit")
return nil
},
})
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
UserId: 42,
Prize: lottery.Prize{Config: `{"amount_cents":300}`},
IdempotencyKey: "lottery:100:200",
})
if err != nil {
t.Fatalf("Dispatch: %v", err)
}
if res.Message != "佣金已到账 300" {
t.Fatalf("expected replay message, got %q", res.Message)
}
}
func TestCommission_FirstTimeWritesCommissionAndLog(t *testing.T) {
db, mock, cleanup := newHandlerTestDB(t)
defer cleanup()
ledger := &fakeLedger{
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{Id: 11}, false, nil
},
}
var (
updateCommissionCalled = false
writeLogCalled = false
writeLogType uint16
writeLogAmount int64
writeLogOrderNo string
)
h := NewCommissionHandler(CommissionDeps{
Ledger: ledger,
UpdateCommission: func(_ context.Context, uid, amount int64, _ ...*gorm.DB) error {
updateCommissionCalled = true
if uid != 42 || amount != 300 {
t.Fatalf("UpdateCommission got (uid=%d, amount=%d)", uid, amount)
}
return nil
},
WriteCommissionLog: func(_ *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error {
writeLogCalled = true
writeLogType = logType
writeLogAmount = amount
writeLogOrderNo = orderNo
if objectID != 42 {
t.Fatalf("WriteCommissionLog objectID=%d, want 42", objectID)
}
return nil
},
})
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
WillReturnResult(sqlmockAnyResult())
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
UserId: 42,
ActivityId: 100,
DrawId: 200,
Prize: lottery.Prize{Config: `{"amount_cents":300}`},
IdempotencyKey: "lottery:100:200",
})
if err != nil {
t.Fatalf("Dispatch: %v", err)
}
if !updateCommissionCalled || !writeLogCalled {
t.Fatalf("expected both UpdateCommission and WriteCommissionLog to be called (uc=%v wl=%v)", updateCommissionCalled, writeLogCalled)
}
if writeLogType != logmodel.CommissionTypeLottery {
t.Fatalf("expected CommissionTypeLottery(%d), got %d", logmodel.CommissionTypeLottery, writeLogType)
}
if writeLogAmount != 300 {
t.Fatalf("expected amount 300, got %d", writeLogAmount)
}
if writeLogOrderNo != "lottery:100:200" {
t.Fatalf("expected orderNo to reuse ExternalRef, got %q", writeLogOrderNo)
}
}
func TestCommission_BadConfigRejected(t *testing.T) {
db, _, cleanup := newHandlerTestDB(t)
defer cleanup()
h := NewCommissionHandler(CommissionDeps{
Ledger: &fakeLedger{},
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error { return nil },
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
})
tests := []struct {
name string
config string
}{
{name: "invalid json", config: `{bad`},
{name: "zero amount", config: `{"amount_cents":0}`},
{name: "negative amount", config: `{"amount_cents":-1}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
Prize: lottery.Prize{Config: tt.config},
IdempotencyKey: "k",
})
if err == nil {
t.Fatalf("expected error on %s", tt.name)
}
})
}
}
func TestCommission_MissingDepsFailsFast(t *testing.T) {
db, _, cleanup := newHandlerTestDB(t)
defer cleanup()
h := NewCommissionHandler(CommissionDeps{
Ledger: &fakeLedger{},
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
})
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
IdempotencyKey: "k",
Prize: lottery.Prize{Config: `{"amount_cents":1}`},
})
if err == nil {
t.Fatalf("expected error when deps missing")
}
}
@@ -0,0 +1,12 @@
package handler
import (
"database/sql/driver"
"github.com/DATA-DOG/go-sqlmock"
)
// sqlmockAnyResult 是 sqlmock.NewResult 的简写,语义与它一致(0 影响行)。
func sqlmockAnyResult() driver.Result {
return sqlmock.NewResult(0, 1)
}
@@ -0,0 +1,226 @@
// Package handler contains real PrizeHandler implementations for lottery prize
// dispatch. Handlers live in the logic layer because they depend on UserModel,
// NodeModel, and commonLogic — importing those from the pure-model
// internal/model/lottery package would flip the layering.
//
// All Dispatch entry points are called inside the draw service's transaction
// and must remain tx-only: no cache invalidation, no goroutine fan-out. The
// draw service is responsible for post-commit side effects (node cache clear,
// user group recalculation) once the enclosing transaction commits.
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/lottery"
usermodel "github.com/perfect-panel/server/internal/model/user"
"gorm.io/gorm"
)
// VPNDurationHandler 发放"N 天订阅时长"。
//
// 幂等模型:DispatchRequest.IdempotencyKey → lottery_grant_ledger.external_ref。
// Reserve 命中即幂等,返回 payload 里之前记录的 message;未命中才走真实发放。
//
// 家庭组:走 ResolveEffectiveUser 归位到 owner;若 owner 无活跃订阅,日志
// "skipped" 并返回 auto_claimed(与 grantGiftDays 的行为一致,避免中奖后无处发
// 的场景导致抽奖事务回滚吞事件)。
type VPNDurationHandler struct {
deps VPNDurationDeps
}
// VPNDurationDeps 是 VPNDurationHandler 需要的依赖。用 struct 显式收拢,避免
// 直接依赖庞大的 ServiceContext;测试时可注入实现了同接口的 mock。
type VPNDurationDeps struct {
UserModel usermodel.Model
Ledger lottery.LedgerService
DB *gorm.DB
// ResolveEffectiveUser 用于家庭组归位。为 nil 时不做归位(等价于身份函数)。
// 生产接线用 DefaultResolveEffectiveUser(DB) 包出闭包。
ResolveEffectiveUser func(ctx context.Context, userID int64) (int64, error)
}
// DefaultResolveEffectiveUser 是生产环境的家庭组归位实现:走
// commonLogic.ResolveEntitlementUser。用 struct 依赖注入避免 handler 直接依赖
// 具体 SQL —— 测试里 stub 掉。
func DefaultResolveEffectiveUser(db *gorm.DB) func(ctx context.Context, userID int64) (int64, error) {
return func(ctx context.Context, userID int64) (int64, error) {
entitlement, err := commonLogic.ResolveEntitlementUser(ctx, db, userID)
if err != nil {
return 0, err
}
if entitlement == nil || entitlement.EffectiveUserID <= 0 {
return userID, nil
}
return entitlement.EffectiveUserID, nil
}
}
// NewVPNDurationHandler 构造真实的 vpn_duration handler。
func NewVPNDurationHandler(deps VPNDurationDeps) *VPNDurationHandler {
return &VPNDurationHandler{deps: deps}
}
// Type / IsAuto / ValidateClaim 实现 PrizeHandler 接口。
func (*VPNDurationHandler) Type() string { return lottery.PrizeTypeVPNDuration }
func (*VPNDurationHandler) IsAuto() bool { return true }
func (*VPNDurationHandler) ValidateClaim([]byte) error { return nil }
// vpnDurationConfig 是奖品 Config JSON 的解码目标。
type vpnDurationConfig struct {
DurationDays int `json:"duration_days"`
}
// vpnDurationPayload 落库到 lottery_grant_ledger.payload,用于幂等重放时返回同一
// message;同时便于对账(哪条 user_subscribe 被延长、延长了多少天)。
type vpnDurationPayload struct {
EffectiveUserID int64 `json:"effective_user_id"`
SubscribeID int64 `json:"subscribe_id"`
Days int `json:"days"`
Message string `json:"message"`
}
// Dispatch 在 caller 的事务内发放订阅时长。
func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
if tx == nil {
return lottery.DispatchResult{}, errors.New("vpn_duration handler requires a transaction")
}
if req.IdempotencyKey == "" {
return lottery.DispatchResult{}, errors.New("vpn_duration handler requires DispatchRequest.IdempotencyKey")
}
var cfg vpnDurationConfig
if err := json.Unmarshal([]byte(req.Prize.Config), &cfg); err != nil {
return lottery.DispatchResult{}, fmt.Errorf("decode vpn_duration config: %w", err)
}
if cfg.DurationDays <= 0 {
return lottery.DispatchResult{}, fmt.Errorf("vpn_duration config duration_days must be > 0, got %d", cfg.DurationDays)
}
// 家庭组归位:注入的 ResolveEffectiveUser 决定是否穿透到 owner。
effectiveUserID := req.UserId
if h.deps.ResolveEffectiveUser != nil {
if eid, err := h.deps.ResolveEffectiveUser(ctx, req.UserId); err == nil && eid > 0 {
effectiveUserID = eid
}
}
entry := lottery.GrantLedger{
ExternalRef: req.IdempotencyKey,
HandlerType: lottery.PrizeTypeVPNDuration,
UserId: effectiveUserID,
ActivityId: req.ActivityId,
DrawId: req.DrawId,
Amount: int64(cfg.DurationDays),
}
row, alreadyExisted, err := h.deps.Ledger.Reserve(ctx, tx, entry)
if err != nil {
return lottery.DispatchResult{}, fmt.Errorf("reserve grant ledger: %w", err)
}
if alreadyExisted {
// 幂等命中:直接返回之前记录的 payload.message。
var payload vpnDurationPayload
if row.Payload != "" {
_ = json.Unmarshal([]byte(row.Payload), &payload)
}
if payload.Message == "" {
payload.Message = "已加到订阅"
}
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
}
// 未存在 → 真实发放。查用户的活跃订阅。
activeSub, findErr := h.findActiveSubscribe(ctx, effectiveUserID)
if errors.Is(findErr, gorm.ErrRecordNotFound) {
// 与 grantGiftDays 一致:无活跃订阅时记录 skipped 但不失败。
payload := vpnDurationPayload{
EffectiveUserID: effectiveUserID,
Days: cfg.DurationDays,
Message: "跳过:用户无活跃订阅",
}
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
return lottery.DispatchResult{}, err
}
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
}
if findErr != nil {
return lottery.DispatchResult{}, fmt.Errorf("find active subscribe for user %d: %w", effectiveUserID, findErr)
}
// 计算新 ExpireTime。样板见 activateOrderLogic.go:1336-1342
// a) NoLimit 永久(time.UnixMilli(0))→ 不延长
// b) 已过期 → 从 now 起加
// c) 未过期 → 从 ExpireTime 起加
now := time.Now()
if !activeSub.ExpireTime.Equal(time.UnixMilli(0)) {
if activeSub.ExpireTime.Before(now) {
activeSub.ExpireTime = now.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour)
} else {
activeSub.ExpireTime = activeSub.ExpireTime.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour)
}
}
activeSub.Status = 1
activeSub.FinishedAt = nil
if err := h.deps.UserModel.UpdateSubscribe(ctx, activeSub, tx); err != nil {
return lottery.DispatchResult{}, fmt.Errorf("update subscribe %d: %w", activeSub.Id, err)
}
payload := vpnDurationPayload{
EffectiveUserID: effectiveUserID,
SubscribeID: activeSub.Id,
Days: cfg.DurationDays,
Message: fmt.Sprintf("已加 %d 天到订阅", cfg.DurationDays),
}
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
return lottery.DispatchResult{}, err
}
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
}
// findActiveSubscribe 优先走 UserModel.FindActiveSubscribe;未找到则回退到
// 最新 token 非空的历史订阅(样板 activateOrderLogic.go:1371-1393)。
func (h *VPNDurationHandler) findActiveSubscribe(ctx context.Context, userID int64) (*usermodel.Subscribe, error) {
activeSub, err := h.deps.UserModel.FindActiveSubscribe(ctx, userID)
if err == nil {
return activeSub, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if h.deps.DB == nil {
return nil, gorm.ErrRecordNotFound
}
var fallback usermodel.Subscribe
fallbackErr := h.deps.DB.WithContext(ctx).
Model(&usermodel.Subscribe{}).
Where("user_id = ? AND token != ''", userID).
Where("status IN ?", []int64{0, 1, 2, 3}).
Order("expire_time DESC").
Order("updated_at DESC").
Order("id DESC").
First(&fallback).Error
if fallbackErr != nil {
return nil, fallbackErr
}
return &fallback, nil
}
func (h *VPNDurationHandler) writeBackPayload(ctx context.Context, tx *gorm.DB, ledgerID int64, payload vpnDurationPayload) error {
raw, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal ledger payload: %w", err)
}
if err := tx.WithContext(ctx).
Model(&lottery.GrantLedger{}).
Where("id = ?", ledgerID).
UpdateColumn("payload", string(raw)).Error; err != nil {
return fmt.Errorf("update ledger payload: %w", err)
}
return nil
}
@@ -0,0 +1,345 @@
package handler
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/perfect-panel/server/internal/model/lottery"
usermodel "github.com/perfect-panel/server/internal/model/user"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// newHandlerTestDB 建一个 sqlmock 支撑的 gorm.DB,子测试直接把它当 tx 传给 handler。
func newHandlerTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
if strings.Contains(actual, expected) {
return nil
}
return errors.New("actual sql does not contain expected: " + expected)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{
SkipDefaultTransaction: true,
})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() { _ = sqlDB.Close() }
}
// fakeLedger 让 handler 单测不依赖真实 SQL,只验证控制流。
type fakeLedger struct {
reserveFn func(ctx context.Context, tx *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error)
}
func (f *fakeLedger) Reserve(ctx context.Context, tx *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return f.reserveFn(ctx, tx, entry)
}
// fakeUserModel 满足 usermodel.Model 里 handler 用到的两个方法。
type fakeUserModel struct {
usermodel.Model
findActive func(ctx context.Context, userID int64) (*usermodel.Subscribe, error)
updateSubscribe func(ctx context.Context, sub *usermodel.Subscribe, tx ...*gorm.DB) error
}
func (f *fakeUserModel) FindActiveSubscribe(ctx context.Context, userID int64) (*usermodel.Subscribe, error) {
return f.findActive(ctx, userID)
}
func (f *fakeUserModel) UpdateSubscribe(ctx context.Context, sub *usermodel.Subscribe, tx ...*gorm.DB) error {
return f.updateSubscribe(ctx, sub, tx...)
}
// identityResolver 单测里的家庭组归位:始终返回自身。
func identityResolver(_ context.Context, userID int64) (int64, error) { return userID, nil }
func TestVPNDuration_RequiresIdempotencyKey(t *testing.T) {
db, _, cleanup := newHandlerTestDB(t)
defer cleanup()
h := NewVPNDurationHandler(VPNDurationDeps{
Ledger: &fakeLedger{},
DB: db,
ResolveEffectiveUser: identityResolver,
})
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{})
if err == nil || !strings.Contains(err.Error(), "IdempotencyKey") {
t.Fatalf("expected IdempotencyKey error, got %v", err)
}
}
func TestVPNDuration_RequiresTx(t *testing.T) {
h := NewVPNDurationHandler(VPNDurationDeps{Ledger: &fakeLedger{}})
_, err := h.Dispatch(context.Background(), nil, lottery.DispatchRequest{IdempotencyKey: "k"})
if err == nil || !strings.Contains(err.Error(), "transaction") {
t.Fatalf("expected tx error, got %v", err)
}
}
func TestVPNDuration_IdempotentHitReturnsStoredMessage(t *testing.T) {
db, _, cleanup := newHandlerTestDB(t)
defer cleanup()
stored := lottery.GrantLedger{
Id: 9,
ExternalRef: "lottery:100:200",
Payload: `{"message":"已加 3 天到订阅"}`,
}
ledger := &fakeLedger{
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &stored, true, nil
},
}
fake := &fakeUserModel{
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
t.Fatal("must NOT touch UserModel on idempotent hit")
return nil, nil
},
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
t.Fatal("must NOT touch UserModel on idempotent hit")
return nil
},
}
h := NewVPNDurationHandler(VPNDurationDeps{
Ledger: ledger,
UserModel: fake,
DB: db,
ResolveEffectiveUser: identityResolver,
})
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
UserId: 42,
ActivityId: 100,
DrawId: 200,
Prize: lottery.Prize{Config: `{"duration_days":3}`},
IdempotencyKey: "lottery:100:200",
})
if err != nil {
t.Fatalf("Dispatch: %v", err)
}
if res.State != lottery.DispatchStateAutoClaimed {
t.Fatalf("expected auto_claimed, got %q", res.State)
}
if res.Message != "已加 3 天到订阅" {
t.Fatalf("expected stored message, got %q", res.Message)
}
}
func TestVPNDuration_NoActiveSubscribeSkipsWithoutError(t *testing.T) {
db, mock, cleanup := newHandlerTestDB(t)
defer cleanup()
ledger := &fakeLedger{
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
},
}
fake := &fakeUserModel{
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
return nil, gorm.ErrRecordNotFound
},
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
t.Fatal("must NOT UpdateSubscribe when no active sub")
return nil
},
}
// fallback query returns no rows either
mock.ExpectQuery("FROM `user_subscribe`").
WillReturnError(gorm.ErrRecordNotFound)
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewVPNDurationHandler(VPNDurationDeps{
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
})
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
UserId: 42,
ActivityId: 100,
DrawId: 200,
Prize: lottery.Prize{Config: `{"duration_days":3}`},
IdempotencyKey: "lottery:100:200",
})
if err != nil {
t.Fatalf("Dispatch: %v", err)
}
if res.State != lottery.DispatchStateAutoClaimed {
t.Fatalf("expected auto_claimed even on skip, got %q", res.State)
}
if !strings.Contains(res.Message, "跳过") {
t.Fatalf("expected skip message, got %q", res.Message)
}
}
func TestVPNDuration_ExtendsExistingExpireTime(t *testing.T) {
db, mock, cleanup := newHandlerTestDB(t)
defer cleanup()
future := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second)
activeSub := &usermodel.Subscribe{
Id: 77,
UserId: 42,
ExpireTime: future,
Status: 1,
}
ledger := &fakeLedger{
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{Id: 5, ExternalRef: "lottery:100:200"}, false, nil
},
}
updateCalled := false
fake := &fakeUserModel{
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
updateCalled = true
expected := future.Add(3 * 24 * time.Hour)
if !sub.ExpireTime.Equal(expected) {
t.Fatalf("expire time not stacked: got %s want %s", sub.ExpireTime, expected)
}
if sub.Status != 1 {
t.Fatalf("expected Status=1 after grant, got %d", sub.Status)
}
return nil
},
}
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewVPNDurationHandler(VPNDurationDeps{
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
})
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
UserId: 42,
ActivityId: 100,
DrawId: 200,
Prize: lottery.Prize{Config: `{"duration_days":3}`},
IdempotencyKey: "lottery:100:200",
})
if err != nil {
t.Fatalf("Dispatch: %v", err)
}
if !updateCalled {
t.Fatalf("expected UpdateSubscribe to be called")
}
if res.Message != "已加 3 天到订阅" {
t.Fatalf("unexpected message: %q", res.Message)
}
}
func TestVPNDuration_ExpiredSubscribeRestartsFromNow(t *testing.T) {
db, mock, cleanup := newHandlerTestDB(t)
defer cleanup()
past := time.Now().Add(-24 * time.Hour)
activeSub := &usermodel.Subscribe{
Id: 77,
ExpireTime: past,
}
ledger := &fakeLedger{
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{Id: 5}, false, nil
},
}
fake := &fakeUserModel{
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
delta := time.Until(sub.ExpireTime)
if delta < 3*24*time.Hour-5*time.Second || delta > 3*24*time.Hour+5*time.Second {
t.Fatalf("expected ~3 days from now, got %v", delta)
}
return nil
},
}
mock.ExpectExec("UPDATE `lottery_grant_ledger`").WillReturnResult(sqlmock.NewResult(0, 1))
h := NewVPNDurationHandler(VPNDurationDeps{
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
})
_, _ = h.Dispatch(context.Background(), db, lottery.DispatchRequest{
Prize: lottery.Prize{Config: `{"duration_days":3}`},
IdempotencyKey: "k",
})
}
func TestVPNDuration_NoLimitNotExtended(t *testing.T) {
db, mock, cleanup := newHandlerTestDB(t)
defer cleanup()
noLimit := time.UnixMilli(0)
activeSub := &usermodel.Subscribe{
Id: 77,
ExpireTime: noLimit,
Status: 1,
}
ledger := &fakeLedger{
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{Id: 5}, false, nil
},
}
fake := &fakeUserModel{
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
if !sub.ExpireTime.Equal(noLimit) {
t.Fatalf("no-limit ExpireTime must not be extended, got %v", sub.ExpireTime)
}
return nil
},
}
mock.ExpectExec("UPDATE `lottery_grant_ledger`").WillReturnResult(sqlmock.NewResult(0, 1))
h := NewVPNDurationHandler(VPNDurationDeps{
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
})
_, _ = h.Dispatch(context.Background(), db, lottery.DispatchRequest{
Prize: lottery.Prize{Config: `{"duration_days":3}`},
IdempotencyKey: "k",
})
}
func TestVPNDuration_BadConfigRejected(t *testing.T) {
db, _, cleanup := newHandlerTestDB(t)
defer cleanup()
h := NewVPNDurationHandler(VPNDurationDeps{
Ledger: &fakeLedger{},
DB: db,
ResolveEffectiveUser: identityResolver,
})
tests := []struct {
name string
config string
}{
{name: "invalid json", config: `{bad`},
{name: "zero days", config: `{"duration_days":0}`},
{name: "negative days", config: `{"duration_days":-1}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
Prize: lottery.Prize{Config: tt.config},
IdempotencyKey: "k",
})
if err == nil {
t.Fatalf("expected error on %s", tt.name)
}
})
}
_ = json.Unmarshal
}