新功能(#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:
2026-07-10 04:01:34 -07:00
committed by GitHub
parent 117dc0d6a7
commit 07409eb602
28 changed files with 2471 additions and 133 deletions
@@ -0,0 +1,74 @@
// admin_claims_handler.go 提供 Stage 2 后台工单接口的 gin handler 层。
// 路径注册在 internal/handler/lottery_routes.go 里;handler 只负责参数绑定 +
// 委派到 internal/logic/admin/lottery/admin_claims.go。
package lottery
import (
"github.com/gin-gonic/gin"
adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// ListLotteryClaimsHandler GET /v1/admin/lottery/claims
func ListLotteryClaimsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.ListAdminLotteryClaimsRequest
_ = c.ShouldBind(&req)
l := adminlottery.NewListLotteryClaimsLogic(c.Request.Context(), svcCtx)
resp, err := l.ListLotteryClaims(&req)
result.HttpResult(c, resp, err)
}
}
// ApproveLotteryClaimHandler POST /v1/admin/lottery/claims/approve
func ApproveLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.AdminApproveClaimRequest
_ = c.ShouldBind(&req)
if err := svcCtx.Validate(&req); err != nil {
result.ParamErrorResult(c, err)
return
}
l := adminlottery.NewApproveLotteryClaimLogic(c.Request.Context(), svcCtx)
result.HttpResult(c, nil, l.ApproveLotteryClaim(&req))
}
}
// RejectLotteryClaimHandler POST /v1/admin/lottery/claims/reject
func RejectLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.AdminRejectClaimRequest
_ = c.ShouldBind(&req)
if err := svcCtx.Validate(&req); err != nil {
result.ParamErrorResult(c, err)
return
}
l := adminlottery.NewRejectLotteryClaimLogic(c.Request.Context(), svcCtx)
result.HttpResult(c, nil, l.RejectLotteryClaim(&req))
}
}
// MarkPaidLotteryClaimHandler POST /v1/admin/lottery/claims/mark-paid
func MarkPaidLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.AdminMarkPaidClaimRequest
_ = c.ShouldBind(&req)
if err := svcCtx.Validate(&req); err != nil {
result.ParamErrorResult(c, err)
return
}
l := adminlottery.NewMarkPaidLotteryClaimLogic(c.Request.Context(), svcCtx)
result.HttpResult(c, nil, l.MarkPaidLotteryClaim(&req))
}
}
// LotteryClaimsSummaryHandler GET /v1/admin/lottery/claims/summary
func LotteryClaimsSummaryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
l := adminlottery.NewLotteryClaimsSummaryLogic(c.Request.Context(), svcCtx)
resp, err := l.LotteryClaimsSummary()
result.HttpResult(c, resp, err)
}
}