Files
hi-server/internal/logic/lottery/handler/vpn_duration.go
T
shanshanzhong147 a46fb83054 新功能(#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 返回空。
2026-07-08 21:19:28 -07:00

227 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}