Files
hi-server/internal/logic/lottery/handler/vpn_duration.go
T
shanshanzhong147 07409eb602 新功能(#4): 抽奖 Stage 2 人工奖领奖工单(crypto / physical / manual_other)
Closes HIF-4

Stage 2 交付:人工奖领奖工单完整闭环。crypto / physical / manual_other 三类奖品从抽中到 mark-paid 的全流程可用。

- 迁移 02159_lottery_claim:UNIQUE(draw_id) + 3 支持索引,状态机 pending_claim→reviewing→paying→paid,rejected 可复活,超时 expired
- 3 个 PrizeHandler:Dispatch→ErrDispatchNotSupported 兜底、ClaimSchema 各自形态、ValidateClaim 表驱动
- BuildCryptoClaimSchema:抽中时按奖品 config.networks 注入 enum,前端下拉直接可用
- Draw service dispatchOrEnqueueClaim:人工奖同 tx 插 pending_claim(回滚双清),nonce 重放回读 ExpiresAt + ClaimFormSchema
- POST /claim 实装:ownership 校验 → prize 类型校验 → handler.ValidateClaim → crypto network 白名单二次校验 → tx CAS status IN (pending_claim, rejected) AND expires_at > now
- Admin CRUD 5 接口:list(IN 批拉 snap + user,无 N+1)、summary(GROUP BY 一次拿计数 + overdue 单查)、approve/reject/mark-paid 全走 CAS + audit
- Scheduler @every 1h 扫过期,级联 lottery_draw.dispatch_state → expired
- 新增错误码 100005-100011(already_submitted / invalid_claim_data / draw_not_found / not_your_draw / claim_expired / claim_state_invalid)
- Rebase 后 Stage 2 测试主动 reuse PR E 的 unmetReasonsNotEmpty + evaluatedAtNotZero matcher,人工奖分支若绕过守卫会立即挂
- Stage 1 全部 4 处 guardrail 后端 rebase 时自检过:UnmetReasons、EvaluatedAt、GrantLedger.Payload、AdminMetaMiddleware 全保留

CI 全绿;28 files, +2442/-126;覆盖率 handler 78.9% / model.lottery 74.3% / draw 68.7% / queue/lottery 76.9%
2026-07-10 04:01:34 -07:00

241 lines
9.3 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"
"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 cyclecommon 包里有别的文件反向 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
}