新功能(#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,2 @@
-- 02157 抽奖发奖账本回滚
DROP TABLE IF EXISTS `lottery_grant_ledger`;
@@ -0,0 +1,25 @@
-- 02157 抽奖发奖账本(PR B
--
-- 目的:以 external_ref 作为 DB 层唯一键,做每个 draw 的发奖幂等。
-- 各 PrizeHandler.Dispatch 内先 SELECT/INSERT lottery_grant_ledger,命中即幂等返回,
-- 未命中再调下游发放(UpdateSubscribe / UpdateCommission + WriteCommissionLog),
-- 全部在同一 tx 内完成 → 抽奖事务与发奖账本同生共死。
--
-- 关键唯一索引:external_ref。惯例值 = "lottery:{activity_id}:{draw_id}"。
-- handler_type:与 lottery_prize.type 一致(vpn_duration / commission / …),用于统计。
CREATE TABLE IF NOT EXISTS `lottery_grant_ledger` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`external_ref` VARCHAR(128) NOT NULL COMMENT '幂等键:lottery:{activity_id}:{draw_id}',
`handler_type` VARCHAR(32) NOT NULL COMMENT 'handler 类型,与 lottery_prize.type 对齐',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID(家庭组已归位到 owner)',
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
`amount` BIGINT NOT NULL DEFAULT 0 COMMENT '发放数量(天/佣金金额,单位与 handler 一致)',
`payload` JSON COMMENT '发放后的关键结果快照(订阅 ID、佣金前后余额等)',
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发放完成时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_external_ref` (`external_ref`),
KEY `idx_user_activity` (`user_id`, `activity_id`),
KEY `idx_draw_id` (`draw_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖发奖账本(幂等键 = external_ref';
@@ -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
}
+137
View File
@@ -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
}
+191
View File
@@ -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)
}
}
+1
View File
@@ -52,6 +52,7 @@ const (
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund
CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金
CommissionTypeLottery uint16 = 339 // 抽奖奖励(PR B: 与 Purchase/Renewal 区分,便于对账)
GiftTypeIncrease uint16 = 341 // Increase
GiftTypeReduce uint16 = 342 // Reduce
)
+71
View File
@@ -0,0 +1,71 @@
package lottery
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// GrantLedger 是发奖账本一行。UNIQUE(external_ref) 是幂等键的载体:
// 每次 PrizeHandler.Dispatch 用 DispatchRequest.IdempotencyKey 作 external_ref
// INSERT 冲突即"已发过",直接返回持久化的原结果。
type GrantLedger struct {
Id int64 `gorm:"primaryKey"`
ExternalRef string `gorm:"type:varchar(128);not null;uniqueIndex:uk_external_ref;comment:幂等键"`
HandlerType string `gorm:"type:varchar(32);not null;comment:handler 类型"`
UserId int64 `gorm:"type:bigint unsigned;not null;comment:发放对象用户 ID"`
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
DrawId int64 `gorm:"type:bigint unsigned;not null;comment:抽奖记录 ID"`
Amount int64 `gorm:"type:bigint;not null;default:0;comment:发放数量"`
Payload string `gorm:"type:json;comment:发放后的关键结果快照"`
GrantedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:发放完成时间"`
}
// TableName 对齐 02157 migration。
func (GrantLedger) TableName() string { return "lottery_grant_ledger" }
// LedgerService 处理发奖账本的幂等 upsert。所有 handler 的第一步都是它。
type LedgerService interface {
// Reserve 尝试为 external_ref 抢占一行账本。
// - 未冲突 → 返回新建行,caller 继续调用下游业务;提交事务时账本一起落。
// - 冲突 → 返回已存在的账本行,caller 视为幂等命中直接返回。
// 传入 tx 必须是 caller 的事务句柄,保证账本行随抽奖事务一起提交。
Reserve(ctx context.Context, tx *gorm.DB, entry GrantLedger) (row *GrantLedger, alreadyExisted bool, err error)
}
type ledgerService struct{}
// NewLedgerService 返回默认账本服务。
func NewLedgerService() LedgerService { return &ledgerService{} }
// Reserve 用 INSERT ... ON CONFLICT DO NOTHING 抢占 external_ref。
// 未命中时再走一次 SELECT 拿到实际持久化的行(不管是新插的还是旧的),
// 目的是让 caller 拿到统一的 GrantLedger 结构,方便回写 draw 状态。
func (s *ledgerService) Reserve(ctx context.Context, tx *gorm.DB, entry GrantLedger) (*GrantLedger, bool, error) {
if tx == nil {
return nil, false, errors.New("Reserve requires a transaction handle")
}
if entry.ExternalRef == "" {
return nil, false, errors.New("Reserve requires a non-empty ExternalRef")
}
insertRes := tx.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
Create(&entry)
if insertRes.Error != nil {
return nil, false, insertRes.Error
}
alreadyExisted := insertRes.RowsAffected == 0
// 读回持久化的行,避免依赖 gorm 的 AutoIncrement 回填在冲突分支不确定的行为。
var stored GrantLedger
if err := tx.WithContext(ctx).
Where("external_ref = ?", entry.ExternalRef).
First(&stored).Error; err != nil {
return nil, alreadyExisted, err
}
return &stored, alreadyExisted, nil
}
+112
View File
@@ -0,0 +1,112 @@
package lottery
import (
"context"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/gorm"
)
func TestLedgerService_Reserve_FirstInsertNotExisted(t *testing.T) {
db, mock, cleanup := newLotteryTestDB(t)
defer cleanup()
svc := NewLedgerService()
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
WillReturnResult(sqlmock.NewResult(7, 1))
mock.ExpectQuery("FROM `lottery_grant_ledger`").
WithArgs("lottery:100:200", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
AddRow(int64(7), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
mock.ExpectCommit()
err := db.Transaction(func(tx *gorm.DB) error {
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
ExternalRef: "lottery:100:200",
HandlerType: "vpn_duration",
UserId: 42,
ActivityId: 100,
DrawId: 200,
Amount: 3,
})
if e != nil {
return e
}
if existed {
t.Fatalf("expected not existed")
}
if row.Id != 7 {
t.Fatalf("expected reloaded id=7, got %d", row.Id)
}
return nil
})
if err != nil {
t.Fatalf("tx: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("expectations: %v", err)
}
}
func TestLedgerService_Reserve_DuplicateExisted(t *testing.T) {
db, mock, cleanup := newLotteryTestDB(t)
defer cleanup()
svc := NewLedgerService()
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
WillReturnResult(sqlmock.NewResult(0, 0)) // conflict, 0 rows affected
mock.ExpectQuery("FROM `lottery_grant_ledger`").
WithArgs("lottery:100:200", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
AddRow(int64(9), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
mock.ExpectCommit()
err := db.Transaction(func(tx *gorm.DB) error {
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
ExternalRef: "lottery:100:200",
HandlerType: "vpn_duration",
})
if e != nil {
return e
}
if !existed {
t.Fatalf("expected existed=true when INSERT returns 0 rows affected")
}
if row.Id != 9 {
t.Fatalf("expected stored id=9, got %d", row.Id)
}
return nil
})
if err != nil {
t.Fatalf("tx: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("expectations: %v", err)
}
}
func TestLedgerService_Reserve_RequiresTx(t *testing.T) {
svc := NewLedgerService()
if _, _, err := svc.Reserve(context.Background(), nil, GrantLedger{ExternalRef: "x"}); err == nil {
t.Fatalf("expected error when tx is nil")
}
}
func TestLedgerService_Reserve_RequiresExternalRef(t *testing.T) {
db, _, cleanup := newLotteryTestDB(t)
defer cleanup()
svc := NewLedgerService()
err := db.Transaction(func(tx *gorm.DB) error {
_, _, e := svc.Reserve(context.Background(), tx, GrantLedger{})
return e
})
if err == nil {
t.Fatalf("expected error on empty ExternalRef")
}
}
+3 -42
View File
@@ -14,6 +14,8 @@ type defaultRegistry struct {
}
// NewRegistry 返回空的默认注册表。使用 Register 挂接实现。
// 实际业务 handlervpn_duration / commission)在 internal/logic/lottery/handler 里,
// 由 initialize 阶段注入(避免 model 层反向依赖 logic 层)。
func NewRegistry() *defaultRegistry {
return &defaultRegistry{handlers: make(map[string]PrizeHandler, 8)}
}
@@ -39,11 +41,7 @@ func (r *defaultRegistry) MustGet(prizeType string) (PrizeHandler, error) {
return nil, ErrHandlerNotRegistered
}
// ---- Stage 1 骨架 handler --------------------------------------------------
// 真实业务对接在架构师 review 骨架 PR 之后 Day 2-3 补齐。此时命中真实
// handler 会返回 ErrNotImplemented,让抽奖流程整笔事务回滚,避免误发。
// noopHandler 是"谢谢参与"。写记录、不发放,不需要真实业务对接。
// noopHandler 是"谢谢参与",永远归 model 层:不依赖任何业务,最小占位。
type noopHandler struct{}
// NewNoopHandler 返回 PrizeTypeNone 的 handler。
@@ -55,40 +53,3 @@ func (noopHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (D
return DispatchResult{State: DispatchStateAutoClaimed, Message: "谢谢参与"}, nil
}
func (noopHandler) ValidateClaim(_ []byte) error { return nil }
// vpnDurationStubHandler 是订阅时长发放的占位 handler。
// Day 2-3 需替换为真实实现:
// 1. tx 内 SELECT ... FOR UPDATE 锁 lottery_grant_ledger UNIQUE(external_ref)
// 2. 命中 → 幂等返回;未命中 → INSERT ledger + 调用 UserModel.UpdateSubscribe/InsertSubscribe
// 3. external_ref 用 fmt.Sprintf("lottery:%d:%d", req.ActivityId, req.DrawId)
// 4. 家庭组场景通过 commonLogic.ResolveEntitlementUser 归位到 owner
// 5. 成功后 best-effort 调 NodeModel.ClearServerAllCache + 用户组重算
type vpnDurationStubHandler struct{}
// NewVPNDurationStubHandler 返回 PrizeTypeVPNDuration 的骨架 handler。
func NewVPNDurationStubHandler() PrizeHandler { return &vpnDurationStubHandler{} }
func (vpnDurationStubHandler) Type() string { return PrizeTypeVPNDuration }
func (vpnDurationStubHandler) IsAuto() bool { return true }
func (vpnDurationStubHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (DispatchResult, error) {
return DispatchResult{}, ErrNotImplemented
}
func (vpnDurationStubHandler) ValidateClaim(_ []byte) error { return nil }
// commissionStubHandler 是佣金入账的占位 handler。
// Day 2-3 需替换为真实实现:
// 1. tx 内 lottery_grant_ledger 幂等检查
// 2. 调 UserModel.UpdateCommission + common.WriteCommissionLog(同 tx
// 3. 建议新增 log.CommissionTypeLottery 常量(数值待架构师批),
// 避免把抽奖入账混进 Purchase/Renewal 类型统计
type commissionStubHandler struct{}
// NewCommissionStubHandler 返回 PrizeTypeCommission 的骨架 handler。
func NewCommissionStubHandler() PrizeHandler { return &commissionStubHandler{} }
func (commissionStubHandler) Type() string { return PrizeTypeCommission }
func (commissionStubHandler) IsAuto() bool { return true }
func (commissionStubHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (DispatchResult, error) {
return DispatchResult{}, ErrNotImplemented
}
func (commissionStubHandler) ValidateClaim(_ []byte) error { return nil }
+6 -19
View File
@@ -21,17 +21,13 @@ func TestRegistry_GetMissing(t *testing.T) {
func TestRegistry_RegisterAndGet(t *testing.T) {
r := NewRegistry()
r.Register(NewNoopHandler())
r.Register(NewVPNDurationStubHandler())
r.Register(NewCommissionStubHandler())
for _, want := range []string{PrizeTypeNone, PrizeTypeVPNDuration, PrizeTypeCommission} {
h, err := r.MustGet(want)
if err != nil {
t.Fatalf("MustGet(%q): %v", want, err)
}
if h.Type() != want {
t.Fatalf("Type mismatch: want %q got %q", want, h.Type())
}
h, err := r.MustGet(PrizeTypeNone)
if err != nil {
t.Fatalf("MustGet(%q): %v", PrizeTypeNone, err)
}
if h.Type() != PrizeTypeNone {
t.Fatalf("Type mismatch: want %q got %q", PrizeTypeNone, h.Type())
}
}
@@ -48,12 +44,3 @@ func TestNoopHandler_AlwaysAutoClaimed(t *testing.T) {
t.Fatalf("noop should dispatch as auto_claimed, got %q", res.State)
}
}
func TestStubHandlers_ReturnNotImplemented(t *testing.T) {
for _, h := range []PrizeHandler{NewVPNDurationStubHandler(), NewCommissionStubHandler()} {
_, err := h.Dispatch(context.Background(), (*gorm.DB)(nil), DispatchRequest{})
if !errors.Is(err, ErrNotImplemented) {
t.Fatalf("%s: expected ErrNotImplemented, got %v", h.Type(), err)
}
}
}
+4
View File
@@ -79,6 +79,10 @@ type DispatchRequest struct {
DrawId int64
Prize Prize // 当前奖品(含 Config JSON
Snapshot PrizeSnapshot // 抽奖时刻快照
// IdempotencyKey 是 lottery_grant_ledger.external_ref 的最终值。
// 调用方(抽奖服务)负责生成,惯例 = fmt.Sprintf("lottery:%d:%d", ActivityId, DrawId)。
// 每次 Dispatch 用同一 IdempotencyKey 重试 → handler 命中 ledger UNIQUE 返回原结果。
IdempotencyKey string
}
// DispatchResult 是发奖结果。
+12
View File
@@ -11,6 +11,7 @@ import (
"github.com/perfect-panel/server/pkg/storage"
"github.com/perfect-panel/server/internal/config"
lotteryhook "github.com/perfect-panel/server/internal/logic/lottery/hook"
"github.com/perfect-panel/server/internal/model/ads"
"github.com/perfect-panel/server/internal/model/announcement"
"github.com/perfect-panel/server/internal/model/auth"
@@ -19,6 +20,7 @@ import (
iapapple "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/internal/model/log"
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
"github.com/perfect-panel/server/internal/model/lottery"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/payment"
"github.com/perfect-panel/server/internal/model/promo"
@@ -71,6 +73,11 @@ type ServiceContext struct {
AnnouncementModel announcement.Model
IAPAppleTransactionModel iapapple.Model
// Lottery (Stage 1)
LotteryChance lottery.ChanceService
LotteryLedger lottery.LedgerService
LotteryInviteHook lotteryhook.InviteHook
Restart func() error
TelegramBot *tgbotapi.BotAPI
NodeMultiplierManager *nodeMultiplier.Manager
@@ -148,6 +155,11 @@ func NewServiceContext(c config.Config) *ServiceContext {
}
srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds)
srv.DeviceManager = NewDeviceManager(srv)
// Lottery Stage 1: wire the always-safe pieces (ChanceService, LedgerService,
// InviteHook). PrizeHandler registry is created in the draw service (PR C).
srv.LotteryChance = lottery.NewChanceService(db)
srv.LotteryLedger = lottery.NewLedgerService()
srv.LotteryInviteHook = lotteryhook.NewInviteHook(db, srv.LotteryChance)
if c.S3.Enable {
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
if err != nil {
+26
View File
@@ -1127,6 +1127,11 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
// 普通用户路径(佣金比例=0):只有首单才双方赠N天
if orderInfo.IsNew {
l.grantGiftDaysToBothParties(ctx, userInfo, orderInfo)
// 抽奖钩子(Stage 1):首单成功即算邀请转化,即便本笔无佣金。
// referer 通过 userInfo.RefererId 定位,orderNo 作幂等键。
if userInfo != nil {
l.invokeInviteHookIfEligible(ctx, userInfo.RefererId, orderInfo)
}
}
return
}
@@ -1214,6 +1219,11 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
)
}
// 抽奖钩子(Stage 1):仅新购激活时触发(IsNew)。续费走留存路径不再发放
// 机会,避免注册后弃号刷奖的黑产。branch B 内不再显式判 IsNew,全部通过
// invokeInviteHookIfEligible 收拢,语义唯一入口。
l.invokeInviteHookIfEligible(ctx, referer.Id, orderInfo)
// 有佣金路径:邀请人拿佣金,被邀请用户(首单)拿天数
if orderInfo.IsNew {
giftTarget := l.resolveGiftTargetUser(ctx, userInfo, orderInfo.SubscriptionUserId)
@@ -1223,6 +1233,22 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
}
}
// invokeInviteHookIfEligible 是抽奖邀请钩子的唯一入口。所有 handleCommission
// 分支都通过它触发,避免"这里加了 IsNew 判断,那里忘了"的漂移。
//
// 门槛:仅在 orderInfo.IsNew=true(新购首次激活)时触发 —— 这是架构师锁定
// 的"首次付款激活"决策。续费是留存事件而非转化事件,不发放抽奖机会(否则
// 会催生注册后弃号刷奖的黑产)。
func (l *ActivateOrderLogic) invokeInviteHookIfEligible(ctx context.Context, refererID int64, orderInfo *order.Order) {
if l.svc == nil || l.svc.LotteryInviteHook == nil || orderInfo == nil {
return
}
if !orderInfo.IsNew {
return
}
l.svc.LotteryInviteHook.OnConversion(ctx, refererID, orderInfo.OrderNo)
}
func (l *ActivateOrderLogic) grantGiftDaysToBothParties(ctx context.Context, referee *user.User, orderInfo *order.Order) {
giftDays := l.svc.Config.Invite.GiftDays
if giftDays <= 0 || referee == nil || referee.Id == 0 || referee.RefererId == 0 || orderInfo == nil {
+100
View File
@@ -0,0 +1,100 @@
package orderLogic
import (
"context"
"sync"
"testing"
lotteryhook "github.com/perfect-panel/server/internal/logic/lottery/hook"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/svc"
)
// recordingInviteHook 记录每次 OnConversion 调用,让测试直接断言时序与参数。
type recordingInviteHook struct {
mu sync.Mutex
calls []recordingInviteCall
}
type recordingInviteCall struct {
refererID int64
orderNo string
}
func (h *recordingInviteHook) OnConversion(_ context.Context, refererID int64, orderNo string) {
h.mu.Lock()
defer h.mu.Unlock()
h.calls = append(h.calls, recordingInviteCall{refererID: refererID, orderNo: orderNo})
}
// verify recordingInviteHook satisfies the interface at compile time.
var _ lotteryhook.InviteHook = (*recordingInviteHook)(nil)
func (h *recordingInviteHook) snapshot() []recordingInviteCall {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]recordingInviteCall, len(h.calls))
copy(out, h.calls)
return out
}
func newLotteryHookTestLogic(hook lotteryhook.InviteHook) *ActivateOrderLogic {
return NewActivateOrderLogic(&svc.ServiceContext{LotteryInviteHook: hook})
}
// TestInvokeInviteHook_FiresOnFirstPurchase 保证新购激活会触发抽奖钩子。
// Positive-path regression matched against the "首次付款激活" decision.
func TestInvokeInviteHook_FiresOnFirstPurchase(t *testing.T) {
hook := &recordingInviteHook{}
logic := newLotteryHookTestLogic(hook)
logic.invokeInviteHookIfEligible(context.Background(), 42, &order.Order{
OrderNo: "ORD-NEW-1",
IsNew: true,
})
calls := hook.snapshot()
if len(calls) != 1 {
t.Fatalf("expected exactly 1 OnConversion call on first purchase, got %+v", calls)
}
if calls[0].refererID != 42 || calls[0].orderNo != "ORD-NEW-1" {
t.Fatalf("wrong args: %+v", calls[0])
}
}
// TestInvokeInviteHook_DoesNotFireOnRenewal 是架构师 R1 打回的关键回归:
// 续费付款必须 NOT 触发抽奖钩子,否则 referer 每月拿一次白嫖机会,与"首次
// 付款激活"决策相悖。
func TestInvokeInviteHook_DoesNotFireOnRenewal(t *testing.T) {
hook := &recordingInviteHook{}
logic := newLotteryHookTestLogic(hook)
logic.invokeInviteHookIfEligible(context.Background(), 42, &order.Order{
OrderNo: "ORD-RENEWAL-1",
IsNew: false,
})
if calls := hook.snapshot(); len(calls) != 0 {
t.Fatalf("renewal must NOT trigger invite hook; got %+v", calls)
}
}
// TestInvokeInviteHook_NilOrderIsSafe 保证空 order 输入不 panic(防御性)。
func TestInvokeInviteHook_NilOrderIsSafe(t *testing.T) {
hook := &recordingInviteHook{}
logic := newLotteryHookTestLogic(hook)
logic.invokeInviteHookIfEligible(context.Background(), 42, nil)
if calls := hook.snapshot(); len(calls) != 0 {
t.Fatalf("nil order must be a no-op; got %+v", calls)
}
}
// TestInvokeInviteHook_NilHookIsSafe 保证 ServiceContext 里没接线时不 panic。
func TestInvokeInviteHook_NilHookIsSafe(t *testing.T) {
logic := NewActivateOrderLogic(&svc.ServiceContext{}) // no LotteryInviteHook
logic.invokeInviteHookIfEligible(context.Background(), 42, &order.Order{
OrderNo: "ORD-1",
IsNew: true,
})
// If we got here without panicking, we pass.
}