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
324 lines
9.6 KiB
Go
324 lines
9.6 KiB
Go
// 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)
|
||
}
|