// 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" "github.com/google/uuid" "github.com/perfect-panel/server/internal/model/lottery" subscribemodel "github.com/perfect-panel/server/internal/model/subscribe" usermodel "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/pkg/uuidx" "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 是生产环境的家庭组归位实现。语义与 // internal/logic/common.ResolveEntitlementUser 一致(活跃家庭成员 → owner), // 但直接在 handler 包内做 JOIN 查询以避免 internal/svc → internal/logic/common // 的 import cycle(common 包里有别的文件反向 import 了 svc)。 func DefaultResolveEffectiveUser(db *gorm.DB) func(ctx context.Context, userID int64) (int64, error) { return func(ctx context.Context, userID int64) (int64, error) { if userID <= 0 { return userID, nil } var row struct { OwnerUserID int64 `gorm:"column:owner_user_id"` } q := db.WithContext(ctx). Table("user_family_member"). Select("user_family.owner_user_id AS owner_user_id"). Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL"). Where("user_family_member.user_id = ? AND user_family_member.deleted_at IS NULL AND user_family_member.status = ?", userID, usermodel.FamilyMemberActive). Order("user_family_member.role"). Limit(1). Scan(&row) if q.Error != nil { return 0, q.Error } if q.RowsAffected == 0 || row.OwnerUserID <= 0 { return userID, nil } return row.OwnerUserID, nil } } // NewVPNDurationHandler 构造真实的 vpn_duration handler。 func NewVPNDurationHandler(deps VPNDurationDeps) *VPNDurationHandler { return &VPNDurationHandler{deps: deps} } // Type / IsAuto / ValidateClaim / ClaimSchema 实现 PrizeHandler 接口。 func (*VPNDurationHandler) Type() string { return lottery.PrizeTypeVPNDuration } func (*VPNDurationHandler) IsAuto() bool { return true } func (*VPNDurationHandler) ValidateClaim([]byte) error { return nil } func (*VPNDurationHandler) ClaimSchema() json.RawMessage { return nil } // vpnDurationConfig 是奖品 Config JSON 的解码目标。 type vpnDurationConfig struct { DurationDays int `json:"duration_days"` // SubscribeId 指定“无活跃订阅时新建订阅”所用的套餐计划 ID。 // 0 表示不新建:延续历史行为(无活跃订阅则记录 skipped 不发放)。 SubscribeId int64 `json:"subscribe_id"` } // 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) { // 无活跃订阅: // - 若奖品配置了 subscribe_id,则按该套餐新建一条订阅并发放时长; // - 否则延续旧行为:记录 skipped 但不失败(避免抽奖事务因无处发放而回滚)。 if cfg.SubscribeId > 0 { newSub, createErr := h.createSubscription(ctx, tx, effectiveUserID, req.DrawId, cfg) if createErr != nil { return lottery.DispatchResult{}, fmt.Errorf("auto-create subscribe for user %d: %w", effectiveUserID, createErr) } payload := vpnDurationPayload{ EffectiveUserID: effectiveUserID, SubscribeID: newSub.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 } 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 } // createSubscription 在无活跃订阅时,按奖品配置的 subscribe_id 套餐为用户新建一条 // 订阅,时长为 cfg.DurationDays 天。套餐属性(流量、节点组)继承自计划,token/uuid // 现场生成。整个操作在 caller 的事务内完成,随抽奖事务一起提交/回滚。 func (h *VPNDurationHandler) createSubscription(ctx context.Context, tx *gorm.DB, userID, drawID int64, cfg vpnDurationConfig) (*usermodel.Subscribe, error) { if tx == nil { return nil, errors.New("createSubscription requires a transaction") } var plan subscribemodel.Subscribe if err := tx.WithContext(ctx).Where("id = ?", cfg.SubscribeId).First(&plan).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("subscribe plan %d not found", cfg.SubscribeId) } return nil, err } now := time.Now() // token 需全局唯一:用 lottery draw 维度做种子,避免与订单 token 冲突。 tokenSeed := fmt.Sprintf("lottery:%d:%d:%d", cfg.SubscribeId, userID, drawID) newSub := &usermodel.Subscribe{ UserId: userID, OrderId: 0, SubscribeId: plan.Id, NodeGroupId: plan.NodeGroupId, StartTime: now, ExpireTime: now.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour), Traffic: plan.Traffic, Token: uuidx.SubscribeToken(tokenSeed), UUID: uuid.New().String(), Status: 1, } if err := tx.WithContext(ctx).Create(newSub).Error; err != nil { return nil, err } return newSub, 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 }