新功能(#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%
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
// 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)
|
||||
//
|
||||
// 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"
|
||||
@@ -170,9 +173,11 @@ func (l *DrawLotteryLogic) DrawLottery(req *types.DrawLotteryRequest) (*types.Dr
|
||||
IsWin: result.IsWin,
|
||||
ChancesRemaining: result.ChancesRemaining,
|
||||
Claim: types.LotteryClaimStatus{
|
||||
Required: result.Claim.Required,
|
||||
AutoClaimed: result.Claim.AutoClaimed,
|
||||
Message: result.Claim.Message,
|
||||
Required: result.Claim.Required,
|
||||
AutoClaimed: result.Claim.AutoClaimed,
|
||||
Message: result.Claim.Message,
|
||||
ExpiresAt: result.Claim.ExpiresAt,
|
||||
ClaimFormSchema: result.Claim.ClaimFormSchema,
|
||||
},
|
||||
}
|
||||
if result.Prize != nil {
|
||||
@@ -244,6 +249,10 @@ func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryReco
|
||||
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{
|
||||
@@ -253,7 +262,8 @@ func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryReco
|
||||
DispatchState: d.DispatchState,
|
||||
DrawnAt: d.DrawnAt.Unix(),
|
||||
}
|
||||
if snap, ok := snapshots[d.Id]; ok {
|
||||
snap, hasSnap := snapshots[d.Id]
|
||||
if hasSnap {
|
||||
record.Prize = &types.DrawnPrize{
|
||||
Slot: snap.Slot,
|
||||
Id: snap.PrizeId,
|
||||
@@ -262,11 +272,68 @@ func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryReco
|
||||
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
|
||||
@@ -299,7 +366,16 @@ func recordStatusFilter(s string) string {
|
||||
|
||||
// ---- POST /claim ----------------------------------------------------------
|
||||
|
||||
// ClaimLotteryPrizeLogic 是 Stage 2 才实装的入口;Stage 1 直接返回 4010。
|
||||
// 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
|
||||
@@ -314,10 +390,110 @@ func NewClaimLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(_ *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) {
|
||||
if currentUserId(l.ctx) == 0 {
|
||||
// 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)
|
||||
}
|
||||
// Stage 1 不实装人工领奖流程。
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user