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

324 lines
9.6 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 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 上下文。
// 匿名 / 未登录返回 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,
},
}
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)
}