新功能(#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:
@@ -62,4 +62,25 @@ service ppanel {
|
|||||||
@doc "Manually grant N chances to a user (idempotent by source_ref)"
|
@doc "Manually grant N chances to a user (idempotent by source_ref)"
|
||||||
@handler GrantLotteryChance
|
@handler GrantLotteryChance
|
||||||
post /chances/grant (GrantAdminLotteryChanceRequest)
|
post /chances/grant (GrantAdminLotteryChanceRequest)
|
||||||
|
|
||||||
|
// Stage 2 (HIF-4): 人工奖工单接口
|
||||||
|
@doc "List manual-claim work orders (filter by type/status/activity/user/time)"
|
||||||
|
@handler ListLotteryClaims
|
||||||
|
get /claims (ListAdminLotteryClaimsRequest) returns (ListAdminLotteryClaimsResponse)
|
||||||
|
|
||||||
|
@doc "Summary counts for claims workbench"
|
||||||
|
@handler LotteryClaimsSummary
|
||||||
|
get /claims/summary returns (AdminLotteryClaimsSummary)
|
||||||
|
|
||||||
|
@doc "Approve a claim (reviewing -> paying)"
|
||||||
|
@handler ApproveLotteryClaim
|
||||||
|
post /claims/approve (AdminApproveClaimRequest)
|
||||||
|
|
||||||
|
@doc "Reject a claim (reviewing/paying -> rejected; user may resubmit)"
|
||||||
|
@handler RejectLotteryClaim
|
||||||
|
post /claims/reject (AdminRejectClaimRequest)
|
||||||
|
|
||||||
|
@doc "Mark as paid (paying -> paid, records tx_hash/delivery_ref)"
|
||||||
|
@handler MarkPaidLotteryClaim
|
||||||
|
post /claims/mark-paid (AdminMarkPaidClaimRequest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 02159 抽奖活动 Stage 2 down migration
|
||||||
|
-- Stage 2 只新增 1 张表,回滚直接 drop 即可。
|
||||||
|
DROP TABLE IF EXISTS `lottery_claim`;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- 02159 抽奖活动 Stage 2(人工奖领奖工单)
|
||||||
|
--
|
||||||
|
-- 新建 `lottery_claim` 表:承载 crypto / physical / manual_other 三类人工奖
|
||||||
|
-- 从"抽中"到"运营打款/发货"的完整工单状态机。
|
||||||
|
--
|
||||||
|
-- 幂等设计:`CREATE TABLE IF NOT EXISTS`;一个 draw_id 只能有一条 claim 行
|
||||||
|
-- (UNIQUE 约束保证 POST /draw 事务不会重复挂单,避免用户端重放时重复入队)。
|
||||||
|
--
|
||||||
|
-- 关键索引:
|
||||||
|
-- 1) UNIQUE (draw_id) — 抽奖记录 ↔ 领奖工单 一对一
|
||||||
|
-- 2) (activity_id, status) — 后台工单列表按活动 + 状态过滤
|
||||||
|
-- 3) (user_id, activity_id) — GET /records 按用户拉工单
|
||||||
|
-- 4) (status, expires_at) — 过期定时任务扫描
|
||||||
|
--
|
||||||
|
-- 状态机(详细见 doc/lottery-stage2 或 issue HIF-4):
|
||||||
|
-- pending_claim ─── 用户提交 ──→ reviewing
|
||||||
|
-- └── 超时 ──→ expired
|
||||||
|
-- reviewing ─── 运营 approve ──→ paying
|
||||||
|
-- └── 运营 reject ──→ rejected(用户可再次提交)
|
||||||
|
-- paying ─── 运营 mark-paid ──→ paid(终态)
|
||||||
|
-- └── 运营 reject ──→ rejected
|
||||||
|
-- rejected ─── 用户再提交 ──→ reviewing
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `lottery_claim` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||||
|
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||||
|
`prize_type` VARCHAR(32) NOT NULL COMMENT '奖品类型(crypto/physical/manual_other,冗余便于后台按类型过滤)',
|
||||||
|
`claim_data` JSON COMMENT '用户提交的领奖表单数据(结构随 prize_type 变化)',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'pending_claim' COMMENT '状态:pending_claim / reviewing / paying / paid / rejected / expired',
|
||||||
|
`submitted_at` DATETIME DEFAULT NULL COMMENT '用户提交领奖信息时间(首次提交后写;重新提交会覆盖)',
|
||||||
|
`expires_at` DATETIME NOT NULL COMMENT '领奖窗口截止时间(默认 now+7d,可被奖品 config.claim_ttl_hours 覆盖)',
|
||||||
|
`reviewed_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '最近一次审核操作者 user.id',
|
||||||
|
`reviewed_at` DATETIME DEFAULT NULL COMMENT '最近一次审核时间',
|
||||||
|
`reject_reason` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '拒绝原因',
|
||||||
|
`tx_hash` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '链上交易哈希(crypto 打款)',
|
||||||
|
`delivery_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '快递单号 / 发货单据编号(physical 发货)',
|
||||||
|
`paid_at` DATETIME DEFAULT NULL COMMENT '运营标记打款/发货完成时间',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_draw_id` (`draw_id`),
|
||||||
|
KEY `idx_activity_status` (`activity_id`, `status`),
|
||||||
|
KEY `idx_user_activity` (`user_id`, `activity_id`),
|
||||||
|
KEY `idx_status_expires` (`status`, `expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖人工奖领奖工单';
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,5 +40,12 @@ func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
adminGroup.GET("/prizes", adminLottery.ListLotteryPrizesHandler(serverCtx))
|
adminGroup.GET("/prizes", adminLottery.ListLotteryPrizesHandler(serverCtx))
|
||||||
|
|
||||||
adminGroup.POST("/chances/grant", adminLottery.GrantLotteryChanceHandler(serverCtx))
|
adminGroup.POST("/chances/grant", adminLottery.GrantLotteryChanceHandler(serverCtx))
|
||||||
|
|
||||||
|
// Stage 2 (HIF-4): 人工奖工单接口
|
||||||
|
adminGroup.GET("/claims", adminLottery.ListLotteryClaimsHandler(serverCtx))
|
||||||
|
adminGroup.GET("/claims/summary", adminLottery.LotteryClaimsSummaryHandler(serverCtx))
|
||||||
|
adminGroup.POST("/claims/approve", adminLottery.ApproveLotteryClaimHandler(serverCtx))
|
||||||
|
adminGroup.POST("/claims/reject", adminLottery.RejectLotteryClaimHandler(serverCtx))
|
||||||
|
adminGroup.POST("/claims/mark-paid", adminLottery.MarkPaidLotteryClaimHandler(serverCtx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
// admin_claims.go 实现 Stage 2 后台工单接口:
|
||||||
|
//
|
||||||
|
// GET /v1/admin/lottery/claims — 分页列表
|
||||||
|
// POST /v1/admin/lottery/claims/approve — reviewing → paying
|
||||||
|
// POST /v1/admin/lottery/claims/reject — reviewing|paying → rejected
|
||||||
|
// POST /v1/admin/lottery/claims/mark-paid — paying → paid
|
||||||
|
// GET /v1/admin/lottery/claims/summary — 工作台状态计数
|
||||||
|
//
|
||||||
|
// 状态机严格 CAS:所有写路径都用 WHERE status IN (...) 做前置校验,
|
||||||
|
// RowsAffected==0 → 100011 claim_state_invalid(并发/竞态兜底)。
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/logic/audit"
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// audit action codes 供 admin_action_log 用(新增 Stage 2 三个)。
|
||||||
|
const (
|
||||||
|
ActionLotteryClaimApprove = "lottery.claim.approve"
|
||||||
|
ActionLotteryClaimReject = "lottery.claim.reject"
|
||||||
|
ActionLotteryClaimMarkPaid = "lottery.claim.mark_paid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- ListLotteryClaims ----------------------------------------------------
|
||||||
|
|
||||||
|
type ListLotteryClaimsLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListLotteryClaimsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryClaimsLogic {
|
||||||
|
return &ListLotteryClaimsLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLotteryClaims 按 type/status/activity_id/user_id/时间窗过滤。
|
||||||
|
// user_id / email 是"友好视图"字段,走 IN 查询批量拉一次 users 表拼上。
|
||||||
|
func (l *ListLotteryClaimsLogic) ListLotteryClaims(req *types.ListAdminLotteryClaimsRequest) (*types.ListAdminLotteryClaimsResponse, error) {
|
||||||
|
if currentAdminId(l.ctx) == 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.Claim{})
|
||||||
|
if t := strings.TrimSpace(req.Type); t != "" {
|
||||||
|
db = db.Where("prize_type = ?", t)
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(req.Status); s != "" {
|
||||||
|
db = db.Where("status = ?", s)
|
||||||
|
}
|
||||||
|
if req.ActivityId > 0 {
|
||||||
|
db = db.Where("activity_id = ?", req.ActivityId)
|
||||||
|
}
|
||||||
|
if req.UserId > 0 {
|
||||||
|
db = db.Where("user_id = ?", req.UserId)
|
||||||
|
}
|
||||||
|
if req.From > 0 {
|
||||||
|
db = db.Where("created_at >= ?", time.Unix(req.From, 0))
|
||||||
|
}
|
||||||
|
if req.To > 0 {
|
||||||
|
db = db.Where("created_at < ?", time.Unix(req.To, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
var rows []modelLottery.Claim
|
||||||
|
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附加:一次性拉快照 + 用户信息,避免 N+1。
|
||||||
|
drawIds := make([]int64, 0, len(rows))
|
||||||
|
userIds := make([]int64, 0, len(rows))
|
||||||
|
for _, c := range rows {
|
||||||
|
drawIds = append(drawIds, c.DrawId)
|
||||||
|
userIds = append(userIds, c.UserId)
|
||||||
|
}
|
||||||
|
snaps, err := l.loadSnapshots(drawIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users, err := l.loadUsers(userIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &types.ListAdminLotteryClaimsResponse{Total: total, Claims: make([]types.AdminLotteryClaim, 0, len(rows))}
|
||||||
|
for _, c := range rows {
|
||||||
|
resp.Claims = append(resp.Claims, claimToAdminView(c, snaps[c.DrawId], users[c.UserId]))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
func (l *ListLotteryClaimsLogic) loadSnapshots(drawIds []int64) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||||
|
if len(drawIds) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var snaps []modelLottery.PrizeSnapshot
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).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 (l *ListLotteryClaimsLogic) loadUsers(ids []int64) (map[int64]string, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// email 挂在 user_auth_methods 表;一次批量拉 auth_type='email' 的记录,
|
||||||
|
// 每人可能有多条 email(历史合并帐号),按 CreatedAt 排序取第一条即可。
|
||||||
|
type row struct {
|
||||||
|
UserId int64
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Table("user_auth_methods").
|
||||||
|
Select("user_id AS user_id, auth_identifier AS email").
|
||||||
|
Where("auth_type = ? AND user_id IN ?", "email", ids).
|
||||||
|
Order("created_at ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
out := make(map[int64]string, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if _, exists := out[r.UserId]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[r.UserId] = r.Email
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ApproveLotteryClaim --------------------------------------------------
|
||||||
|
|
||||||
|
type ApproveLotteryClaimLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApproveLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveLotteryClaimLogic {
|
||||||
|
return &ApproveLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveLotteryClaim reviewing → paying。CAS:命中 status='reviewing' 才推进。
|
||||||
|
func (l *ApproveLotteryClaimLogic) ApproveLotteryClaim(req *types.AdminApproveClaimRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Model(&modelLottery.Claim{}).
|
||||||
|
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusReviewing).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": modelLottery.ClaimStatusPaying,
|
||||||
|
"reviewed_by": actor,
|
||||||
|
"reviewed_at": now,
|
||||||
|
"reject_reason": "",
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: ActionLotteryClaimApprove,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RejectLotteryClaim ---------------------------------------------------
|
||||||
|
|
||||||
|
type RejectLotteryClaimLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRejectLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectLotteryClaimLogic {
|
||||||
|
return &RejectLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectLotteryClaim reviewing|paying → rejected;expires_at 不重置,用户在
|
||||||
|
// 剩余窗口内可再次提交。
|
||||||
|
func (l *RejectLotteryClaimLogic) RejectLotteryClaim(req *types.AdminRejectClaimRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(req.Reason)
|
||||||
|
if reason == "" {
|
||||||
|
return xerr.NewErrCode(xerr.InvalidParams)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Model(&modelLottery.Claim{}).
|
||||||
|
Where("id = ? AND status IN ?", req.Id,
|
||||||
|
[]string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": modelLottery.ClaimStatusRejected,
|
||||||
|
"reviewed_by": actor,
|
||||||
|
"reviewed_at": now,
|
||||||
|
"reject_reason": reason,
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: ActionLotteryClaimReject,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MarkPaidLotteryClaim -------------------------------------------------
|
||||||
|
|
||||||
|
type MarkPaidLotteryClaimLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMarkPaidLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkPaidLotteryClaimLogic {
|
||||||
|
return &MarkPaidLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPaidLotteryClaim paying → paid。
|
||||||
|
// 校验:crypto 必填 tx_hash / physical 必填 delivery_ref / manual_other 至少填一个。
|
||||||
|
// paid_at 缺省用服务端 now。同事务把 lottery_draw.dispatch_state 也推 paid。
|
||||||
|
func (l *MarkPaidLotteryClaimLogic) MarkPaidLotteryClaim(req *types.AdminMarkPaidClaimRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
txHash := strings.TrimSpace(req.TxHash)
|
||||||
|
deliveryRef := strings.TrimSpace(req.DeliveryRef)
|
||||||
|
now := time.Now()
|
||||||
|
paidAt := now
|
||||||
|
if req.PaidAt > 0 {
|
||||||
|
paidAt = time.Unix(req.PaidAt, 0)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 先取当前 claim 用于类型强校验
|
||||||
|
var claim modelLottery.Claim
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&claim).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
if err := validateMarkPaidByType(claim.PrizeType, txHash, deliveryRef); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAS 推进 status。
|
||||||
|
res := tx.Model(&modelLottery.Claim{}).
|
||||||
|
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusPaying).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": modelLottery.ClaimStatusPaid,
|
||||||
|
"reviewed_by": actor,
|
||||||
|
"reviewed_at": now,
|
||||||
|
"tx_hash": txHash,
|
||||||
|
"delivery_ref": deliveryRef,
|
||||||
|
"paid_at": paidAt,
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
// 同事务把 draw 的 dispatch_state 推到 paid,让 GET /records 与 admin 视图一致。
|
||||||
|
if err := tx.Model(&modelLottery.Draw{}).
|
||||||
|
Where("id = ? AND dispatch_state = ?", claim.DrawId, modelLottery.DispatchStatePendingClaim).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"dispatch_state": modelLottery.DispatchStatePaid,
|
||||||
|
"dispatched_at": paidAt,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: ActionLotteryClaimMarkPaid,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateMarkPaidByType 强制不同奖品类型的最少凭证:
|
||||||
|
// - crypto: tx_hash 必填
|
||||||
|
// - physical: delivery_ref 必填
|
||||||
|
// - manual_other: tx_hash 或 delivery_ref 至少一个
|
||||||
|
//
|
||||||
|
// 校验失败返回 InvalidParams(带具体原因,前端展示给运营)。
|
||||||
|
func validateMarkPaidByType(prizeType, txHash, deliveryRef string) error {
|
||||||
|
switch prizeType {
|
||||||
|
case modelLottery.PrizeTypeCrypto:
|
||||||
|
if txHash == "" {
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, "crypto 奖品必须填写 tx_hash")
|
||||||
|
}
|
||||||
|
case modelLottery.PrizeTypePhysical:
|
||||||
|
if deliveryRef == "" {
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, "physical 奖品必须填写 delivery_ref")
|
||||||
|
}
|
||||||
|
case modelLottery.PrizeTypeManualOther:
|
||||||
|
if txHash == "" && deliveryRef == "" {
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, "manual_other 奖品必须至少填写 tx_hash 或 delivery_ref")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ClaimsSummary --------------------------------------------------------
|
||||||
|
|
||||||
|
type LotteryClaimsSummaryLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLotteryClaimsSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LotteryClaimsSummaryLogic {
|
||||||
|
return &LotteryClaimsSummaryLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryClaimsSummary 一次 GROUP BY 拉齐 reviewing/paying 计数 + 单独查 overdue。
|
||||||
|
func (l *LotteryClaimsSummaryLogic) LotteryClaimsSummary() (*types.AdminLotteryClaimsSummary, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
PrizeType string
|
||||||
|
Status string
|
||||||
|
Cnt int64
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&modelLottery.Claim{}).
|
||||||
|
Select("prize_type, status, COUNT(*) AS cnt").
|
||||||
|
Where("status IN ?", []string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||||||
|
Group("prize_type, status").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
summary := &types.AdminLotteryClaimsSummary{}
|
||||||
|
for _, r := range rows {
|
||||||
|
bucket := bucketByType(summary, r.PrizeType)
|
||||||
|
if bucket == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r.Status {
|
||||||
|
case modelLottery.ClaimStatusReviewing:
|
||||||
|
bucket.Reviewing = r.Cnt
|
||||||
|
case modelLottery.ClaimStatusPaying:
|
||||||
|
bucket.Paying = r.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// overdue:pending_claim 且 expires_at 已过(还没转 expired 的边缘时刻)。
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&modelLottery.Claim{}).
|
||||||
|
Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, time.Now()).
|
||||||
|
Count(&summary.Overdue).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketByType(s *types.AdminLotteryClaimsSummary, prizeType string) *types.AdminLotteryClaimsStatusCount {
|
||||||
|
switch prizeType {
|
||||||
|
case modelLottery.PrizeTypeCrypto:
|
||||||
|
return &s.Crypto
|
||||||
|
case modelLottery.PrizeTypePhysical:
|
||||||
|
return &s.Physical
|
||||||
|
case modelLottery.PrizeTypeManualOther:
|
||||||
|
return &s.ManualOther
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// claimToAdminView 把 model.Claim 组装成后台视图,附带快照 + 用户信息。
|
||||||
|
func claimToAdminView(c modelLottery.Claim, snap modelLottery.PrizeSnapshot, email string) types.AdminLotteryClaim {
|
||||||
|
view := types.AdminLotteryClaim{
|
||||||
|
Id: c.Id,
|
||||||
|
DrawId: c.DrawId,
|
||||||
|
ActivityId: c.ActivityId,
|
||||||
|
Status: c.Status,
|
||||||
|
ExpiresAt: c.ExpiresAt.Unix(),
|
||||||
|
ReviewedBy: c.ReviewedBy,
|
||||||
|
RejectReason: c.RejectReason,
|
||||||
|
TxHash: c.TxHash,
|
||||||
|
DeliveryRef: c.DeliveryRef,
|
||||||
|
CreatedAt: c.CreatedAt.Unix(),
|
||||||
|
User: types.AdminLotteryClaimUser{
|
||||||
|
Id: c.UserId,
|
||||||
|
Email: email,
|
||||||
|
},
|
||||||
|
Prize: types.AdminLotteryClaimPrize{
|
||||||
|
Type: snap.Type,
|
||||||
|
Name: snap.Name,
|
||||||
|
Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if snap.Type == "" {
|
||||||
|
// snapshot 未命中,用 claim.prize_type 兜底
|
||||||
|
view.Prize.Type = c.PrizeType
|
||||||
|
}
|
||||||
|
if c.ClaimData != "" {
|
||||||
|
view.ClaimData = json.RawMessage(c.ClaimData)
|
||||||
|
}
|
||||||
|
if c.SubmittedAt != nil {
|
||||||
|
view.SubmittedAt = c.SubmittedAt.Unix()
|
||||||
|
}
|
||||||
|
if c.ReviewedAt != nil {
|
||||||
|
view.ReviewedAt = c.ReviewedAt.Unix()
|
||||||
|
}
|
||||||
|
if c.PaidAt != nil {
|
||||||
|
view.PaidAt = c.PaidAt.Unix()
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// admin_claims_test.go — 单元测试 Stage 2 claim 状态机的纯函数校验。
|
||||||
|
// 数据库集成留给 stage2 QA curl 脚本;单测只覆盖纯逻辑分支:
|
||||||
|
// - validateMarkPaidByType 的三个奖品类型 x 凭证字段组合
|
||||||
|
// - bucketByType 的类型 → 状态桶映射
|
||||||
|
// - claimToAdminView 的 nullable 字段渲染
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateMarkPaidByType(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
prizeType string
|
||||||
|
txHash string
|
||||||
|
deliveryRef string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"crypto with tx_hash", modelLottery.PrizeTypeCrypto, "0xdeadbeef", "", false},
|
||||||
|
{"crypto missing tx_hash", modelLottery.PrizeTypeCrypto, "", "", true},
|
||||||
|
{"crypto ignores delivery_ref alone", modelLottery.PrizeTypeCrypto, "", "SF123", true},
|
||||||
|
{"physical with delivery_ref", modelLottery.PrizeTypePhysical, "", "SF123456", false},
|
||||||
|
{"physical missing delivery_ref", modelLottery.PrizeTypePhysical, "", "", true},
|
||||||
|
{"manual_other with tx_hash", modelLottery.PrizeTypeManualOther, "0xabc", "", false},
|
||||||
|
{"manual_other with delivery_ref", modelLottery.PrizeTypeManualOther, "", "SF00", false},
|
||||||
|
{"manual_other with both", modelLottery.PrizeTypeManualOther, "0xabc", "SF00", false},
|
||||||
|
{"manual_other with none", modelLottery.PrizeTypeManualOther, "", "", true},
|
||||||
|
{"unknown type", "auto_hallucinated", "", "", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateMarkPaidByType(tc.prizeType, tc.txHash, tc.deliveryRef)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketByType(t *testing.T) {
|
||||||
|
s := &types.AdminLotteryClaimsSummary{}
|
||||||
|
if bucketByType(s, modelLottery.PrizeTypeCrypto) != &s.Crypto {
|
||||||
|
t.Fatal("crypto bucket mismatch")
|
||||||
|
}
|
||||||
|
if bucketByType(s, modelLottery.PrizeTypePhysical) != &s.Physical {
|
||||||
|
t.Fatal("physical bucket mismatch")
|
||||||
|
}
|
||||||
|
if bucketByType(s, modelLottery.PrizeTypeManualOther) != &s.ManualOther {
|
||||||
|
t.Fatal("manual_other bucket mismatch")
|
||||||
|
}
|
||||||
|
if bucketByType(s, "unknown") != nil {
|
||||||
|
t.Fatal("unknown type must return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimToAdminView_RendersNullableFields(t *testing.T) {
|
||||||
|
submittedAt := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
paidAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||||
|
claim := modelLottery.Claim{
|
||||||
|
Id: 42,
|
||||||
|
DrawId: 1234,
|
||||||
|
UserId: 88,
|
||||||
|
ActivityId: 100,
|
||||||
|
PrizeType: modelLottery.PrizeTypeCrypto,
|
||||||
|
Status: modelLottery.ClaimStatusPaid,
|
||||||
|
ClaimData: `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`,
|
||||||
|
SubmittedAt: &submittedAt,
|
||||||
|
ExpiresAt: submittedAt.Add(24 * time.Hour),
|
||||||
|
TxHash: "0xabcdef",
|
||||||
|
PaidAt: &paidAt,
|
||||||
|
}
|
||||||
|
snap := modelLottery.PrizeSnapshot{
|
||||||
|
Type: modelLottery.PrizeTypeCrypto,
|
||||||
|
Name: "1 BTC",
|
||||||
|
Config: `{"amount":"1","currency":"BTC","networks":["BTC"]}`,
|
||||||
|
}
|
||||||
|
view := claimToAdminView(claim, snap, "user@example.com")
|
||||||
|
|
||||||
|
if view.Id != 42 || view.DrawId != 1234 || view.User.Id != 88 {
|
||||||
|
t.Fatalf("view IDs wrong: %+v", view)
|
||||||
|
}
|
||||||
|
if view.User.Email != "user@example.com" {
|
||||||
|
t.Fatalf("Email = %q", view.User.Email)
|
||||||
|
}
|
||||||
|
if view.Status != modelLottery.ClaimStatusPaid {
|
||||||
|
t.Fatalf("Status = %q", view.Status)
|
||||||
|
}
|
||||||
|
if view.SubmittedAt != submittedAt.Unix() {
|
||||||
|
t.Fatalf("SubmittedAt = %d, want %d", view.SubmittedAt, submittedAt.Unix())
|
||||||
|
}
|
||||||
|
if view.PaidAt != paidAt.Unix() {
|
||||||
|
t.Fatalf("PaidAt = %d, want %d", view.PaidAt, paidAt.Unix())
|
||||||
|
}
|
||||||
|
if view.Prize.Type != modelLottery.PrizeTypeCrypto {
|
||||||
|
t.Fatalf("Prize.Type = %q", view.Prize.Type)
|
||||||
|
}
|
||||||
|
if len(view.ClaimData) == 0 {
|
||||||
|
t.Fatal("ClaimData must be included when non-empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimToAdminView_NoSnapshotFallsBackToClaimPrizeType(t *testing.T) {
|
||||||
|
claim := modelLottery.Claim{
|
||||||
|
Id: 1,
|
||||||
|
DrawId: 2,
|
||||||
|
PrizeType: modelLottery.PrizeTypePhysical,
|
||||||
|
Status: modelLottery.ClaimStatusPendingClaim,
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
view := claimToAdminView(claim, modelLottery.PrizeSnapshot{}, "")
|
||||||
|
if view.Prize.Type != modelLottery.PrizeTypePhysical {
|
||||||
|
t.Fatalf("Prize.Type fallback = %q", view.Prize.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||||
"github.com/perfect-panel/server/internal/model/lottery"
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
"github.com/perfect-panel/server/pkg/limit"
|
"github.com/perfect-panel/server/pkg/limit"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
@@ -72,6 +73,11 @@ type ClaimSummary struct {
|
|||||||
Required bool
|
Required bool
|
||||||
AutoClaimed bool
|
AutoClaimed bool
|
||||||
Message string
|
Message string
|
||||||
|
// ExpiresAt 是人工奖领奖窗口截止时间(Unix 秒;0 表示不适用)。
|
||||||
|
ExpiresAt int64
|
||||||
|
// ClaimFormSchema 是人工奖前端渲染领奖表单用的 JSON Schema
|
||||||
|
// (nil 表示不适用;auto handler 与"谢谢参与"都返回 nil)。
|
||||||
|
ClaimFormSchema json.RawMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service orchestrates the transactional draw flow. Deps are struct-injected
|
// Service orchestrates the transactional draw flow. Deps are struct-injected
|
||||||
@@ -200,8 +206,8 @@ func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) {
|
|||||||
return wrapInternal(snapErr)
|
return wrapInternal(snapErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// (f) Dispatch prize.
|
// (f) Dispatch prize (auto handler) or create pending claim (manual handler).
|
||||||
dispatch, dispatchErr := s.dispatchIfAutomatic(ctx, tx, req, draw, final)
|
dispatch, claimInfo, dispatchErr := s.dispatchOrEnqueueClaim(ctx, tx, req, draw, final)
|
||||||
if dispatchErr != nil {
|
if dispatchErr != nil {
|
||||||
return dispatchErr
|
return dispatchErr
|
||||||
}
|
}
|
||||||
@@ -211,7 +217,7 @@ func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) {
|
|||||||
return wrapInternal(updateErr)
|
return wrapInternal(updateErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
result = buildResult(draw, final, dispatch, remaining)
|
result = buildResult(draw, final, dispatch, claimInfo, remaining)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if txErr != nil {
|
if txErr != nil {
|
||||||
@@ -395,23 +401,37 @@ func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lotter
|
|||||||
return tx.WithContext(ctx).Create(&es).Error
|
return tx.WithContext(ctx).Create(&es).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) dispatchIfAutomatic(ctx context.Context, tx *gorm.DB, req Request, draw *lottery.Draw, prize lottery.Prize) (lottery.DispatchResult, error) {
|
// pendingClaimInfo carries the manual-claim details the draw service produced
|
||||||
|
// this turn. Zero-value = draw did not create a claim (auto prize or none).
|
||||||
|
type pendingClaimInfo struct {
|
||||||
|
ExpiresAt time.Time
|
||||||
|
ClaimFormSchema json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchOrEnqueueClaim routes the prize to either an auto-handler dispatch
|
||||||
|
// (Stage 1 path) or to a lottery_claim insert (Stage 2 manual path).
|
||||||
|
//
|
||||||
|
// - draw.IsWin == false → thanks-for-playing, auto_claimed.
|
||||||
|
// - handler.IsAuto()==true → call Dispatch inside caller's tx.
|
||||||
|
// - handler.IsAuto()==false → insert lottery_claim (pending_claim) and
|
||||||
|
// return ClaimFormSchema + ExpiresAt so the
|
||||||
|
// caller can render the response.
|
||||||
|
func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req Request, draw *lottery.Draw, prize lottery.Prize) (lottery.DispatchResult, pendingClaimInfo, error) {
|
||||||
if !draw.IsWin {
|
if !draw.IsWin {
|
||||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, nil
|
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, pendingClaimInfo{}, nil
|
||||||
}
|
}
|
||||||
if s.deps.Registry == nil {
|
if s.deps.Registry == nil {
|
||||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
|
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, nil
|
||||||
}
|
}
|
||||||
handler, err := s.deps.Registry.MustGet(prize.Type)
|
prizeHandler, err := s.deps.Registry.MustGet(prize.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, lottery.ErrHandlerNotRegistered) {
|
if errors.Is(err, lottery.ErrHandlerNotRegistered) {
|
||||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
|
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, nil
|
||||||
}
|
}
|
||||||
return lottery.DispatchResult{}, wrapInternal(err)
|
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(err)
|
||||||
}
|
|
||||||
if !handler.IsAuto() {
|
|
||||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if prizeHandler.IsAuto() {
|
||||||
dispatchReq := lottery.DispatchRequest{
|
dispatchReq := lottery.DispatchRequest{
|
||||||
UserId: req.UserId,
|
UserId: req.UserId,
|
||||||
ActivityId: req.ActivityId,
|
ActivityId: req.ActivityId,
|
||||||
@@ -420,7 +440,52 @@ func (s *Service) dispatchIfAutomatic(ctx context.Context, tx *gorm.DB, req Requ
|
|||||||
Snapshot: lottery.PrizeSnapshot{DrawId: draw.Id, PrizeId: prize.Id, Slot: prize.Slot, Type: prize.Type, Name: prize.Name, Config: prize.Config},
|
Snapshot: lottery.PrizeSnapshot{DrawId: draw.Id, PrizeId: prize.Id, Slot: prize.Slot, Type: prize.Type, Name: prize.Name, Config: prize.Config},
|
||||||
IdempotencyKey: fmt.Sprintf("lottery:%d:%d", req.ActivityId, draw.Id),
|
IdempotencyKey: fmt.Sprintf("lottery:%d:%d", req.ActivityId, draw.Id),
|
||||||
}
|
}
|
||||||
return handler.Dispatch(ctx, tx, dispatchReq)
|
result, dispatchErr := prizeHandler.Dispatch(ctx, tx, dispatchReq)
|
||||||
|
return result, pendingClaimInfo{}, dispatchErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual-claim path (Stage 2). Insert a pending_claim row inside the same
|
||||||
|
// draw tx so a rollback also erases the claim.
|
||||||
|
expiresAt := s.computeClaimExpiry(prize)
|
||||||
|
claim := lottery.Claim{
|
||||||
|
DrawId: draw.Id,
|
||||||
|
UserId: req.UserId,
|
||||||
|
ActivityId: req.ActivityId,
|
||||||
|
PrizeType: prize.Type,
|
||||||
|
Status: lottery.ClaimStatusPendingClaim,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Create(&claim).Error; err != nil {
|
||||||
|
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(fmt.Errorf("insert lottery_claim for draw %d: %w", draw.Id, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := prizeHandler.ClaimSchema()
|
||||||
|
// crypto handler 需要用奖品 config.networks 生成带 enum 的最终 schema。
|
||||||
|
if prize.Type == lottery.PrizeTypeCrypto {
|
||||||
|
schema = handler.BuildCryptoClaimSchema(prize.Config)
|
||||||
|
}
|
||||||
|
return lottery.DispatchResult{
|
||||||
|
State: lottery.DispatchStatePendingClaim,
|
||||||
|
Message: "等待填写领奖信息",
|
||||||
|
}, pendingClaimInfo{
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
ClaimFormSchema: schema,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeClaimExpiry 从奖品 config.claim_ttl_hours 读窗口配置;缺失或非正
|
||||||
|
// 则回落到 lottery.DefaultClaimTTLHours (7 天)。
|
||||||
|
func (s *Service) computeClaimExpiry(prize lottery.Prize) time.Time {
|
||||||
|
hours := lottery.DefaultClaimTTLHours
|
||||||
|
if prize.Config != "" {
|
||||||
|
var cfg struct {
|
||||||
|
ClaimTTLHours int `json:"claim_ttl_hours"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(prize.Config), &cfg); err == nil && cfg.ClaimTTLHours > 0 {
|
||||||
|
hours = cfg.ClaimTTLHours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Now().Add(time.Duration(hours) * time.Hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) finalizeDrawState(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, dispatch lottery.DispatchResult) error {
|
func (s *Service) finalizeDrawState(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, dispatch lottery.DispatchResult) error {
|
||||||
@@ -454,20 +519,38 @@ func (s *Service) buildResultFromExistingDraw(ctx context.Context, tx *gorm.DB,
|
|||||||
Config: json.RawMessage(snap.Config),
|
Config: json.RawMessage(snap.Config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
claim := ClaimSummary{
|
||||||
|
Required: existing.DispatchState == lottery.DispatchStatePendingClaim,
|
||||||
|
AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed,
|
||||||
|
}
|
||||||
|
// 重放场景(同一 client_nonce)也补回 expires_at / schema,避免前端第二次
|
||||||
|
// 收到的响应比首次少字段。
|
||||||
|
if claim.Required {
|
||||||
|
var claimRow lottery.Claim
|
||||||
|
err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&claimRow).Error
|
||||||
|
if err == nil {
|
||||||
|
claim.ExpiresAt = claimRow.ExpiresAt.Unix()
|
||||||
|
if s.deps.Registry != nil {
|
||||||
|
if h, ok := s.deps.Registry.Get(claimRow.PrizeType); ok {
|
||||||
|
if claimRow.PrizeType == lottery.PrizeTypeCrypto {
|
||||||
|
claim.ClaimFormSchema = handler.BuildCryptoClaimSchema(snap.Config)
|
||||||
|
} else {
|
||||||
|
claim.ClaimFormSchema = h.ClaimSchema()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return &Result{
|
return &Result{
|
||||||
DrawId: existing.Id,
|
DrawId: existing.Id,
|
||||||
IsWin: existing.IsWin,
|
IsWin: existing.IsWin,
|
||||||
Prize: prize,
|
Prize: prize,
|
||||||
ChancesRemaining: remaining,
|
ChancesRemaining: remaining,
|
||||||
Claim: ClaimSummary{
|
Claim: claim,
|
||||||
Required: existing.DispatchState == lottery.DispatchStatePendingClaim,
|
|
||||||
AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed,
|
|
||||||
Message: "",
|
|
||||||
},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, remaining int64) *Result {
|
func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, claim pendingClaimInfo, remaining int64) *Result {
|
||||||
res := &Result{
|
res := &Result{
|
||||||
DrawId: draw.Id,
|
DrawId: draw.Id,
|
||||||
IsWin: draw.IsWin,
|
IsWin: draw.IsWin,
|
||||||
@@ -477,8 +560,12 @@ func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.Dispa
|
|||||||
Required: dispatch.State == lottery.DispatchStatePendingClaim,
|
Required: dispatch.State == lottery.DispatchStatePendingClaim,
|
||||||
AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed,
|
AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed,
|
||||||
Message: dispatch.Message,
|
Message: dispatch.Message,
|
||||||
|
ClaimFormSchema: claim.ClaimFormSchema,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if !claim.ExpiresAt.IsZero() {
|
||||||
|
res.Claim.ExpiresAt = claim.ExpiresAt.Unix()
|
||||||
|
}
|
||||||
if draw.IsWin {
|
if draw.IsWin {
|
||||||
res.Prize = &PrizeSummary{
|
res.Prize = &PrizeSummary{
|
||||||
Slot: prize.Slot,
|
Slot: prize.Slot,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ func (h *recordingHandler) Dispatch(_ context.Context, _ *gorm.DB, _ lottery.Dis
|
|||||||
return h.result, h.err
|
return h.result, h.err
|
||||||
}
|
}
|
||||||
func (h *recordingHandler) ValidateClaim([]byte) error { return nil }
|
func (h *recordingHandler) ValidateClaim([]byte) error { return nil }
|
||||||
|
func (h *recordingHandler) ClaimSchema() json.RawMessage { return nil }
|
||||||
|
|
||||||
// stubRegistry only knows what we register.
|
// stubRegistry only knows what we register.
|
||||||
type stubRegistry struct {
|
type stubRegistry struct {
|
||||||
@@ -513,7 +514,8 @@ func errAsCode(err error) (uint32, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInsertSnapshots_UnmetReasonsIsValidJSON is the F4 regression guard.
|
// TestInsertSnapshots_UnmetReasonsIsValidJSON is the F4 regression guard
|
||||||
|
// (kept from PR E — must survive Stage 2 rebase).
|
||||||
//
|
//
|
||||||
// Before PR E, insertSnapshots created lottery_eligibility_snapshot rows with
|
// Before PR E, insertSnapshots created lottery_eligibility_snapshot rows with
|
||||||
// UnmetReasons="" — MySQL error 3140 rejects empty strings on JSON columns,
|
// UnmetReasons="" — MySQL error 3140 rejects empty strings on JSON columns,
|
||||||
@@ -611,3 +613,153 @@ func (m evaluatedAtNotZero) Match(v driver.Value) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Stage 2 (manual claim) tests ----------------------------------------
|
||||||
|
|
||||||
|
// TestDraw_ManualClaimHandlerInsertsPendingClaim 验证:命中 IsAuto()==false
|
||||||
|
// 的 handler 时,draw 事务里会 INSERT lottery_claim 并返回 ExpiresAt + Schema。
|
||||||
|
// 复用 PR E 的 F4/F5 matcher 断言 EligibilitySnapshot 守卫在人工奖分支同样生效。
|
||||||
|
func TestDraw_ManualClaimHandlerInsertsPendingClaim(t *testing.T) {
|
||||||
|
db, mock, cleanup := newTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
expectRunningActivity(mock, 100)
|
||||||
|
expectPrizePool(mock, 100,
|
||||||
|
lottery.Prize{
|
||||||
|
Id: 50, ActivityId: 100, Slot: 3, Type: lottery.PrizeTypeCrypto,
|
||||||
|
Name: "1 BTC",
|
||||||
|
Config: `{"amount":"1","currency":"BTC","networks":["BTC","TRX"],"claim_ttl_hours":48}`,
|
||||||
|
Weight: 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
manualHandler := &recordingHandler{
|
||||||
|
handlerType: lottery.PrizeTypeCrypto,
|
||||||
|
auto: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
expectExistingDrawEmpty(mock)
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(5678, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
// F4/F5 regression guards MUST hold on manual-claim path too.
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||||
|
WithArgs(
|
||||||
|
sqlmock.AnyArg(), // draw_id
|
||||||
|
sqlmock.AnyArg(), // user_id
|
||||||
|
sqlmock.AnyArg(), // activity_id
|
||||||
|
sqlmock.AnyArg(), // passed
|
||||||
|
unmetReasonsNotEmpty{t}, // MUST be "[]"
|
||||||
|
evaluatedAtNotZero{t}, // MUST be non-zero time
|
||||||
|
).
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
// pending_claim row insert
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_claim`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
// finalize draw.dispatch_state = pending_claim
|
||||||
|
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
svc := NewService(Deps{
|
||||||
|
DB: db,
|
||||||
|
Enabled: true,
|
||||||
|
Chance: &fakeChance{consumeRemaining: 0},
|
||||||
|
Evaluator: &fakeEvaluator{passed: true},
|
||||||
|
Picker: &fakePicker{idx: 0},
|
||||||
|
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeCrypto: manualHandler}},
|
||||||
|
ContextBuilder: fakeContextBuilder{},
|
||||||
|
})
|
||||||
|
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "manual1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw: %v", err)
|
||||||
|
}
|
||||||
|
if manualHandler.calls != 0 {
|
||||||
|
t.Fatalf("manual handler.Dispatch must NOT be called, got %d calls", manualHandler.calls)
|
||||||
|
}
|
||||||
|
if !res.Claim.Required {
|
||||||
|
t.Fatalf("expected Claim.Required=true, got %+v", res.Claim)
|
||||||
|
}
|
||||||
|
if res.Claim.AutoClaimed {
|
||||||
|
t.Fatalf("expected AutoClaimed=false for manual, got %+v", res.Claim)
|
||||||
|
}
|
||||||
|
if res.Claim.ExpiresAt == 0 {
|
||||||
|
t.Fatal("expected non-zero ExpiresAt")
|
||||||
|
}
|
||||||
|
// 48h TTL from prize config
|
||||||
|
expected := time.Now().Add(48 * time.Hour).Unix()
|
||||||
|
if diff := res.Claim.ExpiresAt - expected; diff > 5 || diff < -5 {
|
||||||
|
t.Fatalf("ExpiresAt off by %ds; got %d expected ~%d", diff, res.Claim.ExpiresAt, expected)
|
||||||
|
}
|
||||||
|
// crypto handler builds schema with enum injected from prize config
|
||||||
|
if len(res.Claim.ClaimFormSchema) == 0 {
|
||||||
|
t.Fatal("expected ClaimFormSchema for crypto")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(res.Claim.ClaimFormSchema), `"enum":["BTC","TRX"]`) {
|
||||||
|
t.Fatalf("expected enum with BTC/TRX in schema, got %s", res.Claim.ClaimFormSchema)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDraw_ManualClaimDefaultsTo7DayTTL 验证:奖品 config 没写 claim_ttl_hours
|
||||||
|
// 时,落在默认 168h(7 天)窗口。
|
||||||
|
func TestDraw_ManualClaimDefaultsTo7DayTTL(t *testing.T) {
|
||||||
|
db, mock, cleanup := newTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
expectRunningActivity(mock, 100)
|
||||||
|
expectPrizePool(mock, 100,
|
||||||
|
lottery.Prize{
|
||||||
|
Id: 60, ActivityId: 100, Slot: 4, Type: lottery.PrizeTypePhysical,
|
||||||
|
Name: "T-shirt",
|
||||||
|
Config: `{"sku_id":"tee-01","sku_name":"限量 T 恤"}`,
|
||||||
|
Weight: 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
expectExistingDrawEmpty(mock)
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(9001, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||||
|
WithArgs(
|
||||||
|
sqlmock.AnyArg(),
|
||||||
|
sqlmock.AnyArg(),
|
||||||
|
sqlmock.AnyArg(),
|
||||||
|
sqlmock.AnyArg(),
|
||||||
|
unmetReasonsNotEmpty{t}, // F4 guard also applies here
|
||||||
|
evaluatedAtNotZero{t}, // F5 guard also applies here
|
||||||
|
).
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_claim`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
svc := NewService(Deps{
|
||||||
|
DB: db,
|
||||||
|
Enabled: true,
|
||||||
|
Chance: &fakeChance{consumeRemaining: 0},
|
||||||
|
Evaluator: &fakeEvaluator{passed: true},
|
||||||
|
Picker: &fakePicker{idx: 0},
|
||||||
|
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{
|
||||||
|
lottery.PrizeTypePhysical: &recordingHandler{handlerType: lottery.PrizeTypePhysical, auto: false},
|
||||||
|
}},
|
||||||
|
ContextBuilder: fakeContextBuilder{},
|
||||||
|
})
|
||||||
|
res, err := svc.Draw(context.Background(), Request{UserId: 2, ActivityId: 100, ClientNonce: "manual2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draw: %v", err)
|
||||||
|
}
|
||||||
|
expected := time.Now().Add(time.Duration(lottery.DefaultClaimTTLHours) * time.Hour).Unix()
|
||||||
|
if diff := res.Claim.ExpiresAt - expected; diff > 5 || diff < -5 {
|
||||||
|
t.Fatalf("expected default 7-day TTL, got diff=%ds", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func NewCommissionHandler(deps CommissionDeps) *CommissionHandler {
|
|||||||
func (*CommissionHandler) Type() string { return lottery.PrizeTypeCommission }
|
func (*CommissionHandler) Type() string { return lottery.PrizeTypeCommission }
|
||||||
func (*CommissionHandler) IsAuto() bool { return true }
|
func (*CommissionHandler) IsAuto() bool { return true }
|
||||||
func (*CommissionHandler) ValidateClaim([]byte) error { return nil }
|
func (*CommissionHandler) ValidateClaim([]byte) error { return nil }
|
||||||
|
func (*CommissionHandler) ClaimSchema() json.RawMessage { return nil }
|
||||||
|
|
||||||
type commissionConfig struct {
|
type commissionConfig struct {
|
||||||
// AmountCents 是"分"级别的金额(与 user.commission 存储单位对齐)。
|
// AmountCents 是"分"级别的金额(与 user.commission 存储单位对齐)。
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Package handler crypto/physical/manual_other 是 Stage 2 引入的三类"人工奖"
|
||||||
|
// PrizeHandler。特点:IsAuto()=false,抽奖事务不调用 Dispatch,而是由 draw
|
||||||
|
// 服务事务内插入 lottery_claim (pending_claim)。用户随后 POST /claim 提交
|
||||||
|
// 领奖表单;运营在后台 approve → mark-paid。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 通用错误 -------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrClaimDataEmpty 表示用户没有提交任何领奖 body。
|
||||||
|
ErrClaimDataEmpty = errors.New("lottery: claim data is empty")
|
||||||
|
// ErrClaimDataMalformed 表示 body 不是合法 JSON 或缺关键字段。
|
||||||
|
ErrClaimDataMalformed = errors.New("lottery: claim data is malformed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// notSupportedDispatch 返回 ErrDispatchNotSupported,供三个人工奖 handler 共享。
|
||||||
|
// 抽奖服务在 handler.IsAuto()==false 时会短路,不会真的调用 Dispatch;这个
|
||||||
|
// 实现只是防御性的:万一未来某处直接调用了 Dispatch,能立刻在日志里看到问题。
|
||||||
|
func notSupportedDispatch(_ context.Context, _ *gorm.DB, _ lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||||
|
return lottery.DispatchResult{}, lottery.ErrDispatchNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeClaimJSON 是三个人工 handler 通用的 body 解码路径:空 body 直接返回
|
||||||
|
// ErrClaimDataEmpty;解码失败返回 ErrClaimDataMalformed(wrap 原因)。
|
||||||
|
func decodeClaimJSON(raw []byte, out any) error {
|
||||||
|
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" {
|
||||||
|
return ErrClaimDataEmpty
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, out); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrClaimDataMalformed, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- crypto handler ------------------------------------------------------
|
||||||
|
|
||||||
|
// CryptoHandler 支持"加密货币"人工奖。运营在后台配置 amount / currency /
|
||||||
|
// networks;用户选一个网络 + 填一个地址;运营线下打款后 mark-paid + tx_hash。
|
||||||
|
type CryptoHandler struct{}
|
||||||
|
|
||||||
|
// NewCryptoHandler 构造 crypto handler。无依赖,registry 直接 Register 即可。
|
||||||
|
func NewCryptoHandler() *CryptoHandler { return &CryptoHandler{} }
|
||||||
|
|
||||||
|
func (*CryptoHandler) Type() string { return lottery.PrizeTypeCrypto }
|
||||||
|
func (*CryptoHandler) IsAuto() bool { return false }
|
||||||
|
func (*CryptoHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||||
|
return notSupportedDispatch(ctx, tx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cryptoClaimSchemaJSON 是前端渲染表单的 JSON Schema。运行时 crypto handler
|
||||||
|
// 会把奖品 config.networks 注入到 network 字段的 enum,让前端只放开这些网络。
|
||||||
|
// 这里的常量是空 enum 的"模板";ClaimSchema() 返回不带具体 networks 的通用
|
||||||
|
// 描述,实际抽中时 draw 服务会传具体奖品 config,用 BuildCryptoClaimSchema
|
||||||
|
// 生成带 enum 的最终 schema 附到 draw response 上。
|
||||||
|
var cryptoClaimSchemaJSON = json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["network","address"],
|
||||||
|
"properties": {
|
||||||
|
"network": {"type":"string","title":"打款网络"},
|
||||||
|
"address": {"type":"string","title":"钱包地址","minLength":16,"maxLength":128}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
func (*CryptoHandler) ClaimSchema() json.RawMessage { return cryptoClaimSchemaJSON }
|
||||||
|
|
||||||
|
// BuildCryptoClaimSchema 在抽奖成功后按具体奖品 config 生成最终 schema:
|
||||||
|
// 把 config.networks[] 注入到 network 字段的 enum,供前端下拉展示。
|
||||||
|
// prizeConfig 为该奖品的完整 config JSON 字符串(内含 amount/currency/networks)。
|
||||||
|
func BuildCryptoClaimSchema(prizeConfig string) json.RawMessage {
|
||||||
|
var cfg cryptoConfig
|
||||||
|
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
|
||||||
|
return cryptoClaimSchemaJSON
|
||||||
|
}
|
||||||
|
if len(cfg.Networks) == 0 {
|
||||||
|
return cryptoClaimSchemaJSON
|
||||||
|
}
|
||||||
|
// 拼一段带 enum 的 schema,尽量保持体积小、易读。
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`{"type":"object","required":["network","address"],"properties":{"network":{"type":"string","title":"打款网络","enum":[`)
|
||||||
|
for i, n := range cfg.Networks {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(n)
|
||||||
|
b.Write(encoded)
|
||||||
|
}
|
||||||
|
b.WriteString(`]},"address":{"type":"string","title":"钱包地址","minLength":16,"maxLength":128}}}`)
|
||||||
|
return json.RawMessage(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// cryptoConfig 是 lottery_prize.config 的解码目标。
|
||||||
|
type cryptoConfig struct {
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Networks []string `json:"networks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cryptoClaimInput struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// cryptoAddressRegexp 只做最低限度校验(长度 + 字符集),避免 handler 里
|
||||||
|
// 绑定各种链的地址前缀(BTC/ETH/TRX 各有一套),把严格校验推给运营在
|
||||||
|
// mark-paid 前人肉复核。
|
||||||
|
var cryptoAddressRegexp = regexp.MustCompile(`^[A-Za-z0-9]{16,128}$`)
|
||||||
|
|
||||||
|
// ValidateClaim 校验用户提交的 { network, address }:
|
||||||
|
// - network 必须非空(网络白名单是奖品 config 决定的,由 POST /claim 路径
|
||||||
|
// 再做一次二次校验;handler 层只做格式校验,避免把奖品 config 传下来
|
||||||
|
// 污染 ValidateClaim 的签名)
|
||||||
|
// - address 必须匹配基础字符集与长度
|
||||||
|
func (*CryptoHandler) ValidateClaim(raw []byte) error {
|
||||||
|
var input cryptoClaimInput
|
||||||
|
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Network) == "" {
|
||||||
|
return fmt.Errorf("%w: network is required", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
if !cryptoAddressRegexp.MatchString(strings.TrimSpace(input.Address)) {
|
||||||
|
return fmt.Errorf("%w: address format invalid (16-128 alphanumeric)", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCryptoNetwork 二次校验用户选中的 network 必须在奖品 config.networks
|
||||||
|
// 白名单里。抽出到独立函数是因为 handler.ValidateClaim 的签名不接受奖品配置;
|
||||||
|
// 由 POST /claim 逻辑层负责调用。
|
||||||
|
func ValidateCryptoNetwork(raw []byte, prizeConfig string) error {
|
||||||
|
var input cryptoClaimInput
|
||||||
|
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var cfg cryptoConfig
|
||||||
|
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
|
||||||
|
return fmt.Errorf("decode crypto config: %w", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Networks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
network := strings.TrimSpace(input.Network)
|
||||||
|
for _, allowed := range cfg.Networks {
|
||||||
|
if allowed == network {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: network %q not in allowed list", ErrClaimDataMalformed, network)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- physical handler ----------------------------------------------------
|
||||||
|
|
||||||
|
// PhysicalHandler 支持实物奖。运营 mark-paid 时用 delivery_ref 记录快递单号。
|
||||||
|
type PhysicalHandler struct{}
|
||||||
|
|
||||||
|
// NewPhysicalHandler 构造 physical handler。
|
||||||
|
func NewPhysicalHandler() *PhysicalHandler { return &PhysicalHandler{} }
|
||||||
|
|
||||||
|
func (*PhysicalHandler) Type() string { return lottery.PrizeTypePhysical }
|
||||||
|
func (*PhysicalHandler) IsAuto() bool { return false }
|
||||||
|
func (*PhysicalHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||||
|
return notSupportedDispatch(ctx, tx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
var physicalClaimSchemaJSON = json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name","phone","province","city","district","detail"],
|
||||||
|
"properties": {
|
||||||
|
"name": {"type":"string","title":"收件人姓名","minLength":1,"maxLength":64},
|
||||||
|
"phone": {"type":"string","title":"联系电话","minLength":6,"maxLength":32},
|
||||||
|
"province": {"type":"string","title":"省","minLength":1,"maxLength":32},
|
||||||
|
"city": {"type":"string","title":"市","minLength":1,"maxLength":32},
|
||||||
|
"district": {"type":"string","title":"区/县","minLength":1,"maxLength":32},
|
||||||
|
"detail": {"type":"string","title":"详细地址","minLength":1,"maxLength":256}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
func (*PhysicalHandler) ClaimSchema() json.RawMessage { return physicalClaimSchemaJSON }
|
||||||
|
|
||||||
|
type physicalClaimInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Province string `json:"province"`
|
||||||
|
City string `json:"city"`
|
||||||
|
District string `json:"district"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// phoneRegexp 只允许数字、+、-、空格,长度 6-32;宽松以覆盖国际号码格式。
|
||||||
|
var phoneRegexp = regexp.MustCompile(`^[0-9+\-\s]{6,32}$`)
|
||||||
|
|
||||||
|
func (*PhysicalHandler) ValidateClaim(raw []byte) error {
|
||||||
|
var input physicalClaimInput
|
||||||
|
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Name) == "" {
|
||||||
|
return fmt.Errorf("%w: name is required", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
if !phoneRegexp.MatchString(strings.TrimSpace(input.Phone)) {
|
||||||
|
return fmt.Errorf("%w: phone format invalid", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Province) == "" ||
|
||||||
|
strings.TrimSpace(input.City) == "" ||
|
||||||
|
strings.TrimSpace(input.District) == "" ||
|
||||||
|
strings.TrimSpace(input.Detail) == "" {
|
||||||
|
return fmt.Errorf("%w: address components are required", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- manual_other handler ------------------------------------------------
|
||||||
|
|
||||||
|
// ManualOtherHandler 支持"其他人工奖"(点赞、见面礼、线下券码等)。
|
||||||
|
type ManualOtherHandler struct{}
|
||||||
|
|
||||||
|
// NewManualOtherHandler 构造 manual_other handler。
|
||||||
|
func NewManualOtherHandler() *ManualOtherHandler { return &ManualOtherHandler{} }
|
||||||
|
|
||||||
|
func (*ManualOtherHandler) Type() string { return lottery.PrizeTypeManualOther }
|
||||||
|
func (*ManualOtherHandler) IsAuto() bool { return false }
|
||||||
|
func (*ManualOtherHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||||
|
return notSupportedDispatch(ctx, tx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
var manualOtherClaimSchemaJSON = json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["contact_type","contact_value"],
|
||||||
|
"properties": {
|
||||||
|
"contact_type": {"type":"string","title":"联系方式类型","enum":["phone","email","tg"]},
|
||||||
|
"contact_value": {"type":"string","title":"联系方式","minLength":1,"maxLength":128},
|
||||||
|
"remark": {"type":"string","title":"备注","maxLength":512}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
func (*ManualOtherHandler) ClaimSchema() json.RawMessage { return manualOtherClaimSchemaJSON }
|
||||||
|
|
||||||
|
type manualOtherClaimInput struct {
|
||||||
|
ContactType string `json:"contact_type"`
|
||||||
|
ContactValue string `json:"contact_value"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// manualOtherContactTypes 是 contact_type 允许的枚举。
|
||||||
|
var manualOtherContactTypes = map[string]struct{}{
|
||||||
|
"phone": {},
|
||||||
|
"email": {},
|
||||||
|
"tg": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ManualOtherHandler) ValidateClaim(raw []byte) error {
|
||||||
|
var input manualOtherClaimInput
|
||||||
|
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ct := strings.TrimSpace(input.ContactType)
|
||||||
|
if _, ok := manualOtherContactTypes[ct]; !ok {
|
||||||
|
return fmt.Errorf("%w: contact_type must be one of phone/email/tg", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.ContactValue) == "" {
|
||||||
|
return fmt.Errorf("%w: contact_value is required", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
if len(input.Remark) > 512 {
|
||||||
|
return fmt.Errorf("%w: remark too long (max 512)", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// manual_claim_test.go — 单元测试三类人工奖 handler 的静态约束:
|
||||||
|
// - Type / IsAuto / Dispatch 契约
|
||||||
|
// - ClaimSchema 返回合法 JSON
|
||||||
|
// - ValidateClaim 正确/错误样本表驱动
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Crypto ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCryptoHandler_Contract(t *testing.T) {
|
||||||
|
h := NewCryptoHandler()
|
||||||
|
if h.Type() != lottery.PrizeTypeCrypto {
|
||||||
|
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeCrypto)
|
||||||
|
}
|
||||||
|
if h.IsAuto() {
|
||||||
|
t.Fatal("IsAuto must be false for manual claim handler")
|
||||||
|
}
|
||||||
|
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||||
|
t.Fatalf("Dispatch on manual handler must return ErrDispatchNotSupported, got %v", err)
|
||||||
|
}
|
||||||
|
if h.ClaimSchema() == nil {
|
||||||
|
t.Fatal("ClaimSchema must not be nil for manual handler")
|
||||||
|
}
|
||||||
|
var schema map[string]any
|
||||||
|
if err := json.Unmarshal(h.ClaimSchema(), &schema); err != nil {
|
||||||
|
t.Fatalf("ClaimSchema must be valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCryptoHandler_ValidateClaim(t *testing.T) {
|
||||||
|
h := NewCryptoHandler()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantErr bool
|
||||||
|
errIsErr error
|
||||||
|
}{
|
||||||
|
{"empty", "", true, ErrClaimDataEmpty},
|
||||||
|
{"whitespace", " ", true, ErrClaimDataEmpty},
|
||||||
|
{"malformed json", `{"network"`, true, ErrClaimDataMalformed},
|
||||||
|
{"missing network", `{"address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
||||||
|
{"empty network", `{"network":"","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
||||||
|
{"address too short", `{"network":"BTC","address":"abc"}`, true, ErrClaimDataMalformed},
|
||||||
|
{"address bad chars", `{"network":"BTC","address":"bc1$$!!****"}`, true, ErrClaimDataMalformed},
|
||||||
|
{"valid BTC", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, false, nil},
|
||||||
|
{"valid ETH", `{"network":"ETH","address":"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1"}`, false, nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := h.ValidateClaim([]byte(tc.body))
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
if tc.errIsErr != nil && !errors.Is(err, tc.errIsErr) {
|
||||||
|
t.Fatalf("expected errors.Is %v, got %v", tc.errIsErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCryptoNetwork(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
prizeConfig string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"empty networks in cfg means allow-all", `{"network":"foo","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"amount":"1","currency":"BTC"}`, false},
|
||||||
|
{"network in whitelist", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"networks":["BTC","ETH"]}`, false},
|
||||||
|
{"network NOT in whitelist", `{"network":"XRP","address":"rXYZQabcdefghijkxxxxxxxx"}`, `{"networks":["BTC","ETH"]}`, true},
|
||||||
|
{"empty body", "", `{"networks":["BTC"]}`, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := ValidateCryptoNetwork([]byte(tc.body), tc.prizeConfig)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCryptoClaimSchema_InjectsNetworkEnum(t *testing.T) {
|
||||||
|
schema := BuildCryptoClaimSchema(`{"networks":["BTC","TRX"]}`)
|
||||||
|
s := string(schema)
|
||||||
|
if !strings.Contains(s, `"enum":["BTC","TRX"]`) {
|
||||||
|
t.Fatalf("expected schema to include enum with configured networks, got %s", s)
|
||||||
|
}
|
||||||
|
// 合法 JSON
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(schema, &out); err != nil {
|
||||||
|
t.Fatalf("built schema not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCryptoClaimSchema_FallsBackWhenConfigInvalid(t *testing.T) {
|
||||||
|
// invalid JSON → fallback to generic schema without enum
|
||||||
|
schema := BuildCryptoClaimSchema(`not-json`)
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(schema, &out); err != nil {
|
||||||
|
t.Fatalf("fallback schema not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Physical -------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestPhysicalHandler_Contract(t *testing.T) {
|
||||||
|
h := NewPhysicalHandler()
|
||||||
|
if h.Type() != lottery.PrizeTypePhysical {
|
||||||
|
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypePhysical)
|
||||||
|
}
|
||||||
|
if h.IsAuto() {
|
||||||
|
t.Fatal("IsAuto must be false")
|
||||||
|
}
|
||||||
|
if h.ClaimSchema() == nil {
|
||||||
|
t.Fatal("ClaimSchema must not be nil")
|
||||||
|
}
|
||||||
|
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||||
|
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhysicalHandler_ValidateClaim(t *testing.T) {
|
||||||
|
h := NewPhysicalHandler()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"empty", ``, true},
|
||||||
|
{"missing name", `{"phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
||||||
|
{"bad phone", `{"name":"张三","phone":"abc","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
||||||
|
{"missing detail", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":""}`, true},
|
||||||
|
{"valid CN", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路 XX 号"}`, false},
|
||||||
|
{"valid international", `{"name":"John","phone":"+1 415-555-0100","province":"CA","city":"SF","district":"SoMa","detail":"1 Market St"}`, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := h.ValidateClaim([]byte(tc.body))
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ManualOther ---------------------------------------------------------
|
||||||
|
|
||||||
|
func TestManualOtherHandler_Contract(t *testing.T) {
|
||||||
|
h := NewManualOtherHandler()
|
||||||
|
if h.Type() != lottery.PrizeTypeManualOther {
|
||||||
|
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeManualOther)
|
||||||
|
}
|
||||||
|
if h.IsAuto() {
|
||||||
|
t.Fatal("IsAuto must be false")
|
||||||
|
}
|
||||||
|
if h.ClaimSchema() == nil {
|
||||||
|
t.Fatal("ClaimSchema must not be nil")
|
||||||
|
}
|
||||||
|
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||||
|
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualOtherHandler_ValidateClaim(t *testing.T) {
|
||||||
|
h := NewManualOtherHandler()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"empty", ``, true},
|
||||||
|
{"unknown contact_type", `{"contact_type":"fax","contact_value":"1234"}`, true},
|
||||||
|
{"missing contact_value", `{"contact_type":"phone","contact_value":""}`, true},
|
||||||
|
{"valid phone", `{"contact_type":"phone","contact_value":"+8613800001234","remark":"下午联系"}`, false},
|
||||||
|
{"valid email", `{"contact_type":"email","contact_value":"user@example.com"}`, false},
|
||||||
|
{"valid tg", `{"contact_type":"tg","contact_value":"@handle"}`, false},
|
||||||
|
{"remark too long", `{"contact_type":"email","contact_value":"x@y.z","remark":"` + strings.Repeat("x", 513) + `"}`, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := h.ValidateClaim([]byte(tc.body))
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 状态机常量约束 --------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIsClaimStatusResubmittable(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
status string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{lottery.ClaimStatusPendingClaim, true},
|
||||||
|
{lottery.ClaimStatusRejected, true},
|
||||||
|
{lottery.ClaimStatusReviewing, false},
|
||||||
|
{lottery.ClaimStatusPaying, false},
|
||||||
|
{lottery.ClaimStatusPaid, false},
|
||||||
|
{lottery.ClaimStatusExpired, false},
|
||||||
|
{"", false},
|
||||||
|
{"unknown", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := lottery.IsClaimStatusResubmittable(tc.status); got != tc.want {
|
||||||
|
t.Errorf("IsClaimStatusResubmittable(%q) = %v, want %v", tc.status, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrizeTypeManualClaim(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
prizeType string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{lottery.PrizeTypeCrypto, true},
|
||||||
|
{lottery.PrizeTypePhysical, true},
|
||||||
|
{lottery.PrizeTypeManualOther, true},
|
||||||
|
{lottery.PrizeTypeVPNDuration, false},
|
||||||
|
{lottery.PrizeTypeCommission, false},
|
||||||
|
{lottery.PrizeTypeNone, false},
|
||||||
|
{"unknown", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := lottery.IsPrizeTypeManualClaim(tc.prizeType); got != tc.want {
|
||||||
|
t.Errorf("IsPrizeTypeManualClaim(%q) = %v, want %v", tc.prizeType, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,10 +79,11 @@ func NewVPNDurationHandler(deps VPNDurationDeps) *VPNDurationHandler {
|
|||||||
return &VPNDurationHandler{deps: deps}
|
return &VPNDurationHandler{deps: deps}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type / IsAuto / ValidateClaim 实现 PrizeHandler 接口。
|
// Type / IsAuto / ValidateClaim / ClaimSchema 实现 PrizeHandler 接口。
|
||||||
func (*VPNDurationHandler) Type() string { return lottery.PrizeTypeVPNDuration }
|
func (*VPNDurationHandler) Type() string { return lottery.PrizeTypeVPNDuration }
|
||||||
func (*VPNDurationHandler) IsAuto() bool { return true }
|
func (*VPNDurationHandler) IsAuto() bool { return true }
|
||||||
func (*VPNDurationHandler) ValidateClaim([]byte) error { return nil }
|
func (*VPNDurationHandler) ValidateClaim([]byte) error { return nil }
|
||||||
|
func (*VPNDurationHandler) ClaimSchema() json.RawMessage { return nil }
|
||||||
|
|
||||||
// vpnDurationConfig 是奖品 Config JSON 的解码目标。
|
// vpnDurationConfig 是奖品 Config JSON 的解码目标。
|
||||||
type vpnDurationConfig struct {
|
type vpnDurationConfig struct {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
// Package lottery implements the user-side lottery HTTP endpoints:
|
// Package lottery implements the user-side lottery HTTP endpoints:
|
||||||
|
//
|
||||||
// GET /api/v1/lottery/config
|
// GET /api/v1/lottery/config
|
||||||
// POST /api/v1/lottery/draw
|
// POST /api/v1/lottery/draw
|
||||||
// GET /api/v1/lottery/records
|
// GET /api/v1/lottery/records
|
||||||
// POST /api/v1/lottery/claim (Stage 1: returns 4010 not_claimable)
|
// POST /api/v1/lottery/claim (Stage 2: submit manual claim data)
|
||||||
package lottery
|
package lottery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/logic/lottery/draw"
|
"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"
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
userModel "github.com/perfect-panel/server/internal/model/user"
|
userModel "github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
@@ -173,6 +176,8 @@ func (l *DrawLotteryLogic) DrawLottery(req *types.DrawLotteryRequest) (*types.Dr
|
|||||||
Required: result.Claim.Required,
|
Required: result.Claim.Required,
|
||||||
AutoClaimed: result.Claim.AutoClaimed,
|
AutoClaimed: result.Claim.AutoClaimed,
|
||||||
Message: result.Claim.Message,
|
Message: result.Claim.Message,
|
||||||
|
ExpiresAt: result.Claim.ExpiresAt,
|
||||||
|
ClaimFormSchema: result.Claim.ClaimFormSchema,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if result.Prize != nil {
|
if result.Prize != nil {
|
||||||
@@ -244,6 +249,10 @@ func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryReco
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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))}
|
resp := &types.GetLotteryRecordsResponse{Total: total, List: make([]types.LotteryRecord, 0, len(draws))}
|
||||||
for _, d := range draws {
|
for _, d := range draws {
|
||||||
record := types.LotteryRecord{
|
record := types.LotteryRecord{
|
||||||
@@ -253,7 +262,8 @@ func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryReco
|
|||||||
DispatchState: d.DispatchState,
|
DispatchState: d.DispatchState,
|
||||||
DrawnAt: d.DrawnAt.Unix(),
|
DrawnAt: d.DrawnAt.Unix(),
|
||||||
}
|
}
|
||||||
if snap, ok := snapshots[d.Id]; ok {
|
snap, hasSnap := snapshots[d.Id]
|
||||||
|
if hasSnap {
|
||||||
record.Prize = &types.DrawnPrize{
|
record.Prize = &types.DrawnPrize{
|
||||||
Slot: snap.Slot,
|
Slot: snap.Slot,
|
||||||
Id: snap.PrizeId,
|
Id: snap.PrizeId,
|
||||||
@@ -262,11 +272,68 @@ func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryReco
|
|||||||
Config: json.RawMessage(defaultIfEmpty(snap.Config)),
|
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)
|
resp.List = append(resp.List, record)
|
||||||
}
|
}
|
||||||
return resp, nil
|
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) {
|
func (l *QueryLotteryRecordsLogic) loadPrizeSnapshots(draws []modelLottery.Draw) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||||
if len(draws) == 0 {
|
if len(draws) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -299,7 +366,16 @@ func recordStatusFilter(s string) string {
|
|||||||
|
|
||||||
// ---- POST /claim ----------------------------------------------------------
|
// ---- 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 {
|
type ClaimLotteryPrizeLogic struct {
|
||||||
logger.Logger
|
logger.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -314,10 +390,110 @@ func NewClaimLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(_ *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) {
|
// ClaimLotteryPrize 提交领奖表单:pending_claim → reviewing,或 rejected → reviewing。
|
||||||
if currentUserId(l.ctx) == 0 {
|
func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(req *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) {
|
||||||
|
userId := currentUserId(l.ctx)
|
||||||
|
if userId == 0 {
|
||||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
}
|
}
|
||||||
// Stage 1 不实装人工领奖流程。
|
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)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// lottery_stage2_test.go — Stage 2 相关的纯函数单测。
|
||||||
|
// 用户 API 主流程(POST /claim)走 DB 事务 + auth middleware,集成在 QA 脚本里跑;
|
||||||
|
// 这里只补 handler / helper 层的纯逻辑分支。
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectClaimData_PreferClaimDataOverInput(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
req types.ClaimLotteryPrizeRequest
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"claim_data set", types.ClaimLotteryPrizeRequest{ClaimData: []byte(`{"a":1}`), Input: []byte(`{"b":2}`)}, `{"a":1}`},
|
||||||
|
{"only input", types.ClaimLotteryPrizeRequest{Input: []byte(`{"b":2}`)}, `{"b":2}`},
|
||||||
|
{"neither", types.ClaimLotteryPrizeRequest{}, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := string(selectClaimData(&tc.req))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("selectClaimData = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordStatusFilter_KnownStates(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", ""},
|
||||||
|
{"all", ""},
|
||||||
|
{"unclaimed", "unclaimed"},
|
||||||
|
{"paid", "paid"},
|
||||||
|
{"expired", "expired"},
|
||||||
|
{"unknown", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := recordStatusFilter(tc.in); got != tc.want {
|
||||||
|
t.Errorf("recordStatusFilter(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,18 +19,21 @@ const (
|
|||||||
UnmetActionBlock = "block"
|
UnmetActionBlock = "block"
|
||||||
UnmetActionShowReason = "show_reason"
|
UnmetActionShowReason = "show_reason"
|
||||||
|
|
||||||
// PrizeType* 是发奖 handler 注册表的键。Stage 1 只实现前三种。
|
// PrizeType* 是发奖 handler 注册表的键。
|
||||||
|
// Stage 1 已实装:vpn_duration / commission / none。
|
||||||
|
// Stage 2 新增人工奖:crypto / physical / manual_other。
|
||||||
PrizeTypeVPNDuration = "vpn_duration"
|
PrizeTypeVPNDuration = "vpn_duration"
|
||||||
PrizeTypeCommission = "commission"
|
PrizeTypeCommission = "commission"
|
||||||
PrizeTypeNone = "none"
|
PrizeTypeNone = "none"
|
||||||
// Stage 2/3 预留。
|
// Stage 2 人工奖类型(HIF-4)。
|
||||||
|
PrizeTypeCrypto = "crypto"
|
||||||
|
PrizeTypePhysical = "physical"
|
||||||
|
PrizeTypeManualOther = "manual_other"
|
||||||
|
// Stage 3 预留。
|
||||||
PrizeTypeBalance = "balance"
|
PrizeTypeBalance = "balance"
|
||||||
PrizeTypeGiftAmount = "gift_amount"
|
PrizeTypeGiftAmount = "gift_amount"
|
||||||
PrizeTypeCoupon = "coupon"
|
PrizeTypeCoupon = "coupon"
|
||||||
PrizeTypePoints = "points"
|
PrizeTypePoints = "points"
|
||||||
PrizeTypeEncrypted = "encrypted"
|
|
||||||
PrizeTypePhysical = "physical"
|
|
||||||
PrizeTypeManualOther = "manual_other"
|
|
||||||
|
|
||||||
// ChanceSource* 是次数入账触发源。
|
// ChanceSource* 是次数入账触发源。
|
||||||
ChanceSourceDailySignin = "daily_signin"
|
ChanceSourceDailySignin = "daily_signin"
|
||||||
@@ -45,6 +48,24 @@ const (
|
|||||||
DispatchStatePaid = "paid"
|
DispatchStatePaid = "paid"
|
||||||
DispatchStateExpired = "expired"
|
DispatchStateExpired = "expired"
|
||||||
DispatchStateFailed = "failed"
|
DispatchStateFailed = "failed"
|
||||||
|
|
||||||
|
// ClaimStatus* 是 lottery_claim.status 的取值(Stage 2)。
|
||||||
|
// pending_claim: 已入库,等用户填领奖信息。
|
||||||
|
// reviewing: 用户已提交,等运营审核。
|
||||||
|
// paying: 运营 approve,等运营线下打款/发货 + mark-paid。
|
||||||
|
// paid: 运营已录入 tx_hash / delivery_ref。终态。
|
||||||
|
// rejected: 运营 reject(可为 reviewing → rejected 或 paying → rejected);用户可再次提交。
|
||||||
|
// expired: pending_claim 超时未提交(业务规则:过期不补次数)。终态。
|
||||||
|
ClaimStatusPendingClaim = "pending_claim"
|
||||||
|
ClaimStatusReviewing = "reviewing"
|
||||||
|
ClaimStatusPaying = "paying"
|
||||||
|
ClaimStatusPaid = "paid"
|
||||||
|
ClaimStatusRejected = "rejected"
|
||||||
|
ClaimStatusExpired = "expired"
|
||||||
|
|
||||||
|
// DefaultClaimTTLHours 是 Stage 2 spec 里"默认 7 天"的实际编码:
|
||||||
|
// 每个奖品可通过 config.claim_ttl_hours 覆盖单个奖品的过期窗口。
|
||||||
|
DefaultClaimTTLHours = 24 * 7
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- 实体 -----------------------------------------------------------------
|
// ---- 实体 -----------------------------------------------------------------
|
||||||
@@ -154,6 +175,52 @@ type EligibilitySnapshot struct {
|
|||||||
|
|
||||||
func (EligibilitySnapshot) TableName() string { return "lottery_eligibility_snapshot" }
|
func (EligibilitySnapshot) TableName() string { return "lottery_eligibility_snapshot" }
|
||||||
|
|
||||||
|
// Claim 是 Stage 2 的人工奖领奖工单。lottery_draw ↔ lottery_claim 一对一
|
||||||
|
// (由 lottery_claim.draw_id UNIQUE 保证)。
|
||||||
|
//
|
||||||
|
// 生命周期:抽奖事务命中人工类奖品 → 同事务插入一行 status=pending_claim;
|
||||||
|
// 用户 POST /claim → 转 reviewing;运营 approve → paying → mark-paid → paid。
|
||||||
|
// 详细状态机见 02159_lottery_claim.up.sql 的注释。
|
||||||
|
type Claim struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"`
|
||||||
|
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||||
|
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||||
|
PrizeType string `gorm:"type:varchar(32);not null;comment:奖品类型(冗余便于后台过滤)"`
|
||||||
|
ClaimData string `gorm:"type:json;comment:用户提交的领奖表单(结构随 prize_type 变化)"`
|
||||||
|
Status string `gorm:"type:varchar(32);not null;default:'pending_claim';comment:状态"`
|
||||||
|
SubmittedAt *time.Time `gorm:"default:null;comment:用户提交领奖信息时间"`
|
||||||
|
ExpiresAt time.Time `gorm:"not null;comment:领奖窗口截止时间"`
|
||||||
|
ReviewedBy int64 `gorm:"type:bigint unsigned;default:0;comment:最近一次审核操作者"`
|
||||||
|
ReviewedAt *time.Time `gorm:"default:null;comment:最近一次审核时间"`
|
||||||
|
RejectReason string `gorm:"type:varchar(512);not null;default:'';comment:拒绝原因"`
|
||||||
|
TxHash string `gorm:"type:varchar(128);not null;default:'';comment:链上交易哈希"`
|
||||||
|
DeliveryRef string `gorm:"type:varchar(128);not null;default:'';comment:快递单号 / 发货单据编号"`
|
||||||
|
PaidAt *time.Time `gorm:"default:null;comment:运营标记打款/发货完成时间"`
|
||||||
|
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 对齐 02159 migration。
|
||||||
|
func (Claim) TableName() string { return "lottery_claim" }
|
||||||
|
|
||||||
|
// IsClaimStatusResubmittable 判断当前 claim 状态是否允许用户再次提交领奖数据。
|
||||||
|
// pending_claim(还没提交过)与 rejected(被运营拒绝后允许重填)算入。
|
||||||
|
// 其他状态(reviewing / paying / paid / expired)都禁止再提交。
|
||||||
|
func IsClaimStatusResubmittable(status string) bool {
|
||||||
|
return status == ClaimStatusPendingClaim || status == ClaimStatusRejected
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPrizeTypeManualClaim 判断某奖品类型是否属于"人工领奖"类别,
|
||||||
|
// 即 draw 时需要挂 lottery_claim 而不是自动发放。
|
||||||
|
func IsPrizeTypeManualClaim(prizeType string) bool {
|
||||||
|
switch prizeType {
|
||||||
|
case PrizeTypeCrypto, PrizeTypePhysical, PrizeTypeManualOther:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ---- JSON 结构体辅助 -------------------------------------------------------
|
// ---- JSON 结构体辅助 -------------------------------------------------------
|
||||||
|
|
||||||
// EligibilityRule 是持久化在 lottery_activity.eligibility 字段里的门槛规则树。
|
// EligibilityRule 是持久化在 lottery_activity.eligibility 字段里的门槛规则树。
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package lottery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -53,3 +54,4 @@ func (noopHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (D
|
|||||||
return DispatchResult{State: DispatchStateAutoClaimed, Message: "谢谢参与"}, nil
|
return DispatchResult{State: DispatchStateAutoClaimed, Message: "谢谢参与"}, nil
|
||||||
}
|
}
|
||||||
func (noopHandler) ValidateClaim(_ []byte) error { return nil }
|
func (noopHandler) ValidateClaim(_ []byte) error { return nil }
|
||||||
|
func (noopHandler) ClaimSchema() json.RawMessage { return nil }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package lottery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -95,7 +96,7 @@ type DispatchResult struct {
|
|||||||
|
|
||||||
// PrizeHandler 是一种奖品类型的发奖策略。Type 是注册键;IsAuto=true 表示
|
// PrizeHandler 是一种奖品类型的发奖策略。Type 是注册键;IsAuto=true 表示
|
||||||
// 抽奖事务内立刻发放,false 表示挂 pending_claim 等人工发(Stage 1 只实现
|
// 抽奖事务内立刻发放,false 表示挂 pending_claim 等人工发(Stage 1 只实现
|
||||||
// IsAuto=true 的三种)。
|
// IsAuto=true 的三种,Stage 2 补齐 crypto/physical/manual_other)。
|
||||||
//
|
//
|
||||||
// 幂等:所有实现必须以 lottery_draw.id 为外部 ref 做 check-before-write,
|
// 幂等:所有实现必须以 lottery_draw.id 为外部 ref 做 check-before-write,
|
||||||
// 避免重试重复发放。见 doc/lottery-stage1-plan.md 的"发奖账本"章节。
|
// 避免重试重复发放。见 doc/lottery-stage1-plan.md 的"发奖账本"章节。
|
||||||
@@ -104,12 +105,24 @@ type PrizeHandler interface {
|
|||||||
IsAuto() bool
|
IsAuto() bool
|
||||||
// Dispatch 在调用方的事务内执行;返回结果或错误。
|
// Dispatch 在调用方的事务内执行;返回结果或错误。
|
||||||
// 错误会导致抽奖事务回滚(次数不扣、draw 不落库),由用户侧重新发起。
|
// 错误会导致抽奖事务回滚(次数不扣、draw 不落库),由用户侧重新发起。
|
||||||
|
//
|
||||||
|
// 对 IsAuto()=false 的人工奖 handler,Dispatch 不会被抽奖服务调用;
|
||||||
|
// 实现返回 ErrDispatchNotSupported 即可。
|
||||||
Dispatch(ctx context.Context, tx *gorm.DB, req DispatchRequest) (DispatchResult, error)
|
Dispatch(ctx context.Context, tx *gorm.DB, req DispatchRequest) (DispatchResult, error)
|
||||||
// ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);Stage 1 的
|
// ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);auto handler
|
||||||
// auto handler 直接返回 nil 即可。
|
// 直接返回 nil 即可(默认 noopHandler / vpn_duration / commission 都不用)。
|
||||||
ValidateClaim(raw []byte) error
|
ValidateClaim(raw []byte) error
|
||||||
|
// ClaimSchema 返回该奖品的领奖表单 JSON Schema(Stage 2 才用)。
|
||||||
|
// - auto handler 返回 nil(前端拿到 nil / null 就知道不用弹表单)。
|
||||||
|
// - 人工奖 handler 返回一段合法 JSON Schema,前端据此动态渲染表单。
|
||||||
|
ClaimSchema() json.RawMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrDispatchNotSupported 是 IsAuto()=false handler 的 Dispatch 占位错误:
|
||||||
|
// 抽奖服务命中人工奖时不应调用 Dispatch,理论上永远不会返回给用户,仅供
|
||||||
|
// 单测断言与防御性编程使用。
|
||||||
|
var ErrDispatchNotSupported = errors.New("lottery: dispatch not supported for manual claim handler")
|
||||||
|
|
||||||
// ErrNotImplemented 是 Stage 1 骨架里 handler 的占位错误:抽奖流程接入前
|
// ErrNotImplemented 是 Stage 1 骨架里 handler 的占位错误:抽奖流程接入前
|
||||||
// 若不慎命中真实 handler 会立即失败,避免误发。
|
// 若不慎命中真实 handler 会立即失败,避免误发。
|
||||||
var ErrNotImplemented = errors.New("lottery: handler not yet wired to real business")
|
var ErrNotImplemented = errors.New("lottery: handler not yet wired to real business")
|
||||||
|
|||||||
@@ -180,6 +180,12 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
|||||||
UpdateCommission: srv.UserModel.UpdateCommission,
|
UpdateCommission: srv.UserModel.UpdateCommission,
|
||||||
WriteCommissionLog: lotteryhandler.WriteCommissionLog,
|
WriteCommissionLog: lotteryhandler.WriteCommissionLog,
|
||||||
}))
|
}))
|
||||||
|
// Stage 2 人工奖 handler:无外部依赖,直接注册。抽奖服务在 IsAuto()=false
|
||||||
|
// 时不会调用 Dispatch,而是由 draw 事务内挂 lottery_claim (pending_claim),
|
||||||
|
// 交给运营在后台审核 + 线下打款/发货。
|
||||||
|
registry.Register(lotteryhandler.NewCryptoHandler())
|
||||||
|
registry.Register(lotteryhandler.NewPhysicalHandler())
|
||||||
|
registry.Register(lotteryhandler.NewManualOtherHandler())
|
||||||
srv.LotteryRegistry = registry
|
srv.LotteryRegistry = registry
|
||||||
|
|
||||||
drawLimiter := limit.NewPeriodLimit(1, 1, rds, "lottery:draw:rate:")
|
drawLimiter := limit.NewPeriodLimit(1, 1, rds, "lottery:draw:rate:")
|
||||||
|
|||||||
+124
-3
@@ -82,6 +82,11 @@ type LotteryClaimStatus struct {
|
|||||||
Required bool `json:"required"`
|
Required bool `json:"required"`
|
||||||
AutoClaimed bool `json:"auto_claimed"`
|
AutoClaimed bool `json:"auto_claimed"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
|
// ExpiresAt 是领奖窗口截止时间(Unix 秒;0 表示不适用,例如自动奖)。
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||||
|
// ClaimFormSchema 是人工奖的领奖表单 JSON Schema(前端据此动态渲染)。
|
||||||
|
// nil 表示不适用(自动奖 / 谢谢参与)。
|
||||||
|
ClaimFormSchema json.RawMessage `json:"claim_form_schema,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DrawLotteryResponse is what /draw returns.
|
// DrawLotteryResponse is what /draw returns.
|
||||||
@@ -111,6 +116,25 @@ type LotteryRecord struct {
|
|||||||
Prize *DrawnPrize `json:"prize"`
|
Prize *DrawnPrize `json:"prize"`
|
||||||
DispatchState string `json:"dispatch_state"`
|
DispatchState string `json:"dispatch_state"`
|
||||||
DrawnAt int64 `json:"drawn_at"`
|
DrawnAt int64 `json:"drawn_at"`
|
||||||
|
// Claim 是人工奖的工单详情;自动奖 / 未中奖时为 nil。Stage 2 新增。
|
||||||
|
Claim *LotteryRecordClaim `json:"claim,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryRecordClaim 是 GET /records 里"人工奖工单"的用户视图。
|
||||||
|
// 状态:pending_claim / reviewing / paying / paid / rejected / expired。
|
||||||
|
// - 用户在 pending_claim 或 rejected 时可再次提交(前端读 ClaimFormSchema 渲染)。
|
||||||
|
// - paying / paid / expired 时前端只展示状态与打款/发货结果。
|
||||||
|
type LotteryRecordClaim struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
ClaimData json.RawMessage `json:"claim_data,omitempty"`
|
||||||
|
SubmittedAt int64 `json:"submitted_at,omitempty"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
TxHash string `json:"tx_hash,omitempty"`
|
||||||
|
DeliveryRef string `json:"delivery_ref,omitempty"`
|
||||||
|
RejectReason string `json:"reject_reason,omitempty"`
|
||||||
|
PaidAt int64 `json:"paid_at,omitempty"`
|
||||||
|
// ClaimFormSchema 仅当 status 允许再提交(pending_claim / rejected)时下发。
|
||||||
|
ClaimFormSchema json.RawMessage `json:"claim_form_schema,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLotteryRecordsResponse is the paginated payload.
|
// GetLotteryRecordsResponse is the paginated payload.
|
||||||
@@ -119,14 +143,20 @@ type GetLotteryRecordsResponse struct {
|
|||||||
List []LotteryRecord `json:"list"`
|
List []LotteryRecord `json:"list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimLotteryPrizeRequest is Stage 2 spec; Stage 1 always returns 4010.
|
// ClaimLotteryPrizeRequest is Stage 2: submit claim data for a manual prize.
|
||||||
type ClaimLotteryPrizeRequest struct {
|
type ClaimLotteryPrizeRequest struct {
|
||||||
DrawId int64 `json:"draw_id" validate:"required"`
|
DrawId int64 `json:"draw_id" validate:"required"`
|
||||||
|
ClaimData json.RawMessage `json:"claim_data"`
|
||||||
|
// Input 是 Stage 1 骨架里预留的旧字段名,为了不破坏前端契约保留。
|
||||||
|
// Deprecated: Prefer ClaimData for new callers.
|
||||||
Input json.RawMessage `json:"input,omitempty"`
|
Input json.RawMessage `json:"input,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimLotteryPrizeResponse mirrors Stage 2 shape; Stage 1 unused.
|
// ClaimLotteryPrizeResponse mirrors Stage 2 shape.
|
||||||
type ClaimLotteryPrizeResponse struct{}
|
type ClaimLotteryPrizeResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
SubmittedAt int64 `json:"submitted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Admin API --------------------------------------------------------------
|
// ---- Admin API --------------------------------------------------------------
|
||||||
|
|
||||||
@@ -266,3 +296,94 @@ type GrantAdminLotteryChanceRequest struct {
|
|||||||
Amount int `json:"amount" validate:"required,min=1"`
|
Amount int `json:"amount" validate:"required,min=1"`
|
||||||
SourceRef string `json:"source_ref" validate:"required,max=128"`
|
SourceRef string `json:"source_ref" validate:"required,max=128"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Stage 2 Admin Claims --------------------------------------------------
|
||||||
|
|
||||||
|
// ListAdminLotteryClaimsRequest lists claims filtered by type/status/activity.
|
||||||
|
// Page/Size 默认 by logic 层(Page<=0 → 1;Size<=0||>200 → 20)。
|
||||||
|
type ListAdminLotteryClaimsRequest struct {
|
||||||
|
Type string `form:"type,omitempty"`
|
||||||
|
Status string `form:"status,omitempty"`
|
||||||
|
ActivityId int64 `form:"activity_id,omitempty"`
|
||||||
|
UserId int64 `form:"user_id,omitempty"`
|
||||||
|
From int64 `form:"from,omitempty"` // Unix 秒
|
||||||
|
To int64 `form:"to,omitempty"` // Unix 秒
|
||||||
|
Page int `form:"page"`
|
||||||
|
Size int `form:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryClaim is the admin-facing view of one claim row.
|
||||||
|
type AdminLotteryClaim struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
DrawId int64 `json:"draw_id"`
|
||||||
|
ActivityId int64 `json:"activity_id"`
|
||||||
|
User AdminLotteryClaimUser `json:"user"`
|
||||||
|
Prize AdminLotteryClaimPrize `json:"prize"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ClaimData json.RawMessage `json:"claim_data,omitempty"`
|
||||||
|
SubmittedAt int64 `json:"submitted_at,omitempty"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
ReviewedBy int64 `json:"reviewed_by,omitempty"`
|
||||||
|
ReviewedAt int64 `json:"reviewed_at,omitempty"`
|
||||||
|
RejectReason string `json:"reject_reason,omitempty"`
|
||||||
|
TxHash string `json:"tx_hash,omitempty"`
|
||||||
|
DeliveryRef string `json:"delivery_ref,omitempty"`
|
||||||
|
PaidAt int64 `json:"paid_at,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryClaimUser 是 claim 列表里附带的用户简况。
|
||||||
|
type AdminLotteryClaimUser struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryClaimPrize 是 claim 列表里附带的奖品简况(含 config,供审核判断)。
|
||||||
|
type AdminLotteryClaimPrize struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminLotteryClaimsResponse pages.
|
||||||
|
type ListAdminLotteryClaimsResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Claims []AdminLotteryClaim `json:"claims"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminApproveClaimRequest 转 reviewing → paying。
|
||||||
|
type AdminApproveClaimRequest struct {
|
||||||
|
Id int64 `json:"id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRejectClaimRequest 转 reviewing/paying → rejected,reason 必填。
|
||||||
|
type AdminRejectClaimRequest struct {
|
||||||
|
Id int64 `json:"id" validate:"required"`
|
||||||
|
Reason string `json:"reason" validate:"required,max=512"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMarkPaidClaimRequest 转 paying → paid。
|
||||||
|
// tx_hash / delivery_ref 至少填一个;crypto 必填 tx_hash,physical 必填
|
||||||
|
// delivery_ref,manual_other 至少填一个(业务层做类型检查)。
|
||||||
|
// paid_at 可选,缺省时用服务端 now。
|
||||||
|
type AdminMarkPaidClaimRequest struct {
|
||||||
|
Id int64 `json:"id" validate:"required"`
|
||||||
|
TxHash string `json:"tx_hash,omitempty" validate:"max=128"`
|
||||||
|
DeliveryRef string `json:"delivery_ref,omitempty" validate:"max=128"`
|
||||||
|
PaidAt int64 `json:"paid_at,omitempty"` // Unix 秒
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryClaimsSummary 是 /claims/summary 的响应。overdue = 待用户填的
|
||||||
|
// pending_claim 中已过期的条数(不重叠 status=expired)。
|
||||||
|
type AdminLotteryClaimsSummary struct {
|
||||||
|
Crypto AdminLotteryClaimsStatusCount `json:"crypto"`
|
||||||
|
Physical AdminLotteryClaimsStatusCount `json:"physical"`
|
||||||
|
ManualOther AdminLotteryClaimsStatusCount `json:"manual_other"`
|
||||||
|
Overdue int64 `json:"overdue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryClaimsStatusCount 是各类型的关键状态计数。
|
||||||
|
type AdminLotteryClaimsStatusCount struct {
|
||||||
|
Reviewing int64 `json:"reviewing"`
|
||||||
|
Paying int64 `json:"paying"`
|
||||||
|
}
|
||||||
|
|||||||
+13
-3
@@ -152,18 +152,28 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Lottery error (100xxx range; 3-digit business = 100)
|
// Lottery error (100xxx range; 3-digit business = 100)
|
||||||
// codes align with HIF-3 spec (Stage 1 issue description):
|
// codes align with HIF-3 spec (Stage 1 issue description) + HIF-4 (Stage 2):
|
||||||
|
//
|
||||||
// 4001 not_eligible / 4002 no_chances / 4003 activity_ended
|
// 4001 not_eligible / 4002 no_chances / 4003 activity_ended
|
||||||
// 4004 rate_limited / 4010 not_claimable / 5001 internal_error
|
// 4004 rate_limited / 4005 already_submitted / 4006 invalid_claim_data
|
||||||
|
// 4007 draw_not_found / 4008 not_your_draw / 4009 claim_expired
|
||||||
|
// 4010 not_claimable / 5001 internal_error
|
||||||
|
//
|
||||||
// wire-level ints stay unique across the whole codebase.
|
// wire-level ints stay unique across the whole codebase.
|
||||||
const (
|
const (
|
||||||
LotteryNotEligible uint32 = 100001 // 用户未通过门槛
|
LotteryNotEligible uint32 = 100001 // 用户未通过门槛
|
||||||
LotteryNoChances uint32 = 100002 // 用户已无剩余次数
|
LotteryNoChances uint32 = 100002 // 用户已无剩余次数
|
||||||
LotteryActivityEnded uint32 = 100003 // 活动未开始 / 已结束 / 或 feature flag 关闭
|
LotteryActivityEnded uint32 = 100003 // 活动未开始 / 已结束 / 或 feature flag 关闭
|
||||||
LotteryRateLimited uint32 = 100004 // 触发每秒 1 次限流
|
LotteryRateLimited uint32 = 100004 // 触发每秒 1 次限流
|
||||||
LotteryNotClaimable uint32 = 100010 // Stage 2 领奖入口占位
|
LotteryAlreadySubmitted uint32 = 100005 // Stage 2: 该 draw 已经处于不可再提交的状态
|
||||||
|
LotteryInvalidClaimData uint32 = 100006 // Stage 2: 领奖表单校验失败
|
||||||
|
LotteryDrawNotFound uint32 = 100007 // Stage 2: draw_id 不存在
|
||||||
|
LotteryNotYourDraw uint32 = 100008 // Stage 2: draw 不属于当前登录用户
|
||||||
|
LotteryClaimExpired uint32 = 100009 // Stage 2: 领奖窗口已过期
|
||||||
|
LotteryNotClaimable uint32 = 100010 // Stage 2: 该 draw 不需要 / 不允许领奖(自动奖 / 未中奖)
|
||||||
LotteryInternalError uint32 = 100500 // 内部错误
|
LotteryInternalError uint32 = 100500 // 内部错误
|
||||||
LotteryRuleTooDeep uint32 = 100020 // rules JSON 深度超限
|
LotteryRuleTooDeep uint32 = 100020 // rules JSON 深度超限
|
||||||
LotteryRuleTooMany uint32 = 100021 // rules JSON 节点数超限
|
LotteryRuleTooMany uint32 = 100021 // rules JSON 节点数超限
|
||||||
LotteryRuleTooLarge uint32 = 100022 // rules JSON 体积超限
|
LotteryRuleTooLarge uint32 = 100022 // rules JSON 体积超限
|
||||||
|
LotteryClaimStateInvalid uint32 = 100011 // Stage 2: 运营操作时状态机不允许(如非 reviewing 却 approve)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -118,7 +118,13 @@ func init() {
|
|||||||
LotteryNoChances: "抽奖次数不足",
|
LotteryNoChances: "抽奖次数不足",
|
||||||
LotteryActivityEnded: "抽奖活动未开放",
|
LotteryActivityEnded: "抽奖活动未开放",
|
||||||
LotteryRateLimited: "抽奖操作过于频繁,请稍后再试",
|
LotteryRateLimited: "抽奖操作过于频繁,请稍后再试",
|
||||||
|
LotteryAlreadySubmitted: "该奖品的领奖信息已提交,无需重复提交",
|
||||||
|
LotteryInvalidClaimData: "领奖信息格式不正确",
|
||||||
|
LotteryDrawNotFound: "抽奖记录不存在",
|
||||||
|
LotteryNotYourDraw: "无权操作此抽奖记录",
|
||||||
|
LotteryClaimExpired: "领奖窗口已过期",
|
||||||
LotteryNotClaimable: "该奖品当前不支持领取",
|
LotteryNotClaimable: "该奖品当前不支持领取",
|
||||||
|
LotteryClaimStateInvalid: "当前工单状态不允许此操作",
|
||||||
LotteryInternalError: "抽奖服务暂时不可用",
|
LotteryInternalError: "抽奖服务暂时不可用",
|
||||||
LotteryRuleTooDeep: "规则树嵌套层数超限",
|
LotteryRuleTooDeep: "规则树嵌套层数超限",
|
||||||
LotteryRuleTooMany: "规则树节点数超限",
|
LotteryRuleTooMany: "规则树节点数超限",
|
||||||
|
|||||||
Executable
+215
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# HIF-4 Stage 2 lottery — end-to-end curl for manual-claim workflow (crypto / physical / manual_other).
|
||||||
|
#
|
||||||
|
# 环境准备:
|
||||||
|
# 1. Lottery: { Enable: true } in config.yaml
|
||||||
|
# 2. 已有可用的 admin token 和 user token
|
||||||
|
# 3. 已跑过 Stage 1 且现有活动状态是 running(或者用本脚本重新建一个)
|
||||||
|
#
|
||||||
|
# 使用:
|
||||||
|
# BASE_URL=http://127.0.0.1:8080 ADMIN_TOKEN=<jwt> USER_TOKEN=<jwt> USER_ID=<int> \
|
||||||
|
# bash qa/lottery/stage2_curl.sh
|
||||||
|
#
|
||||||
|
# 覆盖:
|
||||||
|
# ✓ 三种人工奖类型都能配置、抽中、领取
|
||||||
|
# ✓ 状态机:pending_claim → reviewing → paying → paid
|
||||||
|
# ✓ 状态机:reviewing → rejected → reviewing(用户重新提交)
|
||||||
|
# ✓ 过期窗口读取(claim_ttl_hours 覆盖默认 7d)
|
||||||
|
# ✓ 后台工单列表 + summary
|
||||||
|
# ✓ mark-paid 前置校验(crypto 必填 tx_hash / physical 必填 delivery_ref)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
|
||||||
|
ADMIN_TOKEN="${ADMIN_TOKEN:-CHANGE_ME}"
|
||||||
|
USER_TOKEN="${USER_TOKEN:-CHANGE_ME}"
|
||||||
|
USER_ID="${USER_ID:-1}"
|
||||||
|
|
||||||
|
hdr_admin=(-H "Authorization: Bearer ${ADMIN_TOKEN}" -H "Content-Type: application/json")
|
||||||
|
hdr_user=(-H "Authorization: Bearer ${USER_TOKEN}" -H "Content-Type: application/json")
|
||||||
|
|
||||||
|
log() { printf '\n\033[1;34m▶ %s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
expect_ok() {
|
||||||
|
local body="$1" step="$2" code
|
||||||
|
code=$(printf '%s' "$body" | jq -r '.code // 0')
|
||||||
|
if [[ "$code" != "0" && "$code" != "200" ]]; then
|
||||||
|
printf '\033[1;31m✗ %s failed: %s\033[0m\n' "$step" "$body" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '\033[1;32m✓ %s\033[0m\n' "$step"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_code() {
|
||||||
|
local body="$1" step="$2" expected="$3" code
|
||||||
|
code=$(printf '%s' "$body" | jq -r '.code // 0')
|
||||||
|
if [[ "$code" != "$expected" ]]; then
|
||||||
|
printf '\033[1;31m✗ %s: expected code=%s, got %s (body=%s)\033[0m\n' "$step" "$expected" "$code" "$body" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '\033[1;32m✓ %s (code=%s)\033[0m\n' "$step" "$expected"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Admin: build activity + three manual prizes -------------------------
|
||||||
|
|
||||||
|
log "Create Stage 2 activity"
|
||||||
|
start_at=$(date +%s)
|
||||||
|
end_at=$((start_at + 86400 * 30))
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/activities" "${hdr_admin[@]}" -d "$(cat <<JSON
|
||||||
|
{"title":"QA Stage 2 抽奖","description":"人工奖 e2e","start_at":${start_at},"end_at":${end_at},"grid_size":9,
|
||||||
|
"eligibility":{},"chance_sources":[{"source":"manual_grant","amount":10}],"unmet_action":"block"}
|
||||||
|
JSON
|
||||||
|
)")
|
||||||
|
expect_ok "$resp" "activity create"
|
||||||
|
ACTIVITY_ID=$(printf '%s' "$resp" | jq -r '.data.id')
|
||||||
|
|
||||||
|
log "Add crypto prize (BTC, unlimited, 48h TTL)"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "$(cat <<JSON
|
||||||
|
{"activity_id":${ACTIVITY_ID},"slot":0,"type":"crypto","name":"1 BTC","icon_url":"",
|
||||||
|
"config":{"amount":"1","currency":"BTC","networks":["BTC","TRX"],"claim_ttl_hours":48},
|
||||||
|
"weight":34,"is_fallback":false}
|
||||||
|
JSON
|
||||||
|
)")
|
||||||
|
expect_ok "$resp" "prize crypto"
|
||||||
|
CRYPTO_PRIZE_ID=$(printf '%s' "$resp" | jq -r '.data.id')
|
||||||
|
|
||||||
|
log "Add physical prize (T-shirt)"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "$(cat <<JSON
|
||||||
|
{"activity_id":${ACTIVITY_ID},"slot":1,"type":"physical","name":"限量 T 恤","icon_url":"",
|
||||||
|
"config":{"sku_id":"tee-01","sku_name":"限量 T 恤"},
|
||||||
|
"weight":33,"is_fallback":false}
|
||||||
|
JSON
|
||||||
|
)")
|
||||||
|
expect_ok "$resp" "prize physical"
|
||||||
|
|
||||||
|
log "Add manual_other prize"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "$(cat <<JSON
|
||||||
|
{"activity_id":${ACTIVITY_ID},"slot":2,"type":"manual_other","name":"运营手工奖","icon_url":"",
|
||||||
|
"config":{"desc":"运营线下联系发放"},
|
||||||
|
"weight":33,"is_fallback":false}
|
||||||
|
JSON
|
||||||
|
)")
|
||||||
|
expect_ok "$resp" "prize manual_other"
|
||||||
|
|
||||||
|
log "Publish activity"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/activities/publish" "${hdr_admin[@]}" -d "{\"id\":${ACTIVITY_ID}}")
|
||||||
|
expect_ok "$resp" "publish"
|
||||||
|
|
||||||
|
log "Grant 10 chances to user ${USER_ID}"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/chances/grant" "${hdr_admin[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"user_id\":${USER_ID},\"amount\":10,\"source_ref\":\"qa-stage2\"}")
|
||||||
|
expect_ok "$resp" "chances grant"
|
||||||
|
|
||||||
|
# ---- User: draw until we land on a crypto prize -------------------------
|
||||||
|
|
||||||
|
CRYPTO_DRAW_ID=""
|
||||||
|
attempts=0
|
||||||
|
while [[ -z "${CRYPTO_DRAW_ID}" && "$attempts" -lt 20 ]]; do
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
nonce=$(uuidgen 2>/dev/null || python3 -c "import uuid;print(uuid.uuid4())")
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"client_nonce\":\"${nonce}\"}")
|
||||||
|
expect_ok "$resp" "draw #${attempts}"
|
||||||
|
ptype=$(printf '%s' "$resp" | jq -r '.data.prize.type // ""')
|
||||||
|
if [[ "$ptype" == "crypto" ]]; then
|
||||||
|
CRYPTO_DRAW_ID=$(printf '%s' "$resp" | jq -r '.data.draw_id')
|
||||||
|
printf 'crypto draw id: %s\n' "$CRYPTO_DRAW_ID"
|
||||||
|
printf 'claim response fields:\n%s\n' "$(printf '%s' "$resp" | jq '.data.claim')"
|
||||||
|
# 验证 schema 包含 enum
|
||||||
|
if ! printf '%s' "$resp" | jq -e '.data.claim.claim_form_schema.properties.network.enum | length > 0' >/dev/null; then
|
||||||
|
echo "✗ crypto schema missing network enum" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ -z "$CRYPTO_DRAW_ID" ]]; then
|
||||||
|
echo "✗ 20 draws still no crypto — weight/rand seed check" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- User: submit claim (crypto) ----------------------------------------
|
||||||
|
|
||||||
|
log "POST /claim with valid BTC address"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "$(cat <<JSON
|
||||||
|
{"draw_id":${CRYPTO_DRAW_ID},"claim_data":{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}}
|
||||||
|
JSON
|
||||||
|
)")
|
||||||
|
expect_ok "$resp" "claim submit (crypto)"
|
||||||
|
|
||||||
|
log "POST /claim second time — expect 100005 already_submitted"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "$(cat <<JSON
|
||||||
|
{"draw_id":${CRYPTO_DRAW_ID},"claim_data":{"network":"BTC","address":"bc1qanotheraddresslongenoughxxxx"}}
|
||||||
|
JSON
|
||||||
|
)")
|
||||||
|
expect_code "$resp" "claim duplicate reject" "100005"
|
||||||
|
|
||||||
|
# ---- Admin: find claim id in list ----------------------------------------
|
||||||
|
|
||||||
|
log "GET /admin/lottery/claims?status=reviewing"
|
||||||
|
resp=$(curl -sS "${BASE_URL}/v1/admin/lottery/claims?status=reviewing&activity_id=${ACTIVITY_ID}" "${hdr_admin[@]}")
|
||||||
|
expect_ok "$resp" "list claims"
|
||||||
|
CLAIM_ID=$(printf '%s' "$resp" | jq -r ".data.claims[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .id" | head -1)
|
||||||
|
if [[ -z "$CLAIM_ID" ]]; then
|
||||||
|
echo "✗ could not locate claim for crypto draw ${CRYPTO_DRAW_ID}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'claim id: %s\n' "$CLAIM_ID"
|
||||||
|
|
||||||
|
# ---- Admin: reject then user resubmits ----------------------------------
|
||||||
|
|
||||||
|
log "POST /claims/reject"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/reject" "${hdr_admin[@]}" -d "{\"id\":${CLAIM_ID},\"reason\":\"地址格式待核对\"}")
|
||||||
|
expect_ok "$resp" "reject"
|
||||||
|
|
||||||
|
log "GET /records — should show rejected + claim_form_schema"
|
||||||
|
resp=$(curl -sS "${BASE_URL}/v1/lottery/records?activity_id=${ACTIVITY_ID}" "${hdr_user[@]}")
|
||||||
|
expect_ok "$resp" "records after reject"
|
||||||
|
status=$(printf '%s' "$resp" | jq -r ".data.list[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .claim.status")
|
||||||
|
if [[ "$status" != "rejected" ]]; then
|
||||||
|
echo "✗ expected status=rejected, got '$status'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! printf '%s' "$resp" | jq -e ".data.list[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .claim.claim_form_schema.properties.network.enum" >/dev/null; then
|
||||||
|
echo "✗ rejected state must still expose claim_form_schema for resubmit" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "User resubmits after reject"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "$(cat <<JSON
|
||||||
|
{"draw_id":${CRYPTO_DRAW_ID},"claim_data":{"network":"BTC","address":"bc1qgoodaddresslongenoughforthevalidator1"}}
|
||||||
|
JSON
|
||||||
|
)")
|
||||||
|
expect_ok "$resp" "resubmit after reject"
|
||||||
|
|
||||||
|
# ---- Admin: approve then mark-paid --------------------------------------
|
||||||
|
|
||||||
|
log "POST /claims/approve (reviewing → paying)"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/approve" "${hdr_admin[@]}" -d "{\"id\":${CLAIM_ID}}")
|
||||||
|
expect_ok "$resp" "approve"
|
||||||
|
|
||||||
|
log "POST /claims/mark-paid (missing tx_hash → 400)"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/mark-paid" "${hdr_admin[@]}" -d "{\"id\":${CLAIM_ID}}")
|
||||||
|
expect_code "$resp" "mark-paid rejects empty tx_hash for crypto" "400"
|
||||||
|
|
||||||
|
log "POST /claims/mark-paid (with tx_hash)"
|
||||||
|
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/mark-paid" "${hdr_admin[@]}" -d "$(cat <<JSON
|
||||||
|
{"id":${CLAIM_ID},"tx_hash":"0xdeadbeefcafebabe12345678"}
|
||||||
|
JSON
|
||||||
|
)")
|
||||||
|
expect_ok "$resp" "mark paid"
|
||||||
|
|
||||||
|
log "GET /records — final should show paid + tx_hash"
|
||||||
|
resp=$(curl -sS "${BASE_URL}/v1/lottery/records?activity_id=${ACTIVITY_ID}" "${hdr_user[@]}")
|
||||||
|
expect_ok "$resp" "records final"
|
||||||
|
tx=$(printf '%s' "$resp" | jq -r ".data.list[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .claim.tx_hash")
|
||||||
|
if [[ "$tx" != "0xdeadbeefcafebabe12345678" ]]; then
|
||||||
|
echo "✗ expected tx_hash=0xdead..., got '$tx'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Summary endpoint ---------------------------------------------------
|
||||||
|
|
||||||
|
log "GET /admin/lottery/claims/summary"
|
||||||
|
resp=$(curl -sS "${BASE_URL}/v1/admin/lottery/claims/summary" "${hdr_admin[@]}")
|
||||||
|
expect_ok "$resp" "summary"
|
||||||
|
printf 'summary:\n%s\n' "$(printf '%s' "$resp" | jq '.data')"
|
||||||
|
|
||||||
|
printf '\n\033[1;32m✅ Stage 2 end-to-end curl passed.\033[0m\n'
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
iapLogic "github.com/perfect-panel/server/queue/logic/iap"
|
iapLogic "github.com/perfect-panel/server/queue/logic/iap"
|
||||||
|
lotteryLogic "github.com/perfect-panel/server/queue/logic/lottery"
|
||||||
orderLogic "github.com/perfect-panel/server/queue/logic/order"
|
orderLogic "github.com/perfect-panel/server/queue/logic/order"
|
||||||
smslogic "github.com/perfect-panel/server/queue/logic/sms"
|
smslogic "github.com/perfect-panel/server/queue/logic/sms"
|
||||||
"github.com/perfect-panel/server/queue/logic/subscription"
|
"github.com/perfect-panel/server/queue/logic/subscription"
|
||||||
@@ -51,4 +52,7 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
|
|||||||
|
|
||||||
// Stuck order recovery
|
// Stuck order recovery
|
||||||
mux.Handle(types.SchedulerStuckOrderRecovery, orderLogic.NewStuckOrderRecoveryLogic(serverCtx))
|
mux.Handle(types.SchedulerStuckOrderRecovery, orderLogic.NewStuckOrderRecoveryLogic(serverCtx))
|
||||||
|
|
||||||
|
// Lottery Stage 2: 过期领奖工单每小时扫描一次
|
||||||
|
mux.Handle(types.SchedulerLotteryExpireClaim, lotteryLogic.NewExpireClaimLogic(serverCtx))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Package lotteryLogic 承载抽奖相关的 asynq 后台任务。
|
||||||
|
// Stage 2 唯一一个后台任务:每小时扫描 pending_claim 且已过期的领奖工单,
|
||||||
|
// 状态推到 expired。业务规则:"过期不补次数",所以只更新 claim/draw,不做补偿。
|
||||||
|
package lotteryLogic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExpireClaimLogic 扫描已过期但状态仍是 pending_claim 的 lottery_claim
|
||||||
|
// (用户没在窗口内提交领奖信息),把它们推到 expired 终态。同时把关联的
|
||||||
|
// lottery_draw.dispatch_state 也推到 expired,让 GET /records 与后台视图一致。
|
||||||
|
type ExpireClaimLogic struct {
|
||||||
|
svc *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExpireClaimLogic(svc *svc.ServiceContext) *ExpireClaimLogic {
|
||||||
|
return &ExpireClaimLogic{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessTask 每小时被 asynq scheduler 触发。
|
||||||
|
//
|
||||||
|
// SQL 采用两步:
|
||||||
|
// 1. UPDATE lottery_claim SET status='expired' WHERE status='pending_claim' AND expires_at < now
|
||||||
|
// 2. UPDATE lottery_draw SET dispatch_state='expired'
|
||||||
|
// WHERE dispatch_state='pending_claim'
|
||||||
|
// AND id IN (SELECT draw_id FROM lottery_claim WHERE status='expired' AND ...)
|
||||||
|
//
|
||||||
|
// 步骤 1 由 lottery_claim.status 的语义主导;步骤 2 是为了避免 draw 视图脱节。
|
||||||
|
// 单次 batch 不设上限——线上 pending_claim 过期量级远小于 asynq 单任务的处理窗口,
|
||||||
|
// 若未来量级上来再改成 loop + LIMIT。
|
||||||
|
func (l *ExpireClaimLogic) ProcessTask(ctx context.Context, _ *asynq.Task) error {
|
||||||
|
now := time.Now()
|
||||||
|
claimRes := l.svc.DB.WithContext(ctx).
|
||||||
|
Model(&modelLottery.Claim{}).
|
||||||
|
Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, now).
|
||||||
|
Update("status", modelLottery.ClaimStatusExpired)
|
||||||
|
if claimRes.Error != nil {
|
||||||
|
logger.WithContext(ctx).Error("[LotteryExpireClaim] update lottery_claim failed",
|
||||||
|
logger.Field("error", claimRes.Error.Error()),
|
||||||
|
)
|
||||||
|
return claimRes.Error
|
||||||
|
}
|
||||||
|
if claimRes.RowsAffected == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRes := l.svc.DB.WithContext(ctx).
|
||||||
|
Model(&modelLottery.Draw{}).
|
||||||
|
Where("dispatch_state = ? AND id IN (?)",
|
||||||
|
modelLottery.DispatchStatePendingClaim,
|
||||||
|
l.svc.DB.WithContext(ctx).
|
||||||
|
Model(&modelLottery.Claim{}).
|
||||||
|
Select("draw_id").
|
||||||
|
Where("status = ?", modelLottery.ClaimStatusExpired)).
|
||||||
|
Update("dispatch_state", modelLottery.DispatchStateExpired)
|
||||||
|
if drawRes.Error != nil {
|
||||||
|
// draw 更新失败不阻断——status 已 expired,缺 draw 一致性下次跑还能补上。
|
||||||
|
logger.WithContext(ctx).Error("[LotteryExpireClaim] update lottery_draw failed",
|
||||||
|
logger.Field("error", drawRes.Error.Error()),
|
||||||
|
logger.Field("claim_expired_count", claimRes.RowsAffected),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logger.WithContext(ctx).Info("[LotteryExpireClaim] expired claims swept",
|
||||||
|
logger.Field("claim_expired_count", claimRes.RowsAffected),
|
||||||
|
logger.Field("draw_updated_count", drawRes.RowsAffected),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// expireClaimLogic_test.go — 用 sqlmock 断言 SQL 契约(不涉及真实 DB)。
|
||||||
|
package lotteryLogic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||||
|
if strings.Contains(actual, expected) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("actual sql does not contain expected: " + expected)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("gorm: %v", err)
|
||||||
|
}
|
||||||
|
return db, mock, func() { _ = sqlDB.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpireClaimLogic_NoRowsAffectedSkipsSecondUpdate(t *testing.T) {
|
||||||
|
db, mock, cleanup := newTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// UPDATE lottery_claim 但 RowsAffected == 0 → 应直接返回,不再打 UPDATE lottery_draw
|
||||||
|
mock.ExpectExec("UPDATE `lottery_claim`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
|
||||||
|
logic := NewExpireClaimLogic(&svc.ServiceContext{DB: db})
|
||||||
|
if err := logic.ProcessTask(context.Background(), asynq.NewTask("", nil)); err != nil {
|
||||||
|
t.Fatalf("ProcessTask: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpireClaimLogic_ExpiresAndCascadesDraw(t *testing.T) {
|
||||||
|
db, mock, cleanup := newTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectExec("UPDATE `lottery_claim`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 3))
|
||||||
|
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 3))
|
||||||
|
|
||||||
|
logic := NewExpireClaimLogic(&svc.ServiceContext{DB: db})
|
||||||
|
if err := logic.ProcessTask(context.Background(), asynq.NewTask("", nil)); err != nil {
|
||||||
|
t.Fatalf("ProcessTask: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,5 @@ const (
|
|||||||
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
|
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
|
||||||
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
|
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
|
||||||
SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单
|
SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单
|
||||||
|
SchedulerLotteryExpireClaim = "scheduler:lottery:expire:claim" // 抽奖 Stage 2:每小时扫描过期 pending_claim
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ func (m *Service) Start() {
|
|||||||
logger.Errorf("register stuck order recovery task failed: %s", err.Error())
|
logger.Errorf("register stuck order recovery task failed: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lottery Stage 2: 每小时扫描过期 pending_claim → expired
|
||||||
|
lotteryExpireTask := asynq.NewTask(types.SchedulerLotteryExpireClaim, nil)
|
||||||
|
if _, err := m.server.Register("@every 1h", lotteryExpireTask, asynq.MaxRetry(1)); err != nil {
|
||||||
|
logger.Errorf("register lottery expire claim task failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
if err := m.server.Run(); err != nil {
|
if err := m.server.Run(); err != nil {
|
||||||
logger.Errorf("run scheduler failed: %s", err.Error())
|
logger.Errorf("run scheduler failed: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user