// 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/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 是生产环境的家庭组归位实现。语义与 // 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"` } // 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 }