ce3babcc33
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
240 lines
9.2 KiB
Go
240 lines
9.2 KiB
Go
// 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 实现 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
|
||
}
|