Files
hi-server/internal/logic/public/lottery/lottery.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

500 lines
16 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 lottery implements the user-side lottery HTTP endpoints:
//
// GET /api/v1/lottery/config
// POST /api/v1/lottery/draw
// GET /api/v1/lottery/records
// POST /api/v1/lottery/claim (Stage 2: submit manual claim data)
package lottery
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/perfect-panel/server/internal/logic/lottery/draw"
lotteryhandler "github.com/perfect-panel/server/internal/logic/lottery/handler"
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
userModel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
// currentUserId 取 middleware.AuthMiddleware 注入的 user 上下文。
// 匿名 / 未登录返回 0handler 侧应当由 AuthMiddleware 已经拦截。
func currentUserId(ctx context.Context) int64 {
u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User)
if !ok || u == nil {
return 0
}
return u.Id
}
// ---- GET /config ------------------------------------------------------------
// QueryLotteryConfigLogic 组装活动 + 奖品 + 用户门槛/次数状态。
type QueryLotteryConfigLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewQueryLotteryConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryConfigLogic {
return &QueryLotteryConfigLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *QueryLotteryConfigLogic) QueryLotteryConfig(req *types.GetLotteryConfigRequest) (*types.GetLotteryConfigResponse, error) {
userId := currentUserId(l.ctx)
if userId == 0 {
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
}
activity, err := l.loadActivity(req.ActivityId)
if err != nil {
return nil, err
}
prizes, err := l.loadPrizes(req.ActivityId)
if err != nil {
return nil, err
}
remaining, _ := l.svcCtx.LotteryChance.Query(l.ctx, userId, req.ActivityId)
resp := &types.GetLotteryConfigResponse{
Activity: types.LotteryActivityConfig{
Id: activity.Id,
Title: activity.Title,
Description: activity.Description,
StartAt: activity.StartAt.Unix(),
EndAt: activity.EndAt.Unix(),
Status: activity.Status,
GridSize: activity.GridSize,
},
User: types.LotteryUserStatus{
ChancesRemaining: remaining,
// Eligible / UnmetReasons 需要 RuleContextBuilderPR C 里 draw 路径
// 用真实构造器,config 路径为节省 DB 查询暂只返回次数,前端拿到
// 未通过时的具体 reason 是在 POST /draw 返回码 100001 里附带的。
Eligible: true,
},
}
resp.Activity.Prizes = make([]types.LotteryPrizeConfig, 0, len(prizes))
for _, p := range prizes {
soldOut := p.RemainingStock.Valid && p.RemainingStock.Int64 <= 0
resp.Activity.Prizes = append(resp.Activity.Prizes, types.LotteryPrizeConfig{
Slot: p.Slot,
Id: p.Id,
Type: p.Type,
Name: p.Name,
IconUrl: p.IconURL,
Config: json.RawMessage(defaultIfEmpty(p.Config)),
SoldOut: soldOut,
})
}
return resp, nil
}
func (l *QueryLotteryConfigLogic) loadActivity(id int64) (*modelLottery.Activity, error) {
var activity modelLottery.Activity
err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", id).First(&activity).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
}
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
}
if activity.Status == modelLottery.ActivityStatusEnded {
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
}
return &activity, nil
}
func (l *QueryLotteryConfigLogic) loadPrizes(activityId int64) ([]modelLottery.Prize, error) {
var prizes []modelLottery.Prize
err := l.svcCtx.DB.WithContext(l.ctx).
Where("activity_id = ?", activityId).
Order("slot ASC").
Find(&prizes).Error
if err != nil {
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
}
return prizes, nil
}
func defaultIfEmpty(s string) string {
if strings.TrimSpace(s) == "" {
return "{}"
}
return s
}
// ---- POST /draw -------------------------------------------------------------
// DrawLotteryLogic 是 POST /draw 的入口,委托给 draw.Service。
type DrawLotteryLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDrawLotteryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DrawLotteryLogic {
return &DrawLotteryLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DrawLotteryLogic) DrawLottery(req *types.DrawLotteryRequest) (*types.DrawLotteryResponse, error) {
userId := currentUserId(l.ctx)
if userId == 0 {
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
}
if l.svcCtx.LotteryDrawService == nil {
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
}
result, err := l.svcCtx.LotteryDrawService.Draw(l.ctx, draw.Request{
UserId: userId,
ActivityId: req.ActivityId,
ClientNonce: req.ClientNonce,
})
if err != nil {
return nil, err
}
resp := &types.DrawLotteryResponse{
DrawId: result.DrawId,
IsWin: result.IsWin,
ChancesRemaining: result.ChancesRemaining,
Claim: types.LotteryClaimStatus{
Required: result.Claim.Required,
AutoClaimed: result.Claim.AutoClaimed,
Message: result.Claim.Message,
ExpiresAt: result.Claim.ExpiresAt,
ClaimFormSchema: result.Claim.ClaimFormSchema,
},
}
if result.Prize != nil {
resp.Prize = &types.DrawnPrize{
Slot: result.Prize.Slot,
Id: result.Prize.Id,
Type: result.Prize.Type,
Name: result.Prize.Name,
Config: result.Prize.Config,
}
}
return resp, nil
}
// ---- GET /records ----------------------------------------------------------
// QueryLotteryRecordsLogic 分页列出当前用户的中奖流水。
type QueryLotteryRecordsLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewQueryLotteryRecordsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryRecordsLogic {
return &QueryLotteryRecordsLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryRecordsRequest) (*types.GetLotteryRecordsResponse, error) {
userId := currentUserId(l.ctx)
if userId == 0 {
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
}
page, size := req.Page, req.Size
if page <= 0 {
page = 1
}
if size <= 0 || size > 200 {
size = 20
}
db := l.svcCtx.DB.WithContext(l.ctx).
Model(&modelLottery.Draw{}).
Where("user_id = ?", userId)
if req.ActivityId > 0 {
db = db.Where("activity_id = ?", req.ActivityId)
}
if state := recordStatusFilter(req.Status); state != "" {
switch state {
case "unclaimed":
db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePendingClaim)
case "paid":
db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePaid)
case "expired":
db = db.Where("dispatch_state = ?", modelLottery.DispatchStateExpired)
}
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
}
var draws []modelLottery.Draw
if err := db.Order("drawn_at DESC").Limit(size).Offset((page - 1) * size).Find(&draws).Error; err != nil {
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
}
snapshots, err := l.loadPrizeSnapshots(draws)
if err != nil {
return nil, err
}
claims, err := l.loadClaims(draws)
if err != nil {
return nil, err
}
resp := &types.GetLotteryRecordsResponse{Total: total, List: make([]types.LotteryRecord, 0, len(draws))}
for _, d := range draws {
record := types.LotteryRecord{
DrawId: d.Id,
ActivityId: d.ActivityId,
IsWin: d.IsWin,
DispatchState: d.DispatchState,
DrawnAt: d.DrawnAt.Unix(),
}
snap, hasSnap := snapshots[d.Id]
if hasSnap {
record.Prize = &types.DrawnPrize{
Slot: snap.Slot,
Id: snap.PrizeId,
Type: snap.Type,
Name: snap.Name,
Config: json.RawMessage(defaultIfEmpty(snap.Config)),
}
}
if claim, ok := claims[d.Id]; ok {
record.Claim = l.buildRecordClaim(claim, snap, hasSnap)
}
resp.List = append(resp.List, record)
}
return resp, nil
}
// loadClaims 批量拉这一页里所有 draw 关联的 lottery_claim。人工奖 draw 一定有一行,
// 自动奖 draw / 谢谢参与不会有;缺失的 draw_id 直接不在 map 里,调用侧只做存在性判断。
func (l *QueryLotteryRecordsLogic) loadClaims(draws []modelLottery.Draw) (map[int64]modelLottery.Claim, error) {
if len(draws) == 0 {
return nil, nil
}
ids := make([]int64, 0, len(draws))
for _, d := range draws {
ids = append(ids, d.Id)
}
var rows []modelLottery.Claim
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).Find(&rows).Error; err != nil {
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
}
out := make(map[int64]modelLottery.Claim, len(rows))
for _, c := range rows {
out[c.DrawId] = c
}
return out, nil
}
// buildRecordClaim 把 lottery_claim 组装成 GET /records 里的 Claim 字段。
// 状态允许再提交(pending_claim / rejected)时附带 ClaimFormSchema
// 否则不再下发(避免前端误以为还能再填)。
func (l *QueryLotteryRecordsLogic) buildRecordClaim(claim modelLottery.Claim, snap modelLottery.PrizeSnapshot, hasSnap bool) *types.LotteryRecordClaim {
view := &types.LotteryRecordClaim{
Status: claim.Status,
ExpiresAt: claim.ExpiresAt.Unix(),
TxHash: claim.TxHash,
DeliveryRef: claim.DeliveryRef,
RejectReason: claim.RejectReason,
}
if claim.ClaimData != "" {
view.ClaimData = json.RawMessage(claim.ClaimData)
}
if claim.SubmittedAt != nil {
view.SubmittedAt = claim.SubmittedAt.Unix()
}
if claim.PaidAt != nil {
view.PaidAt = claim.PaidAt.Unix()
}
// 只在允许再提交状态下下发 schema。
if modelLottery.IsClaimStatusResubmittable(claim.Status) && l.svcCtx.LotteryRegistry != nil {
if h, ok := l.svcCtx.LotteryRegistry.Get(claim.PrizeType); ok {
if claim.PrizeType == modelLottery.PrizeTypeCrypto && hasSnap {
view.ClaimFormSchema = lotteryhandler.BuildCryptoClaimSchema(snap.Config)
} else {
view.ClaimFormSchema = h.ClaimSchema()
}
}
}
return view
}
func (l *QueryLotteryRecordsLogic) loadPrizeSnapshots(draws []modelLottery.Draw) (map[int64]modelLottery.PrizeSnapshot, error) {
if len(draws) == 0 {
return nil, nil
}
ids := make([]int64, 0, len(draws))
for _, d := range draws {
ids = append(ids, d.Id)
}
var snaps []modelLottery.PrizeSnapshot
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).Find(&snaps).Error; err != nil {
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
}
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
for _, s := range snaps {
out[s.DrawId] = s
}
return out, nil
}
func recordStatusFilter(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "all":
return ""
case "unclaimed", "paid", "expired":
return strings.ToLower(s)
default:
return ""
}
}
// ---- POST /claim ----------------------------------------------------------
// ClaimLotteryPrizeLogic 是 Stage 2 人工奖领奖入口。
//
// 调用契约(错误码见 pkg/xerr):
//
// 4007 draw_not_found — 传入的 draw_id 不存在
// 4008 not_your_draw — draw 属于其他用户
// 4010 not_claimable — 该 draw 未中奖 / 自动奖 / 找不到 pending_claim
// 4009 claim_expired — pending_claim.expires_at 已过期
// 4005 already_submitted — 当前状态 (reviewing/paying/paid/expired) 禁止再提交
// 4006 invalid_claim_data — handler.ValidateClaim 校验失败
type ClaimLotteryPrizeLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewClaimLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ClaimLotteryPrizeLogic {
return &ClaimLotteryPrizeLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// ClaimLotteryPrize 提交领奖表单:pending_claim → reviewing,或 rejected → reviewing。
func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(req *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) {
userId := currentUserId(l.ctx)
if userId == 0 {
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
}
if req.DrawId <= 0 {
return nil, xerr.NewErrCode(xerr.InvalidParams)
}
claimData := selectClaimData(req)
// 1. 定位 draw + 归属校验(提前失败,避免暴露内部资源)
var draw modelLottery.Draw
if err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.DrawId).First(&draw).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, xerr.NewErrCode(xerr.LotteryDrawNotFound)
}
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
}
if draw.UserId != userId {
return nil, xerr.NewErrCode(xerr.LotteryNotYourDraw)
}
if !draw.IsWin || draw.DispatchState != modelLottery.DispatchStatePendingClaim {
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
}
// 2. 抽奖时刻快照(用于 crypto network 二次校验)
var snap modelLottery.PrizeSnapshot
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id = ?", draw.Id).First(&snap).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
}
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
}
if !modelLottery.IsPrizeTypeManualClaim(snap.Type) {
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
}
handler, ok := l.svcCtx.LotteryRegistry.Get(snap.Type)
if !ok {
return nil, xerr.NewErrCode(xerr.LotteryInternalError)
}
// 3. handler 校验 body
if err := handler.ValidateClaim(claimData); err != nil {
return nil, xerr.NewErrCodeMsg(xerr.LotteryInvalidClaimData, err.Error())
}
if snap.Type == modelLottery.PrizeTypeCrypto {
if err := lotteryhandler.ValidateCryptoNetwork(claimData, snap.Config); err != nil {
return nil, xerr.NewErrCodeMsg(xerr.LotteryInvalidClaimData, err.Error())
}
}
// 4. 事务内更新 claim(乐观锁:status IN (pending_claim, rejected) AND expires_at > now
now := time.Now()
var response *types.ClaimLotteryPrizeResponse
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
var claim modelLottery.Claim
if err := tx.Where("draw_id = ?", draw.Id).First(&claim).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return xerr.NewErrCode(xerr.LotteryNotClaimable)
}
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
}
if claim.ExpiresAt.Before(now) {
return xerr.NewErrCode(xerr.LotteryClaimExpired)
}
if !modelLottery.IsClaimStatusResubmittable(claim.Status) {
return xerr.NewErrCode(xerr.LotteryAlreadySubmitted)
}
// CAS 更新:命中 status 白名单 + expires_at 未过期时才走。RowsAffected==0
// 视为并发拦截(另一个请求已经把状态推进了),报 4005 即可。
res := tx.Model(&modelLottery.Claim{}).
Where("id = ? AND status IN ? AND expires_at > ?",
claim.Id,
[]string{modelLottery.ClaimStatusPendingClaim, modelLottery.ClaimStatusRejected},
now).
Updates(map[string]any{
"claim_data": string(claimData),
"status": modelLottery.ClaimStatusReviewing,
"submitted_at": now,
})
if res.Error != nil {
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
}
if res.RowsAffected == 0 {
return xerr.NewErrCode(xerr.LotteryAlreadySubmitted)
}
response = &types.ClaimLotteryPrizeResponse{
Status: modelLottery.ClaimStatusReviewing,
SubmittedAt: now.Unix(),
}
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
// selectClaimData 兼容 ClaimData(首选)与 Input(历史字段名)。
func selectClaimData(req *types.ClaimLotteryPrizeRequest) []byte {
if len(req.ClaimData) > 0 {
return req.ClaimData
}
return req.Input
}