diff --git a/apis/admin/lottery.api b/apis/admin/lottery.api index 0120241..c30172d 100644 --- a/apis/admin/lottery.api +++ b/apis/admin/lottery.api @@ -62,4 +62,25 @@ service ppanel { @doc "Manually grant N chances to a user (idempotent by source_ref)" @handler GrantLotteryChance 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) } diff --git a/initialize/migrate/database/02159_lottery_claim.down.sql b/initialize/migrate/database/02159_lottery_claim.down.sql new file mode 100644 index 0000000..7d50b5e --- /dev/null +++ b/initialize/migrate/database/02159_lottery_claim.down.sql @@ -0,0 +1,3 @@ +-- 02159 抽奖活动 Stage 2 down migration +-- Stage 2 只新增 1 张表,回滚直接 drop 即可。 +DROP TABLE IF EXISTS `lottery_claim`; diff --git a/initialize/migrate/database/02159_lottery_claim.up.sql b/initialize/migrate/database/02159_lottery_claim.up.sql new file mode 100644 index 0000000..2b31046 --- /dev/null +++ b/initialize/migrate/database/02159_lottery_claim.up.sql @@ -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='抽奖人工奖领奖工单'; diff --git a/internal/handler/admin/lottery/admin_claims_handler.go b/internal/handler/admin/lottery/admin_claims_handler.go new file mode 100644 index 0000000..ca562cb --- /dev/null +++ b/internal/handler/admin/lottery/admin_claims_handler.go @@ -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) + } +} diff --git a/internal/handler/lottery_routes.go b/internal/handler/lottery_routes.go index 820827e..14e57e4 100644 --- a/internal/handler/lottery_routes.go +++ b/internal/handler/lottery_routes.go @@ -40,5 +40,12 @@ func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) { adminGroup.GET("/prizes", adminLottery.ListLotteryPrizesHandler(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)) } } diff --git a/internal/logic/admin/lottery/admin_claims.go b/internal/logic/admin/lottery/admin_claims.go new file mode 100644 index 0000000..31c946d --- /dev/null +++ b/internal/logic/admin/lottery/admin_claims.go @@ -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 +} diff --git a/internal/logic/admin/lottery/admin_claims_test.go b/internal/logic/admin/lottery/admin_claims_test.go new file mode 100644 index 0000000..1e7b44b --- /dev/null +++ b/internal/logic/admin/lottery/admin_claims_test.go @@ -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) + } +} diff --git a/internal/logic/lottery/draw/service.go b/internal/logic/lottery/draw/service.go index 3d2d85d..30ca045 100644 --- a/internal/logic/lottery/draw/service.go +++ b/internal/logic/lottery/draw/service.go @@ -4,22 +4,22 @@ // model layer into one atomic sequence. // // Ordering matters — the flow is: -// 1. feature-flag gate (config.Lottery.Enable) -// 2. Redis rate limit (per-user 1/sec) -// 3. Load activity + validate window/status -// 4. Build RuleContext (pre-tx reads) -// 5. Evaluate eligibility (pure compute) -// 6. Load prize pool snapshot (pre-tx read) -// 7. Open tx → -// 7a. Nonce dedupe: SELECT lottery_draw WHERE (user_id, client_nonce) -// — hit returns the recorded draw -// 7b. ChanceService.Consume (SELECT FOR UPDATE + decrement) -// 7c. WeightedPicker.Pick -// 7d. Limited-stock optimistic decrement; fallback on RowsAffected=0 -// 7e. INSERT lottery_draw + PrizeSnapshot + EligibilitySnapshot -// 7f. Auto-handler Dispatch (in tx) -// 7g. Update draw.dispatch_state -// → commit +// 1. feature-flag gate (config.Lottery.Enable) +// 2. Redis rate limit (per-user 1/sec) +// 3. Load activity + validate window/status +// 4. Build RuleContext (pre-tx reads) +// 5. Evaluate eligibility (pure compute) +// 6. Load prize pool snapshot (pre-tx read) +// 7. Open tx → +// 7a. Nonce dedupe: SELECT lottery_draw WHERE (user_id, client_nonce) +// — hit returns the recorded draw +// 7b. ChanceService.Consume (SELECT FOR UPDATE + decrement) +// 7c. WeightedPicker.Pick +// 7d. Limited-stock optimistic decrement; fallback on RowsAffected=0 +// 7e. INSERT lottery_draw + PrizeSnapshot + EligibilitySnapshot +// 7f. Auto-handler Dispatch (in tx) +// 7g. Update draw.dispatch_state +// → commit // // Everything past step 5 uses the caller's transaction; post-commit cache // invalidation is the handler layer's job (a future enhancement — the @@ -35,6 +35,7 @@ import ( "strconv" "time" + "github.com/perfect-panel/server/internal/logic/lottery/handler" "github.com/perfect-panel/server/internal/model/lottery" "github.com/perfect-panel/server/pkg/limit" "github.com/perfect-panel/server/pkg/xerr" @@ -72,6 +73,11 @@ type ClaimSummary struct { Required bool AutoClaimed bool 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 @@ -82,13 +88,13 @@ type Service struct { // Deps groups the collaborators. Nil-safe checks live in Draw itself, not here. type Deps struct { - DB *gorm.DB - Enabled bool - RateLimiter RateLimiter - Chance lottery.ChanceService - Evaluator lottery.RuleEvaluator - Picker lottery.WeightedPicker - Registry lottery.Registry + DB *gorm.DB + Enabled bool + RateLimiter RateLimiter + Chance lottery.ChanceService + Evaluator lottery.RuleEvaluator + Picker lottery.WeightedPicker + Registry lottery.Registry ContextBuilder RuleContextBuilder } @@ -200,8 +206,8 @@ func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) { return wrapInternal(snapErr) } - // (f) Dispatch prize. - dispatch, dispatchErr := s.dispatchIfAutomatic(ctx, tx, req, draw, final) + // (f) Dispatch prize (auto handler) or create pending claim (manual handler). + dispatch, claimInfo, dispatchErr := s.dispatchOrEnqueueClaim(ctx, tx, req, draw, final) if dispatchErr != nil { return dispatchErr } @@ -211,7 +217,7 @@ func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) { return wrapInternal(updateErr) } - result = buildResult(draw, final, dispatch, remaining) + result = buildResult(draw, final, dispatch, claimInfo, remaining) return nil }) if txErr != nil { @@ -395,32 +401,91 @@ func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lotter 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 { - return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, nil + return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, pendingClaimInfo{}, 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 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{ + UserId: req.UserId, + ActivityId: req.ActivityId, + DrawId: draw.Id, + Prize: prize, + 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), + } + result, dispatchErr := prizeHandler.Dispatch(ctx, tx, dispatchReq) + return result, pendingClaimInfo{}, dispatchErr } - dispatchReq := lottery.DispatchRequest{ - UserId: req.UserId, - ActivityId: req.ActivityId, - DrawId: draw.Id, - Prize: prize, - 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), + + // 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, } - return handler.Dispatch(ctx, tx, dispatchReq) + 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 { @@ -454,31 +519,53 @@ func (s *Service) buildResultFromExistingDraw(ctx context.Context, tx *gorm.DB, 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{ DrawId: existing.Id, IsWin: existing.IsWin, Prize: prize, ChancesRemaining: remaining, - Claim: ClaimSummary{ - Required: existing.DispatchState == lottery.DispatchStatePendingClaim, - AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed, - Message: "", - }, + Claim: claim, }, 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{ DrawId: draw.Id, IsWin: draw.IsWin, ChancesRemaining: remaining, Message: dispatch.Message, Claim: ClaimSummary{ - Required: dispatch.State == lottery.DispatchStatePendingClaim, - AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed, - Message: dispatch.Message, + Required: dispatch.State == lottery.DispatchStatePendingClaim, + AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed, + Message: dispatch.Message, + ClaimFormSchema: claim.ClaimFormSchema, }, } + if !claim.ExpiresAt.IsZero() { + res.Claim.ExpiresAt = claim.ExpiresAt.Unix() + } if draw.IsWin { res.Prize = &PrizeSummary{ Slot: prize.Slot, diff --git a/internal/logic/lottery/draw/service_test.go b/internal/logic/lottery/draw/service_test.go index 965da8b..8789877 100644 --- a/internal/logic/lottery/draw/service_test.go +++ b/internal/logic/lottery/draw/service_test.go @@ -95,7 +95,8 @@ func (h *recordingHandler) Dispatch(_ context.Context, _ *gorm.DB, _ lottery.Dis h.calls++ 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. type stubRegistry struct { @@ -513,7 +514,8 @@ func errAsCode(err error) (uint32, bool) { 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 // UnmetReasons="" — MySQL error 3140 rejects empty strings on JSON columns, @@ -548,12 +550,12 @@ func TestInsertSnapshots_UnmetReasonsIsValidJSON(t *testing.T) { // We assert UnmetReasons=="[]" (never "") and EvaluatedAt is non-zero. 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 + 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)) mock.ExpectExec("UPDATE `lottery_draw`"). @@ -611,3 +613,153 @@ func (m evaluatedAtNotZero) Match(v driver.Value) bool { } 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) + } +} diff --git a/internal/logic/lottery/handler/commission.go b/internal/logic/lottery/handler/commission.go index b23c476..473f93b 100644 --- a/internal/logic/lottery/handler/commission.go +++ b/internal/logic/lottery/handler/commission.go @@ -36,9 +36,10 @@ func NewCommissionHandler(deps CommissionDeps) *CommissionHandler { return &CommissionHandler{deps: deps} } -func (*CommissionHandler) Type() string { return lottery.PrizeTypeCommission } -func (*CommissionHandler) IsAuto() bool { return true } -func (*CommissionHandler) ValidateClaim([]byte) error { return nil } +func (*CommissionHandler) Type() string { return lottery.PrizeTypeCommission } +func (*CommissionHandler) IsAuto() bool { return true } +func (*CommissionHandler) ValidateClaim([]byte) error { return nil } +func (*CommissionHandler) ClaimSchema() json.RawMessage { return nil } type commissionConfig struct { // AmountCents 是"分"级别的金额(与 user.commission 存储单位对齐)。 diff --git a/internal/logic/lottery/handler/manual_claim.go b/internal/logic/lottery/handler/manual_claim.go new file mode 100644 index 0000000..32b4351 --- /dev/null +++ b/internal/logic/lottery/handler/manual_claim.go @@ -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 +} diff --git a/internal/logic/lottery/handler/manual_claim_test.go b/internal/logic/lottery/handler/manual_claim_test.go new file mode 100644 index 0000000..65c53b3 --- /dev/null +++ b/internal/logic/lottery/handler/manual_claim_test.go @@ -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) + } + } +} diff --git a/internal/logic/lottery/handler/vpn_duration.go b/internal/logic/lottery/handler/vpn_duration.go index 0672532..c4f68c8 100644 --- a/internal/logic/lottery/handler/vpn_duration.go +++ b/internal/logic/lottery/handler/vpn_duration.go @@ -79,10 +79,11 @@ func NewVPNDurationHandler(deps VPNDurationDeps) *VPNDurationHandler { return &VPNDurationHandler{deps: deps} } -// Type / IsAuto / ValidateClaim 实现 PrizeHandler 接口。 -func (*VPNDurationHandler) Type() string { return lottery.PrizeTypeVPNDuration } -func (*VPNDurationHandler) IsAuto() bool { return true } -func (*VPNDurationHandler) ValidateClaim([]byte) error { return nil } +// Type / IsAuto / ValidateClaim / ClaimSchema 实现 PrizeHandler 接口。 +func (*VPNDurationHandler) Type() string { return lottery.PrizeTypeVPNDuration } +func (*VPNDurationHandler) IsAuto() bool { return true } +func (*VPNDurationHandler) ValidateClaim([]byte) error { return nil } +func (*VPNDurationHandler) ClaimSchema() json.RawMessage { return nil } // vpnDurationConfig 是奖品 Config JSON 的解码目标。 type vpnDurationConfig struct { diff --git a/internal/logic/public/lottery/lottery.go b/internal/logic/public/lottery/lottery.go index d79ca19..ec70ad1 100644 --- a/internal/logic/public/lottery/lottery.go +++ b/internal/logic/public/lottery/lottery.go @@ -1,16 +1,19 @@ // Package lottery implements the user-side lottery HTTP endpoints: -// GET /api/v1/lottery/config -// POST /api/v1/lottery/draw -// GET /api/v1/lottery/records -// POST /api/v1/lottery/claim (Stage 1: returns 4010 not_claimable) +// +// GET /api/v1/lottery/config +// POST /api/v1/lottery/draw +// GET /api/v1/lottery/records +// POST /api/v1/lottery/claim (Stage 2: submit manual claim data) package lottery import ( "context" "encoding/json" "strings" + "time" "github.com/perfect-panel/server/internal/logic/lottery/draw" + lotteryhandler "github.com/perfect-panel/server/internal/logic/lottery/handler" modelLottery "github.com/perfect-panel/server/internal/model/lottery" userModel "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" @@ -170,9 +173,11 @@ func (l *DrawLotteryLogic) DrawLottery(req *types.DrawLotteryRequest) (*types.Dr IsWin: result.IsWin, ChancesRemaining: result.ChancesRemaining, Claim: types.LotteryClaimStatus{ - Required: result.Claim.Required, - AutoClaimed: result.Claim.AutoClaimed, - Message: result.Claim.Message, + Required: result.Claim.Required, + AutoClaimed: result.Claim.AutoClaimed, + Message: result.Claim.Message, + ExpiresAt: result.Claim.ExpiresAt, + ClaimFormSchema: result.Claim.ClaimFormSchema, }, } if result.Prize != nil { @@ -244,6 +249,10 @@ func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryReco if err != nil { return nil, err } + claims, err := l.loadClaims(draws) + if err != nil { + return nil, err + } resp := &types.GetLotteryRecordsResponse{Total: total, List: make([]types.LotteryRecord, 0, len(draws))} for _, d := range draws { record := types.LotteryRecord{ @@ -253,7 +262,8 @@ func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryReco DispatchState: d.DispatchState, DrawnAt: d.DrawnAt.Unix(), } - if snap, ok := snapshots[d.Id]; ok { + snap, hasSnap := snapshots[d.Id] + if hasSnap { record.Prize = &types.DrawnPrize{ Slot: snap.Slot, Id: snap.PrizeId, @@ -262,11 +272,68 @@ func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryReco Config: json.RawMessage(defaultIfEmpty(snap.Config)), } } + if claim, ok := claims[d.Id]; ok { + record.Claim = l.buildRecordClaim(claim, snap, hasSnap) + } resp.List = append(resp.List, record) } return resp, nil } +// loadClaims 批量拉这一页里所有 draw 关联的 lottery_claim。人工奖 draw 一定有一行, +// 自动奖 draw / 谢谢参与不会有;缺失的 draw_id 直接不在 map 里,调用侧只做存在性判断。 +func (l *QueryLotteryRecordsLogic) loadClaims(draws []modelLottery.Draw) (map[int64]modelLottery.Claim, error) { + if len(draws) == 0 { + return nil, nil + } + ids := make([]int64, 0, len(draws)) + for _, d := range draws { + ids = append(ids, d.Id) + } + var rows []modelLottery.Claim + if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).Find(&rows).Error; err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + out := make(map[int64]modelLottery.Claim, len(rows)) + for _, c := range rows { + out[c.DrawId] = c + } + return out, nil +} + +// buildRecordClaim 把 lottery_claim 组装成 GET /records 里的 Claim 字段。 +// 状态允许再提交(pending_claim / rejected)时附带 ClaimFormSchema, +// 否则不再下发(避免前端误以为还能再填)。 +func (l *QueryLotteryRecordsLogic) buildRecordClaim(claim modelLottery.Claim, snap modelLottery.PrizeSnapshot, hasSnap bool) *types.LotteryRecordClaim { + view := &types.LotteryRecordClaim{ + Status: claim.Status, + ExpiresAt: claim.ExpiresAt.Unix(), + TxHash: claim.TxHash, + DeliveryRef: claim.DeliveryRef, + RejectReason: claim.RejectReason, + } + if claim.ClaimData != "" { + view.ClaimData = json.RawMessage(claim.ClaimData) + } + if claim.SubmittedAt != nil { + view.SubmittedAt = claim.SubmittedAt.Unix() + } + if claim.PaidAt != nil { + view.PaidAt = claim.PaidAt.Unix() + } + // 只在允许再提交状态下下发 schema。 + if modelLottery.IsClaimStatusResubmittable(claim.Status) && l.svcCtx.LotteryRegistry != nil { + if h, ok := l.svcCtx.LotteryRegistry.Get(claim.PrizeType); ok { + if claim.PrizeType == modelLottery.PrizeTypeCrypto && hasSnap { + view.ClaimFormSchema = lotteryhandler.BuildCryptoClaimSchema(snap.Config) + } else { + view.ClaimFormSchema = h.ClaimSchema() + } + } + } + return view +} + func (l *QueryLotteryRecordsLogic) loadPrizeSnapshots(draws []modelLottery.Draw) (map[int64]modelLottery.PrizeSnapshot, error) { if len(draws) == 0 { return nil, nil @@ -299,7 +366,16 @@ func recordStatusFilter(s string) string { // ---- POST /claim ---------------------------------------------------------- -// ClaimLotteryPrizeLogic 是 Stage 2 才实装的入口;Stage 1 直接返回 4010。 +// ClaimLotteryPrizeLogic 是 Stage 2 人工奖领奖入口。 +// +// 调用契约(错误码见 pkg/xerr): +// +// 4007 draw_not_found — 传入的 draw_id 不存在 +// 4008 not_your_draw — draw 属于其他用户 +// 4010 not_claimable — 该 draw 未中奖 / 自动奖 / 找不到 pending_claim +// 4009 claim_expired — pending_claim.expires_at 已过期 +// 4005 already_submitted — 当前状态 (reviewing/paying/paid/expired) 禁止再提交 +// 4006 invalid_claim_data — handler.ValidateClaim 校验失败 type ClaimLotteryPrizeLogic struct { logger.Logger ctx context.Context @@ -314,10 +390,110 @@ func NewClaimLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) } } -func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(_ *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) { - if currentUserId(l.ctx) == 0 { +// ClaimLotteryPrize 提交领奖表单:pending_claim → reviewing,或 rejected → reviewing。 +func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(req *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) { + userId := currentUserId(l.ctx) + if userId == 0 { return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) } - // Stage 1 不实装人工领奖流程。 - return nil, xerr.NewErrCode(xerr.LotteryNotClaimable) + if req.DrawId <= 0 { + return nil, xerr.NewErrCode(xerr.InvalidParams) + } + claimData := selectClaimData(req) + + // 1. 定位 draw + 归属校验(提前失败,避免暴露内部资源) + var draw modelLottery.Draw + if err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.DrawId).First(&draw).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, xerr.NewErrCode(xerr.LotteryDrawNotFound) + } + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + if draw.UserId != userId { + return nil, xerr.NewErrCode(xerr.LotteryNotYourDraw) + } + if !draw.IsWin || draw.DispatchState != modelLottery.DispatchStatePendingClaim { + return nil, xerr.NewErrCode(xerr.LotteryNotClaimable) + } + + // 2. 抽奖时刻快照(用于 crypto network 二次校验) + var snap modelLottery.PrizeSnapshot + if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id = ?", draw.Id).First(&snap).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, xerr.NewErrCode(xerr.LotteryNotClaimable) + } + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + if !modelLottery.IsPrizeTypeManualClaim(snap.Type) { + return nil, xerr.NewErrCode(xerr.LotteryNotClaimable) + } + handler, ok := l.svcCtx.LotteryRegistry.Get(snap.Type) + if !ok { + return nil, xerr.NewErrCode(xerr.LotteryInternalError) + } + + // 3. handler 校验 body + if err := handler.ValidateClaim(claimData); err != nil { + return nil, xerr.NewErrCodeMsg(xerr.LotteryInvalidClaimData, err.Error()) + } + if snap.Type == modelLottery.PrizeTypeCrypto { + if err := lotteryhandler.ValidateCryptoNetwork(claimData, snap.Config); err != nil { + return nil, xerr.NewErrCodeMsg(xerr.LotteryInvalidClaimData, err.Error()) + } + } + + // 4. 事务内更新 claim(乐观锁:status IN (pending_claim, rejected) AND expires_at > now) + now := time.Now() + var response *types.ClaimLotteryPrizeResponse + err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + var claim modelLottery.Claim + if err := tx.Where("draw_id = ?", draw.Id).First(&claim).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return xerr.NewErrCode(xerr.LotteryNotClaimable) + } + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + if claim.ExpiresAt.Before(now) { + return xerr.NewErrCode(xerr.LotteryClaimExpired) + } + if !modelLottery.IsClaimStatusResubmittable(claim.Status) { + return xerr.NewErrCode(xerr.LotteryAlreadySubmitted) + } + + // CAS 更新:命中 status 白名单 + expires_at 未过期时才走。RowsAffected==0 + // 视为并发拦截(另一个请求已经把状态推进了),报 4005 即可。 + res := tx.Model(&modelLottery.Claim{}). + Where("id = ? AND status IN ? AND expires_at > ?", + claim.Id, + []string{modelLottery.ClaimStatusPendingClaim, modelLottery.ClaimStatusRejected}, + now). + Updates(map[string]any{ + "claim_data": string(claimData), + "status": modelLottery.ClaimStatusReviewing, + "submitted_at": now, + }) + if res.Error != nil { + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error()) + } + if res.RowsAffected == 0 { + return xerr.NewErrCode(xerr.LotteryAlreadySubmitted) + } + response = &types.ClaimLotteryPrizeResponse{ + Status: modelLottery.ClaimStatusReviewing, + SubmittedAt: now.Unix(), + } + return nil + }) + if err != nil { + return nil, err + } + return response, nil +} + +// selectClaimData 兼容 ClaimData(首选)与 Input(历史字段名)。 +func selectClaimData(req *types.ClaimLotteryPrizeRequest) []byte { + if len(req.ClaimData) > 0 { + return req.ClaimData + } + return req.Input } diff --git a/internal/logic/public/lottery/lottery_stage2_test.go b/internal/logic/public/lottery/lottery_stage2_test.go new file mode 100644 index 0000000..4e6a6e7 --- /dev/null +++ b/internal/logic/public/lottery/lottery_stage2_test.go @@ -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) + } + } +} diff --git a/internal/model/lottery/model.go b/internal/model/lottery/model.go index 7793733..2b55a56 100644 --- a/internal/model/lottery/model.go +++ b/internal/model/lottery/model.go @@ -19,18 +19,21 @@ const ( UnmetActionBlock = "block" UnmetActionShowReason = "show_reason" - // PrizeType* 是发奖 handler 注册表的键。Stage 1 只实现前三种。 + // PrizeType* 是发奖 handler 注册表的键。 + // Stage 1 已实装:vpn_duration / commission / none。 + // Stage 2 新增人工奖:crypto / physical / manual_other。 PrizeTypeVPNDuration = "vpn_duration" PrizeTypeCommission = "commission" PrizeTypeNone = "none" - // Stage 2/3 预留。 - PrizeTypeBalance = "balance" - PrizeTypeGiftAmount = "gift_amount" - PrizeTypeCoupon = "coupon" - PrizeTypePoints = "points" - PrizeTypeEncrypted = "encrypted" + // Stage 2 人工奖类型(HIF-4)。 + PrizeTypeCrypto = "crypto" PrizeTypePhysical = "physical" PrizeTypeManualOther = "manual_other" + // Stage 3 预留。 + PrizeTypeBalance = "balance" + PrizeTypeGiftAmount = "gift_amount" + PrizeTypeCoupon = "coupon" + PrizeTypePoints = "points" // ChanceSource* 是次数入账触发源。 ChanceSourceDailySignin = "daily_signin" @@ -45,6 +48,24 @@ const ( DispatchStatePaid = "paid" DispatchStateExpired = "expired" 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" } +// 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 结构体辅助 ------------------------------------------------------- // EligibilityRule 是持久化在 lottery_activity.eligibility 字段里的门槛规则树。 diff --git a/internal/model/lottery/registry.go b/internal/model/lottery/registry.go index f7664ca..f3fbc7c 100644 --- a/internal/model/lottery/registry.go +++ b/internal/model/lottery/registry.go @@ -2,6 +2,7 @@ package lottery import ( "context" + "encoding/json" "sync" "gorm.io/gorm" @@ -53,3 +54,4 @@ func (noopHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (D return DispatchResult{State: DispatchStateAutoClaimed, Message: "谢谢参与"}, nil } func (noopHandler) ValidateClaim(_ []byte) error { return nil } +func (noopHandler) ClaimSchema() json.RawMessage { return nil } diff --git a/internal/model/lottery/service.go b/internal/model/lottery/service.go index 94eaf43..2702c00 100644 --- a/internal/model/lottery/service.go +++ b/internal/model/lottery/service.go @@ -7,6 +7,7 @@ package lottery import ( "context" + "encoding/json" "errors" "gorm.io/gorm" @@ -95,7 +96,7 @@ type DispatchResult struct { // PrizeHandler 是一种奖品类型的发奖策略。Type 是注册键;IsAuto=true 表示 // 抽奖事务内立刻发放,false 表示挂 pending_claim 等人工发(Stage 1 只实现 -// IsAuto=true 的三种)。 +// IsAuto=true 的三种,Stage 2 补齐 crypto/physical/manual_other)。 // // 幂等:所有实现必须以 lottery_draw.id 为外部 ref 做 check-before-write, // 避免重试重复发放。见 doc/lottery-stage1-plan.md 的"发奖账本"章节。 @@ -104,12 +105,24 @@ type PrizeHandler interface { IsAuto() bool // Dispatch 在调用方的事务内执行;返回结果或错误。 // 错误会导致抽奖事务回滚(次数不扣、draw 不落库),由用户侧重新发起。 + // + // 对 IsAuto()=false 的人工奖 handler,Dispatch 不会被抽奖服务调用; + // 实现返回 ErrDispatchNotSupported 即可。 Dispatch(ctx context.Context, tx *gorm.DB, req DispatchRequest) (DispatchResult, error) - // ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);Stage 1 的 - // auto handler 直接返回 nil 即可。 + // ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);auto handler + // 直接返回 nil 即可(默认 noopHandler / vpn_duration / commission 都不用)。 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 的占位错误:抽奖流程接入前 // 若不慎命中真实 handler 会立即失败,避免误发。 var ErrNotImplemented = errors.New("lottery: handler not yet wired to real business") diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index e861a38..b98aede 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -180,6 +180,12 @@ func NewServiceContext(c config.Config) *ServiceContext { UpdateCommission: srv.UserModel.UpdateCommission, 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 drawLimiter := limit.NewPeriodLimit(1, 1, rds, "lottery:draw:rate:") diff --git a/internal/types/lottery.go b/internal/types/lottery.go index 28db8e1..90f436b 100644 --- a/internal/types/lottery.go +++ b/internal/types/lottery.go @@ -43,9 +43,9 @@ type LotteryPrizeConfig struct { // LotteryUserStatus reports whether the user can currently draw. type LotteryUserStatus struct { - Eligible bool `json:"eligible"` - ChancesRemaining int64 `json:"chances_remaining"` - UnmetReasons []LotteryUnmetReason `json:"unmet_reasons"` + Eligible bool `json:"eligible"` + ChancesRemaining int64 `json:"chances_remaining"` + UnmetReasons []LotteryUnmetReason `json:"unmet_reasons"` } // LotteryUnmetReason is a single unmet-rule explanation for frontend display. @@ -82,6 +82,11 @@ type LotteryClaimStatus struct { Required bool `json:"required"` AutoClaimed bool `json:"auto_claimed"` 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. @@ -111,6 +116,25 @@ type LotteryRecord struct { Prize *DrawnPrize `json:"prize"` DispatchState string `json:"dispatch_state"` 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. @@ -119,14 +143,20 @@ type GetLotteryRecordsResponse struct { 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 { - DrawId int64 `json:"draw_id" validate:"required"` - Input json.RawMessage `json:"input,omitempty"` + 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"` } -// ClaimLotteryPrizeResponse mirrors Stage 2 shape; Stage 1 unused. -type ClaimLotteryPrizeResponse struct{} +// ClaimLotteryPrizeResponse mirrors Stage 2 shape. +type ClaimLotteryPrizeResponse struct { + Status string `json:"status"` + SubmittedAt int64 `json:"submitted_at"` +} // ---- Admin API -------------------------------------------------------------- @@ -148,28 +178,28 @@ type AdminLotteryActivity struct { // CreateAdminLotteryActivityRequest creates a new activity in "draft" status. type CreateAdminLotteryActivityRequest struct { - Title string `json:"title" validate:"required,max=128"` - Description string `json:"description"` - StartAt int64 `json:"start_at" validate:"required"` - EndAt int64 `json:"end_at" validate:"required"` + Title string `json:"title" validate:"required,max=128"` + Description string `json:"description"` + StartAt int64 `json:"start_at" validate:"required"` + EndAt int64 `json:"end_at" validate:"required"` // GridSize:0 由 logic 层兜底为 9(不能在 json tag 里写 default=… —— // staticcheck SA5008 会拒;encoding/json 也不认这个选项)。 GridSize int `json:"grid_size"` Eligibility json.RawMessage `json:"eligibility"` ChanceSources json.RawMessage `json:"chance_sources"` // UnmetAction:空字符串由 logic 层兜底为 "block"。 - UnmetAction string `json:"unmet_action"` + UnmetAction string `json:"unmet_action"` } // UpdateAdminLotteryActivityRequest updates mutable fields. type UpdateAdminLotteryActivityRequest struct { - Id int64 `json:"id" validate:"required"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - StartAt int64 `json:"start_at,omitempty"` - EndAt int64 `json:"end_at,omitempty"` - GridSize int `json:"grid_size,omitempty"` - UnmetAction string `json:"unmet_action,omitempty"` + Id int64 `json:"id" validate:"required"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + StartAt int64 `json:"start_at,omitempty"` + EndAt int64 `json:"end_at,omitempty"` + GridSize int `json:"grid_size,omitempty"` + UnmetAction string `json:"unmet_action,omitempty"` } // ListAdminLotteryActivitiesRequest paginates admin listings. @@ -266,3 +296,94 @@ type GrantAdminLotteryChanceRequest struct { Amount int `json:"amount" validate:"required,min=1"` 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"` +} diff --git a/pkg/xerr/errCode.go b/pkg/xerr/errCode.go index 1539de9..bd3c361 100644 --- a/pkg/xerr/errCode.go +++ b/pkg/xerr/errCode.go @@ -152,18 +152,28 @@ const ( ) // Lottery error (100xxx range; 3-digit business = 100) -// codes align with HIF-3 spec (Stage 1 issue description): -// 4001 not_eligible / 4002 no_chances / 4003 activity_ended -// 4004 rate_limited / 4010 not_claimable / 5001 internal_error +// codes align with HIF-3 spec (Stage 1 issue description) + HIF-4 (Stage 2): +// +// 4001 not_eligible / 4002 no_chances / 4003 activity_ended +// 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. const ( - LotteryNotEligible uint32 = 100001 // 用户未通过门槛 - LotteryNoChances uint32 = 100002 // 用户已无剩余次数 - LotteryActivityEnded uint32 = 100003 // 活动未开始 / 已结束 / 或 feature flag 关闭 - LotteryRateLimited uint32 = 100004 // 触发每秒 1 次限流 - LotteryNotClaimable uint32 = 100010 // Stage 2 领奖入口占位 - LotteryInternalError uint32 = 100500 // 内部错误 - LotteryRuleTooDeep uint32 = 100020 // rules JSON 深度超限 - LotteryRuleTooMany uint32 = 100021 // rules JSON 节点数超限 - LotteryRuleTooLarge uint32 = 100022 // rules JSON 体积超限 + LotteryNotEligible uint32 = 100001 // 用户未通过门槛 + LotteryNoChances uint32 = 100002 // 用户已无剩余次数 + LotteryActivityEnded uint32 = 100003 // 活动未开始 / 已结束 / 或 feature flag 关闭 + LotteryRateLimited uint32 = 100004 // 触发每秒 1 次限流 + 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 // 内部错误 + LotteryRuleTooDeep uint32 = 100020 // rules JSON 深度超限 + LotteryRuleTooMany uint32 = 100021 // rules JSON 节点数超限 + LotteryRuleTooLarge uint32 = 100022 // rules JSON 体积超限 + LotteryClaimStateInvalid uint32 = 100011 // Stage 2: 运营操作时状态机不允许(如非 reviewing 却 approve) ) diff --git a/pkg/xerr/errMsg.go b/pkg/xerr/errMsg.go index d5edba4..49866e0 100644 --- a/pkg/xerr/errMsg.go +++ b/pkg/xerr/errMsg.go @@ -114,15 +114,21 @@ func init() { OrderRefundCommissionMismatch: "Refund commission source not found", // Lottery error - LotteryNotEligible: "抽奖门槛未达成", - LotteryNoChances: "抽奖次数不足", - LotteryActivityEnded: "抽奖活动未开放", - LotteryRateLimited: "抽奖操作过于频繁,请稍后再试", - LotteryNotClaimable: "该奖品当前不支持领取", - LotteryInternalError: "抽奖服务暂时不可用", - LotteryRuleTooDeep: "规则树嵌套层数超限", - LotteryRuleTooMany: "规则树节点数超限", - LotteryRuleTooLarge: "规则树 JSON 体积超限", + LotteryNotEligible: "抽奖门槛未达成", + LotteryNoChances: "抽奖次数不足", + LotteryActivityEnded: "抽奖活动未开放", + LotteryRateLimited: "抽奖操作过于频繁,请稍后再试", + LotteryAlreadySubmitted: "该奖品的领奖信息已提交,无需重复提交", + LotteryInvalidClaimData: "领奖信息格式不正确", + LotteryDrawNotFound: "抽奖记录不存在", + LotteryNotYourDraw: "无权操作此抽奖记录", + LotteryClaimExpired: "领奖窗口已过期", + LotteryNotClaimable: "该奖品当前不支持领取", + LotteryClaimStateInvalid: "当前工单状态不允许此操作", + LotteryInternalError: "抽奖服务暂时不可用", + LotteryRuleTooDeep: "规则树嵌套层数超限", + LotteryRuleTooMany: "规则树节点数超限", + LotteryRuleTooLarge: "规则树 JSON 体积超限", // Permission error PermissionDenied: "Permission denied", diff --git a/qa/lottery/stage2_curl.sh b/qa/lottery/stage2_curl.sh new file mode 100755 index 0000000..c06a718 --- /dev/null +++ b/qa/lottery/stage2_curl.sh @@ -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= USER_TOKEN= USER_ID= \ +# 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 </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 <&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 <&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' diff --git a/queue/handler/routes.go b/queue/handler/routes.go index 03ae41d..ce0c3ed 100644 --- a/queue/handler/routes.go +++ b/queue/handler/routes.go @@ -4,6 +4,7 @@ import ( "github.com/hibiken/asynq" "github.com/perfect-panel/server/internal/svc" 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" smslogic "github.com/perfect-panel/server/queue/logic/sms" "github.com/perfect-panel/server/queue/logic/subscription" @@ -51,4 +52,7 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) { // Stuck order recovery mux.Handle(types.SchedulerStuckOrderRecovery, orderLogic.NewStuckOrderRecoveryLogic(serverCtx)) + + // Lottery Stage 2: 过期领奖工单每小时扫描一次 + mux.Handle(types.SchedulerLotteryExpireClaim, lotteryLogic.NewExpireClaimLogic(serverCtx)) } diff --git a/queue/logic/lottery/expireClaimLogic.go b/queue/logic/lottery/expireClaimLogic.go new file mode 100644 index 0000000..5ee9e12 --- /dev/null +++ b/queue/logic/lottery/expireClaimLogic.go @@ -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 +} diff --git a/queue/logic/lottery/expireClaimLogic_test.go b/queue/logic/lottery/expireClaimLogic_test.go new file mode 100644 index 0000000..daaf526 --- /dev/null +++ b/queue/logic/lottery/expireClaimLogic_test.go @@ -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) + } +} diff --git a/queue/types/scheduler.go b/queue/types/scheduler.go index 5e665fa..1ce72f5 100644 --- a/queue/types/scheduler.go +++ b/queue/types/scheduler.go @@ -5,7 +5,8 @@ const ( SchedulerTotalServerData = "scheduler:total:server" SchedulerResetTraffic = "scheduler:reset:traffic" SchedulerTrafficStat = "scheduler:traffic:stat" - SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单 - SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账 - SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单 + SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单 + SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账 + SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单 + SchedulerLotteryExpireClaim = "scheduler:lottery:expire:claim" // 抽奖 Stage 2:每小时扫描过期 pending_claim ) diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index f2b16df..73ddf85 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -70,6 +70,12 @@ func (m *Service) Start() { 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 { logger.Errorf("run scheduler failed: %s", err.Error()) }