// 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 1: returns 4010 not_claimable) package lottery import ( "context" "encoding/json" "strings" "github.com/perfect-panel/server/internal/logic/lottery/draw" 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, }, } 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 } 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(), } if snap, ok := snapshots[d.Id]; ok { record.Prize = &types.DrawnPrize{ Slot: snap.Slot, Id: snap.PrizeId, Type: snap.Type, Name: snap.Name, Config: json.RawMessage(defaultIfEmpty(snap.Config)), } } resp.List = append(resp.List, record) } return resp, nil } 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 才实装的入口;Stage 1 直接返回 4010。 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, } } func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(_ *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) { if currentUserId(l.ctx) == 0 { return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) } // Stage 1 不实装人工领奖流程。 return nil, xerr.NewErrCode(xerr.LotteryNotClaimable) }