// 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 }