Files
hi-server/internal/logic/lottery/handler/manual_claim.go
T
shanshanzhong147 07409eb602 新功能(#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%
2026-07-10 04:01:34 -07:00

281 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}