新功能(#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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ type defaultRegistry struct {
|
||||
}
|
||||
|
||||
// NewRegistry 返回空的默认注册表。使用 Register 挂接实现。
|
||||
// 实际业务 handler(vpn_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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 是发奖结果。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
Reference in New Issue
Block a user