新功能(#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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Package hook contains lottery-side outbound integrations — hooks other flows
|
||||
// (order activation, sign-in, etc.) call after they succeed to feed events into
|
||||
// the lottery system.
|
||||
//
|
||||
// All hooks are fire-and-forget by contract: they run in their own goroutine so
|
||||
// caller latency and error handling are unaffected. Hook failures are logged
|
||||
// and dropped — an invite that fails to earn a lottery chance never blocks the
|
||||
// order it was piggy-backing on.
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// InviteHook is fired by order/renewal activation when an invited user
|
||||
// completes a payment. It grants lottery chances to the referer across every
|
||||
// currently running activity that declares an "invite_success" chance source.
|
||||
type InviteHook interface {
|
||||
// OnConversion queues a background grant for referer. Returns immediately.
|
||||
// Safe to call with refererUserID=0 (no-op) or orderNo="" (no-op).
|
||||
OnConversion(ctx context.Context, refererUserID int64, orderNo string)
|
||||
}
|
||||
|
||||
// NoopInviteHook is a safe placeholder for callers that need an InviteHook
|
||||
// value before the lottery system is wired in. Its OnConversion returns
|
||||
// immediately without side effects — no goroutine, no log spam.
|
||||
func NoopInviteHook() InviteHook { return noopInviteHook{} }
|
||||
|
||||
type noopInviteHook struct{}
|
||||
|
||||
func (noopInviteHook) OnConversion(_ context.Context, _ int64, _ string) {}
|
||||
|
||||
// defaultInviteHook is the production implementation. It queries running
|
||||
// activities on every call rather than caching them — the query is cheap
|
||||
// (small table, indexed by status+time), and skipping the cache avoids stale
|
||||
// reads when an activity is paused or its chance_sources are re-configured.
|
||||
type defaultInviteHook struct {
|
||||
db *gorm.DB
|
||||
chance lottery.ChanceService
|
||||
}
|
||||
|
||||
// NewInviteHook builds the production invite hook.
|
||||
func NewInviteHook(db *gorm.DB, chance lottery.ChanceService) InviteHook {
|
||||
if db == nil || chance == nil {
|
||||
return NoopInviteHook()
|
||||
}
|
||||
return &defaultInviteHook{db: db, chance: chance}
|
||||
}
|
||||
|
||||
// OnConversion spawns a fire-and-forget goroutine that walks all running
|
||||
// activities and calls ChanceService.Grant for each one that declares an
|
||||
// invite_success source.
|
||||
func (h *defaultInviteHook) OnConversion(_ context.Context, refererUserID int64, orderNo string) {
|
||||
if refererUserID <= 0 || orderNo == "" {
|
||||
return
|
||||
}
|
||||
go h.run(refererUserID, orderNo)
|
||||
}
|
||||
|
||||
func (h *defaultInviteHook) run(refererUserID int64, orderNo string) {
|
||||
// Fresh context so the caller cancelling their goroutine does not abort us.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
activities, err := h.loadRunningActivities(ctx)
|
||||
if err != nil {
|
||||
logger.WithContext(ctx).Error("[lottery invite hook] load running activities failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("referer_user_id", refererUserID),
|
||||
logger.Field("order_no", orderNo),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range activities {
|
||||
activity := &activities[i]
|
||||
grants := parseInviteGrantsFromSources(activity.ChanceSources)
|
||||
for _, amount := range grants {
|
||||
if amount <= 0 {
|
||||
continue
|
||||
}
|
||||
// ChanceService.Grant is idempotent per (activity_id, source, source_ref).
|
||||
// orderNo as source_ref makes both new-purchase and renewal safe: same
|
||||
// order → same key → at most one chance grant per (activity, order).
|
||||
if err := h.chance.Grant(ctx, refererUserID, activity.Id, lottery.ChanceSourceInviteSuccess, orderNo, amount); err != nil {
|
||||
logger.WithContext(ctx).Error("[lottery invite hook] Grant failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("referer_user_id", refererUserID),
|
||||
logger.Field("activity_id", activity.Id),
|
||||
logger.Field("order_no", orderNo),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *defaultInviteHook) loadRunningActivities(ctx context.Context) ([]lottery.Activity, error) {
|
||||
now := time.Now()
|
||||
var activities []lottery.Activity
|
||||
if err := h.db.WithContext(ctx).
|
||||
Model(&lottery.Activity{}).
|
||||
Where("status = ?", lottery.ActivityStatusRunning).
|
||||
Where("start_at <= ? AND end_at >= ?", now, now).
|
||||
Find(&activities).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// parseInviteGrantsFromSources decodes the JSON chance_sources array on an
|
||||
// activity and returns the per-conversion grant amount for each invite_success
|
||||
// source (an activity may declare multiple, e.g. with different params by
|
||||
// referer tier — v1 does not, but the loop is a cheap forward-compatibility).
|
||||
func parseInviteGrantsFromSources(raw string) []int {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var sources []lottery.ChanceSource
|
||||
if err := json.Unmarshal([]byte(raw), &sources); err != nil {
|
||||
// Malformed configs skip silently — the activity is misconfigured, not
|
||||
// a hook fault. Admin CRUD (PR C) will surface it.
|
||||
return nil
|
||||
}
|
||||
out := make([]int, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
if s.Source == lottery.ChanceSourceInviteSuccess && s.Amount > 0 {
|
||||
out = append(out, s.Amount)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newHookTestDB(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() }
|
||||
}
|
||||
|
||||
type chanceCall struct {
|
||||
userId, activityId int64
|
||||
source, sourceRef string
|
||||
amount int
|
||||
}
|
||||
|
||||
type fakeChanceService struct {
|
||||
mu sync.Mutex
|
||||
calls []chanceCall
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeChanceService) Grant(_ context.Context, userId, activityId int64, source, sourceRef string, amount int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, chanceCall{userId, activityId, source, sourceRef, amount})
|
||||
return f.err
|
||||
}
|
||||
func (*fakeChanceService) Consume(context.Context, *gorm.DB, int64, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (*fakeChanceService) Query(context.Context, int64, int64) (int64, error) { return 0, nil }
|
||||
|
||||
func (f *fakeChanceService) recorded() []chanceCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]chanceCall, len(f.calls))
|
||||
copy(out, f.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNoopInviteHook_IsInert(t *testing.T) {
|
||||
NoopInviteHook().OnConversion(context.Background(), 1, "ord")
|
||||
}
|
||||
|
||||
func TestInviteHook_SkipsWhenRefererMissing(t *testing.T) {
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(&gorm.DB{}, chance) // won't touch DB because refererUserID=0
|
||||
h.OnConversion(context.Background(), 0, "ord")
|
||||
// No goroutine means no calls; give scheduler a beat and confirm empty.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if len(chance.recorded()) != 0 {
|
||||
t.Fatalf("expected no Grant when refererUserID=0, got %+v", chance.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_GrantsForEachRunningActivity(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Activity 100: single invite_success source, amount=1
|
||||
// Activity 200: two sources, only invite_success (amount=2) counts
|
||||
// Activity 300: has invite_success amount=0 → skipped
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}).
|
||||
AddRow(int64(100), `[{"source":"invite_success","amount":1}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(200), `[{"source":"daily_signin","amount":1},{"source":"invite_success","amount":2}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(300), `[{"source":"invite_success","amount":0}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "order-xyz")
|
||||
|
||||
// give the goroutine time to complete
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(chance.recorded()) < 2 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for grants; got %+v", chance.recorded())
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
calls := chance.recorded()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 grants (100 amount=1, 200 amount=2), got %+v", calls)
|
||||
}
|
||||
byActivity := map[int64]int{}
|
||||
for _, c := range calls {
|
||||
if c.source != lottery.ChanceSourceInviteSuccess {
|
||||
t.Fatalf("unexpected source: %+v", c)
|
||||
}
|
||||
if c.sourceRef != "order-xyz" {
|
||||
t.Fatalf("expected orderNo reused as source_ref, got %q", c.sourceRef)
|
||||
}
|
||||
if c.userId != 42 {
|
||||
t.Fatalf("expected referer=42, got %d", c.userId)
|
||||
}
|
||||
byActivity[c.activityId] = c.amount
|
||||
}
|
||||
if byActivity[100] != 1 || byActivity[200] != 2 {
|
||||
t.Fatalf("wrong amounts: %+v", byActivity)
|
||||
}
|
||||
if _, exists := byActivity[300]; exists {
|
||||
t.Fatalf("activity 300 has invite_success amount=0 and must be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_MalformedChanceSourcesSkipsOnly(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}).
|
||||
AddRow(int64(100), `[{"source":"invite_success","amount":3}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(200), `{bad-json`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "order-1")
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(chance.recorded()) < 1 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out; got %+v", chance.recorded())
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// Only the well-formed activity should have been granted; malformed skipped silently.
|
||||
calls := chance.recorded()
|
||||
if len(calls) != 1 || calls[0].activityId != 100 {
|
||||
t.Fatalf("expected exactly 1 grant for activity 100, got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_QueryFailureLogsAndReturns(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnError(errors.New("db down"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "ord")
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if len(chance.recorded()) != 0 {
|
||||
t.Fatalf("expected no grants when query fails, got %+v", chance.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_ParseHelperExposesInviteAmountsOnly(t *testing.T) {
|
||||
got := parseInviteGrantsFromSources(`[{"source":"invite_success","amount":5},{"source":"daily_signin","amount":9},{"source":"invite_success","amount":0}]`)
|
||||
if len(got) != 1 || got[0] != 5 {
|
||||
t.Fatalf("expected [5], got %v", got)
|
||||
}
|
||||
if got := parseInviteGrantsFromSources(""); got != nil {
|
||||
t.Fatalf("empty string should return nil, got %v", got)
|
||||
}
|
||||
if got := parseInviteGrantsFromSources(`{bad`); got != nil {
|
||||
t.Fatalf("bad json should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user