// 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 上下文。 // 匿名 / 未登录返回 0;handler 侧应当由 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 需要 RuleContextBuilder;PR 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 }