新功能(#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:
@@ -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;解码失败返回 ErrClaimDataMalformed(wrap 原因)。
|
||||
func decodeClaimJSON(raw []byte, out any) error {
|
||||
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" {
|
||||
return ErrClaimDataEmpty
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrClaimDataMalformed, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- crypto handler ------------------------------------------------------
|
||||
|
||||
// CryptoHandler 支持"加密货币"人工奖。运营在后台配置 amount / currency /
|
||||
// networks;用户选一个网络 + 填一个地址;运营线下打款后 mark-paid + tx_hash。
|
||||
type CryptoHandler struct{}
|
||||
|
||||
// NewCryptoHandler 构造 crypto handler。无依赖,registry 直接 Register 即可。
|
||||
func NewCryptoHandler() *CryptoHandler { return &CryptoHandler{} }
|
||||
|
||||
func (*CryptoHandler) Type() string { return lottery.PrizeTypeCrypto }
|
||||
func (*CryptoHandler) IsAuto() bool { return false }
|
||||
func (*CryptoHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return notSupportedDispatch(ctx, tx, req)
|
||||
}
|
||||
|
||||
// cryptoClaimSchemaJSON 是前端渲染表单的 JSON Schema。运行时 crypto handler
|
||||
// 会把奖品 config.networks 注入到 network 字段的 enum,让前端只放开这些网络。
|
||||
// 这里的常量是空 enum 的"模板";ClaimSchema() 返回不带具体 networks 的通用
|
||||
// 描述,实际抽中时 draw 服务会传具体奖品 config,用 BuildCryptoClaimSchema
|
||||
// 生成带 enum 的最终 schema 附到 draw response 上。
|
||||
var cryptoClaimSchemaJSON = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["network","address"],
|
||||
"properties": {
|
||||
"network": {"type":"string","title":"打款网络"},
|
||||
"address": {"type":"string","title":"钱包地址","minLength":16,"maxLength":128}
|
||||
}
|
||||
}`)
|
||||
|
||||
func (*CryptoHandler) ClaimSchema() json.RawMessage { return cryptoClaimSchemaJSON }
|
||||
|
||||
// BuildCryptoClaimSchema 在抽奖成功后按具体奖品 config 生成最终 schema:
|
||||
// 把 config.networks[] 注入到 network 字段的 enum,供前端下拉展示。
|
||||
// prizeConfig 为该奖品的完整 config JSON 字符串(内含 amount/currency/networks)。
|
||||
func BuildCryptoClaimSchema(prizeConfig string) json.RawMessage {
|
||||
var cfg cryptoConfig
|
||||
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
|
||||
return cryptoClaimSchemaJSON
|
||||
}
|
||||
if len(cfg.Networks) == 0 {
|
||||
return cryptoClaimSchemaJSON
|
||||
}
|
||||
// 拼一段带 enum 的 schema,尽量保持体积小、易读。
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"type":"object","required":["network","address"],"properties":{"network":{"type":"string","title":"打款网络","enum":[`)
|
||||
for i, n := range cfg.Networks {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
encoded, _ := json.Marshal(n)
|
||||
b.Write(encoded)
|
||||
}
|
||||
b.WriteString(`]},"address":{"type":"string","title":"钱包地址","minLength":16,"maxLength":128}}}`)
|
||||
return json.RawMessage(b.String())
|
||||
}
|
||||
|
||||
// cryptoConfig 是 lottery_prize.config 的解码目标。
|
||||
type cryptoConfig struct {
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
type cryptoClaimInput struct {
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// cryptoAddressRegexp 只做最低限度校验(长度 + 字符集),避免 handler 里
|
||||
// 绑定各种链的地址前缀(BTC/ETH/TRX 各有一套),把严格校验推给运营在
|
||||
// mark-paid 前人肉复核。
|
||||
var cryptoAddressRegexp = regexp.MustCompile(`^[A-Za-z0-9]{16,128}$`)
|
||||
|
||||
// ValidateClaim 校验用户提交的 { network, address }:
|
||||
// - network 必须非空(网络白名单是奖品 config 决定的,由 POST /claim 路径
|
||||
// 再做一次二次校验;handler 层只做格式校验,避免把奖品 config 传下来
|
||||
// 污染 ValidateClaim 的签名)
|
||||
// - address 必须匹配基础字符集与长度
|
||||
func (*CryptoHandler) ValidateClaim(raw []byte) error {
|
||||
var input cryptoClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(input.Network) == "" {
|
||||
return fmt.Errorf("%w: network is required", ErrClaimDataMalformed)
|
||||
}
|
||||
if !cryptoAddressRegexp.MatchString(strings.TrimSpace(input.Address)) {
|
||||
return fmt.Errorf("%w: address format invalid (16-128 alphanumeric)", ErrClaimDataMalformed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCryptoNetwork 二次校验用户选中的 network 必须在奖品 config.networks
|
||||
// 白名单里。抽出到独立函数是因为 handler.ValidateClaim 的签名不接受奖品配置;
|
||||
// 由 POST /claim 逻辑层负责调用。
|
||||
func ValidateCryptoNetwork(raw []byte, prizeConfig string) error {
|
||||
var input cryptoClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg cryptoConfig
|
||||
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
|
||||
return fmt.Errorf("decode crypto config: %w", err)
|
||||
}
|
||||
if len(cfg.Networks) == 0 {
|
||||
return nil
|
||||
}
|
||||
network := strings.TrimSpace(input.Network)
|
||||
for _, allowed := range cfg.Networks {
|
||||
if allowed == network {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: network %q not in allowed list", ErrClaimDataMalformed, network)
|
||||
}
|
||||
|
||||
// ---- physical handler ----------------------------------------------------
|
||||
|
||||
// PhysicalHandler 支持实物奖。运营 mark-paid 时用 delivery_ref 记录快递单号。
|
||||
type PhysicalHandler struct{}
|
||||
|
||||
// NewPhysicalHandler 构造 physical handler。
|
||||
func NewPhysicalHandler() *PhysicalHandler { return &PhysicalHandler{} }
|
||||
|
||||
func (*PhysicalHandler) Type() string { return lottery.PrizeTypePhysical }
|
||||
func (*PhysicalHandler) IsAuto() bool { return false }
|
||||
func (*PhysicalHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return notSupportedDispatch(ctx, tx, req)
|
||||
}
|
||||
|
||||
var physicalClaimSchemaJSON = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["name","phone","province","city","district","detail"],
|
||||
"properties": {
|
||||
"name": {"type":"string","title":"收件人姓名","minLength":1,"maxLength":64},
|
||||
"phone": {"type":"string","title":"联系电话","minLength":6,"maxLength":32},
|
||||
"province": {"type":"string","title":"省","minLength":1,"maxLength":32},
|
||||
"city": {"type":"string","title":"市","minLength":1,"maxLength":32},
|
||||
"district": {"type":"string","title":"区/县","minLength":1,"maxLength":32},
|
||||
"detail": {"type":"string","title":"详细地址","minLength":1,"maxLength":256}
|
||||
}
|
||||
}`)
|
||||
|
||||
func (*PhysicalHandler) ClaimSchema() json.RawMessage { return physicalClaimSchemaJSON }
|
||||
|
||||
type physicalClaimInput struct {
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Province string `json:"province"`
|
||||
City string `json:"city"`
|
||||
District string `json:"district"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// phoneRegexp 只允许数字、+、-、空格,长度 6-32;宽松以覆盖国际号码格式。
|
||||
var phoneRegexp = regexp.MustCompile(`^[0-9+\-\s]{6,32}$`)
|
||||
|
||||
func (*PhysicalHandler) ValidateClaim(raw []byte) error {
|
||||
var input physicalClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
return fmt.Errorf("%w: name is required", ErrClaimDataMalformed)
|
||||
}
|
||||
if !phoneRegexp.MatchString(strings.TrimSpace(input.Phone)) {
|
||||
return fmt.Errorf("%w: phone format invalid", ErrClaimDataMalformed)
|
||||
}
|
||||
if strings.TrimSpace(input.Province) == "" ||
|
||||
strings.TrimSpace(input.City) == "" ||
|
||||
strings.TrimSpace(input.District) == "" ||
|
||||
strings.TrimSpace(input.Detail) == "" {
|
||||
return fmt.Errorf("%w: address components are required", ErrClaimDataMalformed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- manual_other handler ------------------------------------------------
|
||||
|
||||
// ManualOtherHandler 支持"其他人工奖"(点赞、见面礼、线下券码等)。
|
||||
type ManualOtherHandler struct{}
|
||||
|
||||
// NewManualOtherHandler 构造 manual_other handler。
|
||||
func NewManualOtherHandler() *ManualOtherHandler { return &ManualOtherHandler{} }
|
||||
|
||||
func (*ManualOtherHandler) Type() string { return lottery.PrizeTypeManualOther }
|
||||
func (*ManualOtherHandler) IsAuto() bool { return false }
|
||||
func (*ManualOtherHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return notSupportedDispatch(ctx, tx, req)
|
||||
}
|
||||
|
||||
var manualOtherClaimSchemaJSON = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["contact_type","contact_value"],
|
||||
"properties": {
|
||||
"contact_type": {"type":"string","title":"联系方式类型","enum":["phone","email","tg"]},
|
||||
"contact_value": {"type":"string","title":"联系方式","minLength":1,"maxLength":128},
|
||||
"remark": {"type":"string","title":"备注","maxLength":512}
|
||||
}
|
||||
}`)
|
||||
|
||||
func (*ManualOtherHandler) ClaimSchema() json.RawMessage { return manualOtherClaimSchemaJSON }
|
||||
|
||||
type manualOtherClaimInput struct {
|
||||
ContactType string `json:"contact_type"`
|
||||
ContactValue string `json:"contact_value"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// manualOtherContactTypes 是 contact_type 允许的枚举。
|
||||
var manualOtherContactTypes = map[string]struct{}{
|
||||
"phone": {},
|
||||
"email": {},
|
||||
"tg": {},
|
||||
}
|
||||
|
||||
func (*ManualOtherHandler) ValidateClaim(raw []byte) error {
|
||||
var input manualOtherClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
ct := strings.TrimSpace(input.ContactType)
|
||||
if _, ok := manualOtherContactTypes[ct]; !ok {
|
||||
return fmt.Errorf("%w: contact_type must be one of phone/email/tg", ErrClaimDataMalformed)
|
||||
}
|
||||
if strings.TrimSpace(input.ContactValue) == "" {
|
||||
return fmt.Errorf("%w: contact_value is required", ErrClaimDataMalformed)
|
||||
}
|
||||
if len(input.Remark) > 512 {
|
||||
return fmt.Errorf("%w: remark too long (max 512)", ErrClaimDataMalformed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// manual_claim_test.go — 单元测试三类人工奖 handler 的静态约束:
|
||||
// - Type / IsAuto / Dispatch 契约
|
||||
// - ClaimSchema 返回合法 JSON
|
||||
// - ValidateClaim 正确/错误样本表驱动
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
)
|
||||
|
||||
// ---- Crypto ---------------------------------------------------------------
|
||||
|
||||
func TestCryptoHandler_Contract(t *testing.T) {
|
||||
h := NewCryptoHandler()
|
||||
if h.Type() != lottery.PrizeTypeCrypto {
|
||||
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeCrypto)
|
||||
}
|
||||
if h.IsAuto() {
|
||||
t.Fatal("IsAuto must be false for manual claim handler")
|
||||
}
|
||||
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||
t.Fatalf("Dispatch on manual handler must return ErrDispatchNotSupported, got %v", err)
|
||||
}
|
||||
if h.ClaimSchema() == nil {
|
||||
t.Fatal("ClaimSchema must not be nil for manual handler")
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(h.ClaimSchema(), &schema); err != nil {
|
||||
t.Fatalf("ClaimSchema must be valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoHandler_ValidateClaim(t *testing.T) {
|
||||
h := NewCryptoHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
errIsErr error
|
||||
}{
|
||||
{"empty", "", true, ErrClaimDataEmpty},
|
||||
{"whitespace", " ", true, ErrClaimDataEmpty},
|
||||
{"malformed json", `{"network"`, true, ErrClaimDataMalformed},
|
||||
{"missing network", `{"address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
||||
{"empty network", `{"network":"","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
||||
{"address too short", `{"network":"BTC","address":"abc"}`, true, ErrClaimDataMalformed},
|
||||
{"address bad chars", `{"network":"BTC","address":"bc1$$!!****"}`, true, ErrClaimDataMalformed},
|
||||
{"valid BTC", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, false, nil},
|
||||
{"valid ETH", `{"network":"ETH","address":"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1"}`, false, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.ValidateClaim([]byte(tc.body))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
if tc.errIsErr != nil && !errors.Is(err, tc.errIsErr) {
|
||||
t.Fatalf("expected errors.Is %v, got %v", tc.errIsErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCryptoNetwork(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
prizeConfig string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty networks in cfg means allow-all", `{"network":"foo","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"amount":"1","currency":"BTC"}`, false},
|
||||
{"network in whitelist", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"networks":["BTC","ETH"]}`, false},
|
||||
{"network NOT in whitelist", `{"network":"XRP","address":"rXYZQabcdefghijkxxxxxxxx"}`, `{"networks":["BTC","ETH"]}`, true},
|
||||
{"empty body", "", `{"networks":["BTC"]}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateCryptoNetwork([]byte(tc.body), tc.prizeConfig)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCryptoClaimSchema_InjectsNetworkEnum(t *testing.T) {
|
||||
schema := BuildCryptoClaimSchema(`{"networks":["BTC","TRX"]}`)
|
||||
s := string(schema)
|
||||
if !strings.Contains(s, `"enum":["BTC","TRX"]`) {
|
||||
t.Fatalf("expected schema to include enum with configured networks, got %s", s)
|
||||
}
|
||||
// 合法 JSON
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(schema, &out); err != nil {
|
||||
t.Fatalf("built schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCryptoClaimSchema_FallsBackWhenConfigInvalid(t *testing.T) {
|
||||
// invalid JSON → fallback to generic schema without enum
|
||||
schema := BuildCryptoClaimSchema(`not-json`)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(schema, &out); err != nil {
|
||||
t.Fatalf("fallback schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Physical -------------------------------------------------------------
|
||||
|
||||
func TestPhysicalHandler_Contract(t *testing.T) {
|
||||
h := NewPhysicalHandler()
|
||||
if h.Type() != lottery.PrizeTypePhysical {
|
||||
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypePhysical)
|
||||
}
|
||||
if h.IsAuto() {
|
||||
t.Fatal("IsAuto must be false")
|
||||
}
|
||||
if h.ClaimSchema() == nil {
|
||||
t.Fatal("ClaimSchema must not be nil")
|
||||
}
|
||||
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhysicalHandler_ValidateClaim(t *testing.T) {
|
||||
h := NewPhysicalHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", ``, true},
|
||||
{"missing name", `{"phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
||||
{"bad phone", `{"name":"张三","phone":"abc","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
||||
{"missing detail", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":""}`, true},
|
||||
{"valid CN", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路 XX 号"}`, false},
|
||||
{"valid international", `{"name":"John","phone":"+1 415-555-0100","province":"CA","city":"SF","district":"SoMa","detail":"1 Market St"}`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.ValidateClaim([]byte(tc.body))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ManualOther ---------------------------------------------------------
|
||||
|
||||
func TestManualOtherHandler_Contract(t *testing.T) {
|
||||
h := NewManualOtherHandler()
|
||||
if h.Type() != lottery.PrizeTypeManualOther {
|
||||
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeManualOther)
|
||||
}
|
||||
if h.IsAuto() {
|
||||
t.Fatal("IsAuto must be false")
|
||||
}
|
||||
if h.ClaimSchema() == nil {
|
||||
t.Fatal("ClaimSchema must not be nil")
|
||||
}
|
||||
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualOtherHandler_ValidateClaim(t *testing.T) {
|
||||
h := NewManualOtherHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", ``, true},
|
||||
{"unknown contact_type", `{"contact_type":"fax","contact_value":"1234"}`, true},
|
||||
{"missing contact_value", `{"contact_type":"phone","contact_value":""}`, true},
|
||||
{"valid phone", `{"contact_type":"phone","contact_value":"+8613800001234","remark":"下午联系"}`, false},
|
||||
{"valid email", `{"contact_type":"email","contact_value":"user@example.com"}`, false},
|
||||
{"valid tg", `{"contact_type":"tg","contact_value":"@handle"}`, false},
|
||||
{"remark too long", `{"contact_type":"email","contact_value":"x@y.z","remark":"` + strings.Repeat("x", 513) + `"}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.ValidateClaim([]byte(tc.body))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 状态机常量约束 --------------------------------------------------------
|
||||
|
||||
func TestIsClaimStatusResubmittable(t *testing.T) {
|
||||
cases := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{lottery.ClaimStatusPendingClaim, true},
|
||||
{lottery.ClaimStatusRejected, true},
|
||||
{lottery.ClaimStatusReviewing, false},
|
||||
{lottery.ClaimStatusPaying, false},
|
||||
{lottery.ClaimStatusPaid, false},
|
||||
{lottery.ClaimStatusExpired, false},
|
||||
{"", false},
|
||||
{"unknown", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := lottery.IsClaimStatusResubmittable(tc.status); got != tc.want {
|
||||
t.Errorf("IsClaimStatusResubmittable(%q) = %v, want %v", tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrizeTypeManualClaim(t *testing.T) {
|
||||
cases := []struct {
|
||||
prizeType string
|
||||
want bool
|
||||
}{
|
||||
{lottery.PrizeTypeCrypto, true},
|
||||
{lottery.PrizeTypePhysical, true},
|
||||
{lottery.PrizeTypeManualOther, true},
|
||||
{lottery.PrizeTypeVPNDuration, false},
|
||||
{lottery.PrizeTypeCommission, false},
|
||||
{lottery.PrizeTypeNone, false},
|
||||
{"unknown", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := lottery.IsPrizeTypeManualClaim(tc.prizeType); got != tc.want {
|
||||
t.Errorf("IsPrizeTypeManualClaim(%q) = %v, want %v", tc.prizeType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,10 +79,11 @@ func NewVPNDurationHandler(deps VPNDurationDeps) *VPNDurationHandler {
|
||||
return &VPNDurationHandler{deps: deps}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user