diff --git a/initialize/migrate/database/02157_lottery_grant_ledger.down.sql b/initialize/migrate/database/02157_lottery_grant_ledger.down.sql new file mode 100644 index 0000000..49b0331 --- /dev/null +++ b/initialize/migrate/database/02157_lottery_grant_ledger.down.sql @@ -0,0 +1,2 @@ +-- 02157 抽奖发奖账本回滚 +DROP TABLE IF EXISTS `lottery_grant_ledger`; diff --git a/initialize/migrate/database/02157_lottery_grant_ledger.up.sql b/initialize/migrate/database/02157_lottery_grant_ledger.up.sql new file mode 100644 index 0000000..134de5e --- /dev/null +++ b/initialize/migrate/database/02157_lottery_grant_ledger.up.sql @@ -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)'; diff --git a/internal/logic/lottery/handler/commission.go b/internal/logic/lottery/handler/commission.go new file mode 100644 index 0000000..b23c476 --- /dev/null +++ b/internal/logic/lottery/handler/commission.go @@ -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 +} diff --git a/internal/logic/lottery/handler/commission_test.go b/internal/logic/lottery/handler/commission_test.go new file mode 100644 index 0000000..02842c3 --- /dev/null +++ b/internal/logic/lottery/handler/commission_test.go @@ -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") + } +} diff --git a/internal/logic/lottery/handler/testhelpers_test.go b/internal/logic/lottery/handler/testhelpers_test.go new file mode 100644 index 0000000..e85ba40 --- /dev/null +++ b/internal/logic/lottery/handler/testhelpers_test.go @@ -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) +} diff --git a/internal/logic/lottery/handler/vpn_duration.go b/internal/logic/lottery/handler/vpn_duration.go new file mode 100644 index 0000000..f76d928 --- /dev/null +++ b/internal/logic/lottery/handler/vpn_duration.go @@ -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 +} diff --git a/internal/logic/lottery/handler/vpn_duration_test.go b/internal/logic/lottery/handler/vpn_duration_test.go new file mode 100644 index 0000000..0aeb667 --- /dev/null +++ b/internal/logic/lottery/handler/vpn_duration_test.go @@ -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 +} diff --git a/internal/logic/lottery/hook/invite.go b/internal/logic/lottery/hook/invite.go new file mode 100644 index 0000000..978aa1a --- /dev/null +++ b/internal/logic/lottery/hook/invite.go @@ -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 +} diff --git a/internal/logic/lottery/hook/invite_test.go b/internal/logic/lottery/hook/invite_test.go new file mode 100644 index 0000000..3eb0380 --- /dev/null +++ b/internal/logic/lottery/hook/invite_test.go @@ -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) + } +} diff --git a/internal/model/log/log.go b/internal/model/log/log.go index 0573d2c..de82f1b 100644 --- a/internal/model/log/log.go +++ b/internal/model/log/log.go @@ -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 ) diff --git a/internal/model/lottery/grant_ledger.go b/internal/model/lottery/grant_ledger.go new file mode 100644 index 0000000..96ed6e5 --- /dev/null +++ b/internal/model/lottery/grant_ledger.go @@ -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 +} diff --git a/internal/model/lottery/grant_ledger_test.go b/internal/model/lottery/grant_ledger_test.go new file mode 100644 index 0000000..48ee40d --- /dev/null +++ b/internal/model/lottery/grant_ledger_test.go @@ -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") + } +} diff --git a/internal/model/lottery/registry.go b/internal/model/lottery/registry.go index c5ebf80..f7664ca 100644 --- a/internal/model/lottery/registry.go +++ b/internal/model/lottery/registry.go @@ -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 } diff --git a/internal/model/lottery/registry_test.go b/internal/model/lottery/registry_test.go index 6fc300c..aa5f70c 100644 --- a/internal/model/lottery/registry_test.go +++ b/internal/model/lottery/registry_test.go @@ -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) - } - } -} diff --git a/internal/model/lottery/service.go b/internal/model/lottery/service.go index fcacf2e..94eaf43 100644 --- a/internal/model/lottery/service.go +++ b/internal/model/lottery/service.go @@ -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 是发奖结果。 diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index 168b626..fcf0e36 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -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 { diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 6bc4005..ae19355 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -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 { diff --git a/queue/logic/order/invite_hook_gate_test.go b/queue/logic/order/invite_hook_gate_test.go new file mode 100644 index 0000000..fa87212 --- /dev/null +++ b/queue/logic/order/invite_hook_gate_test.go @@ -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. +}