新功能(#4): 抽奖 Stage 2 人工奖领奖工单(crypto / physical / manual_other)

Closes HIF-4

Stage 2 交付:人工奖领奖工单完整闭环。crypto / physical / manual_other 三类奖品从抽中到 mark-paid 的全流程可用。

- 迁移 02159_lottery_claim:UNIQUE(draw_id) + 3 支持索引,状态机 pending_claim→reviewing→paying→paid,rejected 可复活,超时 expired
- 3 个 PrizeHandler:Dispatch→ErrDispatchNotSupported 兜底、ClaimSchema 各自形态、ValidateClaim 表驱动
- BuildCryptoClaimSchema:抽中时按奖品 config.networks 注入 enum,前端下拉直接可用
- Draw service dispatchOrEnqueueClaim:人工奖同 tx 插 pending_claim(回滚双清),nonce 重放回读 ExpiresAt + ClaimFormSchema
- POST /claim 实装:ownership 校验 → prize 类型校验 → handler.ValidateClaim → crypto network 白名单二次校验 → tx CAS status IN (pending_claim, rejected) AND expires_at > now
- Admin CRUD 5 接口:list(IN 批拉 snap + user,无 N+1)、summary(GROUP BY 一次拿计数 + overdue 单查)、approve/reject/mark-paid 全走 CAS + audit
- Scheduler @every 1h 扫过期,级联 lottery_draw.dispatch_state → expired
- 新增错误码 100005-100011(already_submitted / invalid_claim_data / draw_not_found / not_your_draw / claim_expired / claim_state_invalid)
- Rebase 后 Stage 2 测试主动 reuse PR E 的 unmetReasonsNotEmpty + evaluatedAtNotZero matcher,人工奖分支若绕过守卫会立即挂
- Stage 1 全部 4 处 guardrail 后端 rebase 时自检过:UnmetReasons、EvaluatedAt、GrantLedger.Payload、AdminMetaMiddleware 全保留

CI 全绿;28 files, +2442/-126;覆盖率 handler 78.9% / model.lottery 74.3% / draw 68.7% / queue/lottery 76.9%
This commit is contained in:
2026-07-10 04:01:34 -07:00
committed by GitHub
parent 117dc0d6a7
commit 07409eb602
28 changed files with 2471 additions and 133 deletions
@@ -0,0 +1,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 → rejectedexpires_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
}
}
// overduepending_claim 且 expires_at 已过(还没转 expired 的边缘时刻)。
if err := l.svcCtx.DB.WithContext(l.ctx).
Model(&modelLottery.Claim{}).
Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, time.Now()).
Count(&summary.Overdue).Error; err != nil {
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
}
return summary, nil
}
func bucketByType(s *types.AdminLotteryClaimsSummary, prizeType string) *types.AdminLotteryClaimsStatusCount {
switch prizeType {
case modelLottery.PrizeTypeCrypto:
return &s.Crypto
case modelLottery.PrizeTypePhysical:
return &s.Physical
case modelLottery.PrizeTypeManualOther:
return &s.ManualOther
}
return nil
}
// ---- helpers ---------------------------------------------------------------
// claimToAdminView 把 model.Claim 组装成后台视图,附带快照 + 用户信息。
func claimToAdminView(c modelLottery.Claim, snap modelLottery.PrizeSnapshot, email string) types.AdminLotteryClaim {
view := types.AdminLotteryClaim{
Id: c.Id,
DrawId: c.DrawId,
ActivityId: c.ActivityId,
Status: c.Status,
ExpiresAt: c.ExpiresAt.Unix(),
ReviewedBy: c.ReviewedBy,
RejectReason: c.RejectReason,
TxHash: c.TxHash,
DeliveryRef: c.DeliveryRef,
CreatedAt: c.CreatedAt.Unix(),
User: types.AdminLotteryClaimUser{
Id: c.UserId,
Email: email,
},
Prize: types.AdminLotteryClaimPrize{
Type: snap.Type,
Name: snap.Name,
Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")),
},
}
if snap.Type == "" {
// snapshot 未命中,用 claim.prize_type 兜底
view.Prize.Type = c.PrizeType
}
if c.ClaimData != "" {
view.ClaimData = json.RawMessage(c.ClaimData)
}
if c.SubmittedAt != nil {
view.SubmittedAt = c.SubmittedAt.Unix()
}
if c.ReviewedAt != nil {
view.ReviewedAt = c.ReviewedAt.Unix()
}
if c.PaidAt != nil {
view.PaidAt = c.PaidAt.Unix()
}
return view
}
@@ -0,0 +1,122 @@
// admin_claims_test.go — 单元测试 Stage 2 claim 状态机的纯函数校验。
// 数据库集成留给 stage2 QA curl 脚本;单测只覆盖纯逻辑分支:
// - validateMarkPaidByType 的三个奖品类型 x 凭证字段组合
// - bucketByType 的类型 → 状态桶映射
// - claimToAdminView 的 nullable 字段渲染
package lottery
import (
"testing"
"time"
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
"github.com/perfect-panel/server/internal/types"
)
func TestValidateMarkPaidByType(t *testing.T) {
cases := []struct {
name string
prizeType string
txHash string
deliveryRef string
wantErr bool
}{
{"crypto with tx_hash", modelLottery.PrizeTypeCrypto, "0xdeadbeef", "", false},
{"crypto missing tx_hash", modelLottery.PrizeTypeCrypto, "", "", true},
{"crypto ignores delivery_ref alone", modelLottery.PrizeTypeCrypto, "", "SF123", true},
{"physical with delivery_ref", modelLottery.PrizeTypePhysical, "", "SF123456", false},
{"physical missing delivery_ref", modelLottery.PrizeTypePhysical, "", "", true},
{"manual_other with tx_hash", modelLottery.PrizeTypeManualOther, "0xabc", "", false},
{"manual_other with delivery_ref", modelLottery.PrizeTypeManualOther, "", "SF00", false},
{"manual_other with both", modelLottery.PrizeTypeManualOther, "0xabc", "SF00", false},
{"manual_other with none", modelLottery.PrizeTypeManualOther, "", "", true},
{"unknown type", "auto_hallucinated", "", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateMarkPaidByType(tc.prizeType, tc.txHash, tc.deliveryRef)
if tc.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
}
}
func TestBucketByType(t *testing.T) {
s := &types.AdminLotteryClaimsSummary{}
if bucketByType(s, modelLottery.PrizeTypeCrypto) != &s.Crypto {
t.Fatal("crypto bucket mismatch")
}
if bucketByType(s, modelLottery.PrizeTypePhysical) != &s.Physical {
t.Fatal("physical bucket mismatch")
}
if bucketByType(s, modelLottery.PrizeTypeManualOther) != &s.ManualOther {
t.Fatal("manual_other bucket mismatch")
}
if bucketByType(s, "unknown") != nil {
t.Fatal("unknown type must return nil")
}
}
func TestClaimToAdminView_RendersNullableFields(t *testing.T) {
submittedAt := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
paidAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
claim := modelLottery.Claim{
Id: 42,
DrawId: 1234,
UserId: 88,
ActivityId: 100,
PrizeType: modelLottery.PrizeTypeCrypto,
Status: modelLottery.ClaimStatusPaid,
ClaimData: `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`,
SubmittedAt: &submittedAt,
ExpiresAt: submittedAt.Add(24 * time.Hour),
TxHash: "0xabcdef",
PaidAt: &paidAt,
}
snap := modelLottery.PrizeSnapshot{
Type: modelLottery.PrizeTypeCrypto,
Name: "1 BTC",
Config: `{"amount":"1","currency":"BTC","networks":["BTC"]}`,
}
view := claimToAdminView(claim, snap, "user@example.com")
if view.Id != 42 || view.DrawId != 1234 || view.User.Id != 88 {
t.Fatalf("view IDs wrong: %+v", view)
}
if view.User.Email != "user@example.com" {
t.Fatalf("Email = %q", view.User.Email)
}
if view.Status != modelLottery.ClaimStatusPaid {
t.Fatalf("Status = %q", view.Status)
}
if view.SubmittedAt != submittedAt.Unix() {
t.Fatalf("SubmittedAt = %d, want %d", view.SubmittedAt, submittedAt.Unix())
}
if view.PaidAt != paidAt.Unix() {
t.Fatalf("PaidAt = %d, want %d", view.PaidAt, paidAt.Unix())
}
if view.Prize.Type != modelLottery.PrizeTypeCrypto {
t.Fatalf("Prize.Type = %q", view.Prize.Type)
}
if len(view.ClaimData) == 0 {
t.Fatal("ClaimData must be included when non-empty")
}
}
func TestClaimToAdminView_NoSnapshotFallsBackToClaimPrizeType(t *testing.T) {
claim := modelLottery.Claim{
Id: 1,
DrawId: 2,
PrizeType: modelLottery.PrizeTypePhysical,
Status: modelLottery.ClaimStatusPendingClaim,
ExpiresAt: time.Now().Add(time.Hour),
}
view := claimToAdminView(claim, modelLottery.PrizeSnapshot{}, "")
if view.Prize.Type != modelLottery.PrizeTypePhysical {
t.Fatalf("Prize.Type fallback = %q", view.Prize.Type)
}
}
+138 -51
View File
@@ -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,
+160 -8
View File
@@ -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)
}
}
+4 -3
View File
@@ -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 存储单位对齐)。
@@ -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;解码失败返回 ErrClaimDataMalformedwrap 原因)。
func decodeClaimJSON(raw []byte, out any) error {
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" {
return ErrClaimDataEmpty
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%w: %v", ErrClaimDataMalformed, err)
}
return nil
}
// ---- crypto handler ------------------------------------------------------
// CryptoHandler 支持"加密货币"人工奖。运营在后台配置 amount / currency /
// networks;用户选一个网络 + 填一个地址;运营线下打款后 mark-paid + tx_hash。
type CryptoHandler struct{}
// NewCryptoHandler 构造 crypto handler。无依赖,registry 直接 Register 即可。
func NewCryptoHandler() *CryptoHandler { return &CryptoHandler{} }
func (*CryptoHandler) Type() string { return lottery.PrizeTypeCrypto }
func (*CryptoHandler) IsAuto() bool { return false }
func (*CryptoHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
return notSupportedDispatch(ctx, tx, req)
}
// cryptoClaimSchemaJSON 是前端渲染表单的 JSON Schema。运行时 crypto handler
// 会把奖品 config.networks 注入到 network 字段的 enum,让前端只放开这些网络。
// 这里的常量是空 enum 的"模板"ClaimSchema() 返回不带具体 networks 的通用
// 描述,实际抽中时 draw 服务会传具体奖品 config,用 BuildCryptoClaimSchema
// 生成带 enum 的最终 schema 附到 draw response 上。
var cryptoClaimSchemaJSON = json.RawMessage(`{
"type": "object",
"required": ["network","address"],
"properties": {
"network": {"type":"string","title":"打款网络"},
"address": {"type":"string","title":"钱包地址","minLength":16,"maxLength":128}
}
}`)
func (*CryptoHandler) ClaimSchema() json.RawMessage { return cryptoClaimSchemaJSON }
// BuildCryptoClaimSchema 在抽奖成功后按具体奖品 config 生成最终 schema
// 把 config.networks[] 注入到 network 字段的 enum,供前端下拉展示。
// prizeConfig 为该奖品的完整 config JSON 字符串(内含 amount/currency/networks)。
func BuildCryptoClaimSchema(prizeConfig string) json.RawMessage {
var cfg cryptoConfig
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
return cryptoClaimSchemaJSON
}
if len(cfg.Networks) == 0 {
return cryptoClaimSchemaJSON
}
// 拼一段带 enum 的 schema,尽量保持体积小、易读。
var b strings.Builder
b.WriteString(`{"type":"object","required":["network","address"],"properties":{"network":{"type":"string","title":"打款网络","enum":[`)
for i, n := range cfg.Networks {
if i > 0 {
b.WriteByte(',')
}
encoded, _ := json.Marshal(n)
b.Write(encoded)
}
b.WriteString(`]},"address":{"type":"string","title":"钱包地址","minLength":16,"maxLength":128}}}`)
return json.RawMessage(b.String())
}
// cryptoConfig 是 lottery_prize.config 的解码目标。
type cryptoConfig struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
Networks []string `json:"networks"`
}
type cryptoClaimInput struct {
Network string `json:"network"`
Address string `json:"address"`
}
// cryptoAddressRegexp 只做最低限度校验(长度 + 字符集),避免 handler 里
// 绑定各种链的地址前缀(BTC/ETH/TRX 各有一套),把严格校验推给运营在
// mark-paid 前人肉复核。
var cryptoAddressRegexp = regexp.MustCompile(`^[A-Za-z0-9]{16,128}$`)
// ValidateClaim 校验用户提交的 { network, address }
// - network 必须非空(网络白名单是奖品 config 决定的,由 POST /claim 路径
// 再做一次二次校验;handler 层只做格式校验,避免把奖品 config 传下来
// 污染 ValidateClaim 的签名)
// - address 必须匹配基础字符集与长度
func (*CryptoHandler) ValidateClaim(raw []byte) error {
var input cryptoClaimInput
if err := decodeClaimJSON(raw, &input); err != nil {
return err
}
if strings.TrimSpace(input.Network) == "" {
return fmt.Errorf("%w: network is required", ErrClaimDataMalformed)
}
if !cryptoAddressRegexp.MatchString(strings.TrimSpace(input.Address)) {
return fmt.Errorf("%w: address format invalid (16-128 alphanumeric)", ErrClaimDataMalformed)
}
return nil
}
// ValidateCryptoNetwork 二次校验用户选中的 network 必须在奖品 config.networks
// 白名单里。抽出到独立函数是因为 handler.ValidateClaim 的签名不接受奖品配置;
// 由 POST /claim 逻辑层负责调用。
func ValidateCryptoNetwork(raw []byte, prizeConfig string) error {
var input cryptoClaimInput
if err := decodeClaimJSON(raw, &input); err != nil {
return err
}
var cfg cryptoConfig
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
return fmt.Errorf("decode crypto config: %w", err)
}
if len(cfg.Networks) == 0 {
return nil
}
network := strings.TrimSpace(input.Network)
for _, allowed := range cfg.Networks {
if allowed == network {
return nil
}
}
return fmt.Errorf("%w: network %q not in allowed list", ErrClaimDataMalformed, network)
}
// ---- physical handler ----------------------------------------------------
// PhysicalHandler 支持实物奖。运营 mark-paid 时用 delivery_ref 记录快递单号。
type PhysicalHandler struct{}
// NewPhysicalHandler 构造 physical handler。
func NewPhysicalHandler() *PhysicalHandler { return &PhysicalHandler{} }
func (*PhysicalHandler) Type() string { return lottery.PrizeTypePhysical }
func (*PhysicalHandler) IsAuto() bool { return false }
func (*PhysicalHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
return notSupportedDispatch(ctx, tx, req)
}
var physicalClaimSchemaJSON = json.RawMessage(`{
"type": "object",
"required": ["name","phone","province","city","district","detail"],
"properties": {
"name": {"type":"string","title":"收件人姓名","minLength":1,"maxLength":64},
"phone": {"type":"string","title":"联系电话","minLength":6,"maxLength":32},
"province": {"type":"string","title":"省","minLength":1,"maxLength":32},
"city": {"type":"string","title":"市","minLength":1,"maxLength":32},
"district": {"type":"string","title":"区/县","minLength":1,"maxLength":32},
"detail": {"type":"string","title":"详细地址","minLength":1,"maxLength":256}
}
}`)
func (*PhysicalHandler) ClaimSchema() json.RawMessage { return physicalClaimSchemaJSON }
type physicalClaimInput struct {
Name string `json:"name"`
Phone string `json:"phone"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
Detail string `json:"detail"`
}
// phoneRegexp 只允许数字、+、-、空格,长度 6-32;宽松以覆盖国际号码格式。
var phoneRegexp = regexp.MustCompile(`^[0-9+\-\s]{6,32}$`)
func (*PhysicalHandler) ValidateClaim(raw []byte) error {
var input physicalClaimInput
if err := decodeClaimJSON(raw, &input); err != nil {
return err
}
if strings.TrimSpace(input.Name) == "" {
return fmt.Errorf("%w: name is required", ErrClaimDataMalformed)
}
if !phoneRegexp.MatchString(strings.TrimSpace(input.Phone)) {
return fmt.Errorf("%w: phone format invalid", ErrClaimDataMalformed)
}
if strings.TrimSpace(input.Province) == "" ||
strings.TrimSpace(input.City) == "" ||
strings.TrimSpace(input.District) == "" ||
strings.TrimSpace(input.Detail) == "" {
return fmt.Errorf("%w: address components are required", ErrClaimDataMalformed)
}
return nil
}
// ---- manual_other handler ------------------------------------------------
// ManualOtherHandler 支持"其他人工奖"(点赞、见面礼、线下券码等)。
type ManualOtherHandler struct{}
// NewManualOtherHandler 构造 manual_other handler。
func NewManualOtherHandler() *ManualOtherHandler { return &ManualOtherHandler{} }
func (*ManualOtherHandler) Type() string { return lottery.PrizeTypeManualOther }
func (*ManualOtherHandler) IsAuto() bool { return false }
func (*ManualOtherHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
return notSupportedDispatch(ctx, tx, req)
}
var manualOtherClaimSchemaJSON = json.RawMessage(`{
"type": "object",
"required": ["contact_type","contact_value"],
"properties": {
"contact_type": {"type":"string","title":"联系方式类型","enum":["phone","email","tg"]},
"contact_value": {"type":"string","title":"联系方式","minLength":1,"maxLength":128},
"remark": {"type":"string","title":"备注","maxLength":512}
}
}`)
func (*ManualOtherHandler) ClaimSchema() json.RawMessage { return manualOtherClaimSchemaJSON }
type manualOtherClaimInput struct {
ContactType string `json:"contact_type"`
ContactValue string `json:"contact_value"`
Remark string `json:"remark"`
}
// manualOtherContactTypes 是 contact_type 允许的枚举。
var manualOtherContactTypes = map[string]struct{}{
"phone": {},
"email": {},
"tg": {},
}
func (*ManualOtherHandler) ValidateClaim(raw []byte) error {
var input manualOtherClaimInput
if err := decodeClaimJSON(raw, &input); err != nil {
return err
}
ct := strings.TrimSpace(input.ContactType)
if _, ok := manualOtherContactTypes[ct]; !ok {
return fmt.Errorf("%w: contact_type must be one of phone/email/tg", ErrClaimDataMalformed)
}
if strings.TrimSpace(input.ContactValue) == "" {
return fmt.Errorf("%w: contact_value is required", ErrClaimDataMalformed)
}
if len(input.Remark) > 512 {
return fmt.Errorf("%w: remark too long (max 512)", ErrClaimDataMalformed)
}
return nil
}
@@ -0,0 +1,252 @@
// manual_claim_test.go — 单元测试三类人工奖 handler 的静态约束:
// - Type / IsAuto / Dispatch 契约
// - ClaimSchema 返回合法 JSON
// - ValidateClaim 正确/错误样本表驱动
package handler
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/perfect-panel/server/internal/model/lottery"
)
// ---- Crypto ---------------------------------------------------------------
func TestCryptoHandler_Contract(t *testing.T) {
h := NewCryptoHandler()
if h.Type() != lottery.PrizeTypeCrypto {
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeCrypto)
}
if h.IsAuto() {
t.Fatal("IsAuto must be false for manual claim handler")
}
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
t.Fatalf("Dispatch on manual handler must return ErrDispatchNotSupported, got %v", err)
}
if h.ClaimSchema() == nil {
t.Fatal("ClaimSchema must not be nil for manual handler")
}
var schema map[string]any
if err := json.Unmarshal(h.ClaimSchema(), &schema); err != nil {
t.Fatalf("ClaimSchema must be valid JSON: %v", err)
}
}
func TestCryptoHandler_ValidateClaim(t *testing.T) {
h := NewCryptoHandler()
cases := []struct {
name string
body string
wantErr bool
errIsErr error
}{
{"empty", "", true, ErrClaimDataEmpty},
{"whitespace", " ", true, ErrClaimDataEmpty},
{"malformed json", `{"network"`, true, ErrClaimDataMalformed},
{"missing network", `{"address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
{"empty network", `{"network":"","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
{"address too short", `{"network":"BTC","address":"abc"}`, true, ErrClaimDataMalformed},
{"address bad chars", `{"network":"BTC","address":"bc1$$!!****"}`, true, ErrClaimDataMalformed},
{"valid BTC", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, false, nil},
{"valid ETH", `{"network":"ETH","address":"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1"}`, false, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := h.ValidateClaim([]byte(tc.body))
if tc.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("expected nil, got %v", err)
}
if tc.errIsErr != nil && !errors.Is(err, tc.errIsErr) {
t.Fatalf("expected errors.Is %v, got %v", tc.errIsErr, err)
}
})
}
}
func TestValidateCryptoNetwork(t *testing.T) {
cases := []struct {
name string
body string
prizeConfig string
wantErr bool
}{
{"empty networks in cfg means allow-all", `{"network":"foo","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"amount":"1","currency":"BTC"}`, false},
{"network in whitelist", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"networks":["BTC","ETH"]}`, false},
{"network NOT in whitelist", `{"network":"XRP","address":"rXYZQabcdefghijkxxxxxxxx"}`, `{"networks":["BTC","ETH"]}`, true},
{"empty body", "", `{"networks":["BTC"]}`, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateCryptoNetwork([]byte(tc.body), tc.prizeConfig)
if tc.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
}
}
func TestBuildCryptoClaimSchema_InjectsNetworkEnum(t *testing.T) {
schema := BuildCryptoClaimSchema(`{"networks":["BTC","TRX"]}`)
s := string(schema)
if !strings.Contains(s, `"enum":["BTC","TRX"]`) {
t.Fatalf("expected schema to include enum with configured networks, got %s", s)
}
// 合法 JSON
var out map[string]any
if err := json.Unmarshal(schema, &out); err != nil {
t.Fatalf("built schema not valid JSON: %v", err)
}
}
func TestBuildCryptoClaimSchema_FallsBackWhenConfigInvalid(t *testing.T) {
// invalid JSON → fallback to generic schema without enum
schema := BuildCryptoClaimSchema(`not-json`)
var out map[string]any
if err := json.Unmarshal(schema, &out); err != nil {
t.Fatalf("fallback schema not valid JSON: %v", err)
}
}
// ---- Physical -------------------------------------------------------------
func TestPhysicalHandler_Contract(t *testing.T) {
h := NewPhysicalHandler()
if h.Type() != lottery.PrizeTypePhysical {
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypePhysical)
}
if h.IsAuto() {
t.Fatal("IsAuto must be false")
}
if h.ClaimSchema() == nil {
t.Fatal("ClaimSchema must not be nil")
}
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
}
}
func TestPhysicalHandler_ValidateClaim(t *testing.T) {
h := NewPhysicalHandler()
cases := []struct {
name string
body string
wantErr bool
}{
{"empty", ``, true},
{"missing name", `{"phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
{"bad phone", `{"name":"张三","phone":"abc","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
{"missing detail", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":""}`, true},
{"valid CN", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路 XX 号"}`, false},
{"valid international", `{"name":"John","phone":"+1 415-555-0100","province":"CA","city":"SF","district":"SoMa","detail":"1 Market St"}`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := h.ValidateClaim([]byte(tc.body))
if tc.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
}
}
// ---- ManualOther ---------------------------------------------------------
func TestManualOtherHandler_Contract(t *testing.T) {
h := NewManualOtherHandler()
if h.Type() != lottery.PrizeTypeManualOther {
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeManualOther)
}
if h.IsAuto() {
t.Fatal("IsAuto must be false")
}
if h.ClaimSchema() == nil {
t.Fatal("ClaimSchema must not be nil")
}
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
}
}
func TestManualOtherHandler_ValidateClaim(t *testing.T) {
h := NewManualOtherHandler()
cases := []struct {
name string
body string
wantErr bool
}{
{"empty", ``, true},
{"unknown contact_type", `{"contact_type":"fax","contact_value":"1234"}`, true},
{"missing contact_value", `{"contact_type":"phone","contact_value":""}`, true},
{"valid phone", `{"contact_type":"phone","contact_value":"+8613800001234","remark":"下午联系"}`, false},
{"valid email", `{"contact_type":"email","contact_value":"user@example.com"}`, false},
{"valid tg", `{"contact_type":"tg","contact_value":"@handle"}`, false},
{"remark too long", `{"contact_type":"email","contact_value":"x@y.z","remark":"` + strings.Repeat("x", 513) + `"}`, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := h.ValidateClaim([]byte(tc.body))
if tc.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
}
}
// ---- 状态机常量约束 --------------------------------------------------------
func TestIsClaimStatusResubmittable(t *testing.T) {
cases := []struct {
status string
want bool
}{
{lottery.ClaimStatusPendingClaim, true},
{lottery.ClaimStatusRejected, true},
{lottery.ClaimStatusReviewing, false},
{lottery.ClaimStatusPaying, false},
{lottery.ClaimStatusPaid, false},
{lottery.ClaimStatusExpired, false},
{"", false},
{"unknown", false},
}
for _, tc := range cases {
if got := lottery.IsClaimStatusResubmittable(tc.status); got != tc.want {
t.Errorf("IsClaimStatusResubmittable(%q) = %v, want %v", tc.status, got, tc.want)
}
}
}
func TestIsPrizeTypeManualClaim(t *testing.T) {
cases := []struct {
prizeType string
want bool
}{
{lottery.PrizeTypeCrypto, true},
{lottery.PrizeTypePhysical, true},
{lottery.PrizeTypeManualOther, true},
{lottery.PrizeTypeVPNDuration, false},
{lottery.PrizeTypeCommission, false},
{lottery.PrizeTypeNone, false},
{"unknown", false},
}
for _, tc := range cases {
if got := lottery.IsPrizeTypeManualClaim(tc.prizeType); got != tc.want {
t.Errorf("IsPrizeTypeManualClaim(%q) = %v, want %v", tc.prizeType, got, tc.want)
}
}
}
@@ -79,10 +79,11 @@ func NewVPNDurationHandler(deps VPNDurationDeps) *VPNDurationHandler {
return &VPNDurationHandler{deps: deps}
}
// 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 {
+189 -13
View File
@@ -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
}
@@ -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)
}
}
}