新功能(#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)
}
}