Files
hi-server/internal/logic/lottery/handler/vpn_duration.go
T
shanshanzhong147 ce3babcc33 新功能(#3): 抽奖 Stage 1 收官 — 用户 API + 后台 CRUD + 集成
Closes HIF-3

Stage 1 完整闭环 PR C:用户 API + 后台 CRUD + 抽奖事务服务 + 审计 + QA curl。合并后 Stage 1 可交测试。

架构师 review R1(rulecaps depth≤8/nodes≤64/bytes≤8KB)+ R2(InviteHook source_ref 加 order: 前缀)已全部落地。

- 迁移 02158_admin_action_log:后台写操作审计
- xerr 100xxx 段:抽奖错误码(NotEligible/NoChances/ActivityEnded/RateLimited/NotClaimable/InternalError/RuleTooDeep/RuleTooMany/RuleTooLarge)
- feature flag config.Lottery.Enable 默认 false,合并后线上零副作用
- draw service:feature flag → rate limit → pre-tx reads → nonce dedupe → Consume → Pick → 乐观扣库存 → 双快照 → Dispatch → finalize
- 用户 API 4 个:GET /config、POST /draw、GET /records、POST /claim(Stage 1 返回 100010)
- 后台 CRUD:活动 / 奖品 / rules PUT(rulecaps gate)/ chances/grant
- audit.WriteAdminAction:与调用方 tx 同生共死,SHA1 body 摘要
- QA 脚本:qa/lottery/stage1_curl.sh 全链路 curl

测试覆盖:model 76.5% / draw 69% / handler 68.3% / hook 91.9% / rulecaps 87.8% / audit 100%
100 并发抢库存 + 10000 次概率分布 e2e 推 QA 环境(sqlmock 无法忠实模拟 InnoDB 行锁)
CI 全绿:构建/Vet/测试 + golangci-lint
2026-07-08 22:33:18 -07:00

240 lines
9.2 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 实现 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
}