package luckygift import ( "context" "encoding/json" "errors" "net/http" "strings" "time" "hyapp-luck-gateway/internal/integration/luckygiftclient" "hyapp-luck-gateway/internal/requestid" "hyapp-luck-gateway/internal/response" ) type Handler struct { client luckygiftclient.Client allowedApps map[string]struct{} requestTimeout time.Duration } func New(client luckygiftclient.Client, requestTimeout time.Duration, allowedApps map[string]struct{}) *Handler { if requestTimeout <= 0 { requestTimeout = 3 * time.Second } return &Handler{client: client, allowedApps: cloneAllowedApps(allowedApps), requestTimeout: requestTimeout} } type sendRequest struct { AppCode string `json:"app_code"` RequestID string `json:"request_id"` ExternalUserID string `json:"external_user_id"` // DeviceID 由外部 App 在自己的认证/请求签名边界内绑定。 // luck-gateway 目前只做 App allowlist,不能独立证明该值来自真实设备;dynamic_v3 由 owner 对缺失值 fail-close。 DeviceID string `json:"device_id"` // PaidAtMS 是外部 App 自己完成扣费的 UTC epoch ms;调用方必须把它纳入自己的认证/签名边界。 // gateway 不用到达时间替代。fixed_v2 允许 0 兼容,dynamic_v3 由 owner 读取规则后强制要求正值。 PaidAtMS int64 `json:"paid_at_ms"` GiftCount int64 `json:"gift_count"` UnitAmount int64 `json:"unit_amount"` Currency string `json:"currency"` Metadata map[string]any `json:"metadata"` } func (h *Handler) Send(w http.ResponseWriter, r *http.Request) { requestID := requestid.FromContext(r.Context()) var req sendRequest decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(&req); err != nil { response.Fail(w, http.StatusBadRequest, response.CodeBadRequest, requestID, "invalid lucky gift send body") return } if err := req.validate(); err != nil { response.Fail(w, http.StatusBadRequest, response.CodeBadRequest, requestID, err.Error()) return } appCode := normalizeAppCode(req.AppCode) if !h.isAllowedApp(appCode) { response.Fail(w, http.StatusForbidden, response.CodeForbidden, requestID, "app_code is not allowed") return } ctx, cancel := context.WithTimeout(r.Context(), h.requestTimeout) defer cancel() result, err := h.client.SendLuckyGift(ctx, luckygiftclient.SendCommand{ TraceRequestID: requestID, AppCode: appCode, RequestID: strings.TrimSpace(req.RequestID), ExternalUserID: strings.TrimSpace(req.ExternalUserID), DeviceID: strings.TrimSpace(req.DeviceID), GiftCount: req.GiftCount, UnitAmount: req.UnitAmount, TotalAmount: req.GiftCount * req.UnitAmount, Currency: strings.TrimSpace(req.Currency), MetadataJSON: metadataJSON(req.Metadata), // pool 仍由 owner 默认规则选择;paid_at_ms 则必须保持外部账务事实,不能改写成 gateway 收包时间。 PaidAtMS: req.PaidAtMS, }) if err != nil { response.Fail(w, http.StatusInternalServerError, response.CodeServerError, requestID, "send lucky gift failed") return } response.OK(w, requestID, result) } func (r sendRequest) validate() error { if normalizeAppCode(r.AppCode) == "" { return errors.New("app_code is required") } if len(normalizeAppCode(r.AppCode)) > 32 { return errors.New("app_code is too long") } if strings.TrimSpace(r.RequestID) == "" { return errors.New("request_id is required") } if len(strings.TrimSpace(r.RequestID)) > 128 { return errors.New("request_id is too long") } if strings.TrimSpace(r.ExternalUserID) == "" { return errors.New("external_user_id is required") } if len(strings.TrimSpace(r.ExternalUserID)) > 128 { return errors.New("external_user_id is too long") } if len(strings.TrimSpace(r.DeviceID)) > 128 { return errors.New("device_id is too long") } if r.PaidAtMS < 0 { return errors.New("paid_at_ms is invalid") } // 不在这里统一要求 device_id:网关无法获知本次命中的不可变规则是 fixed_v2 还是 dynamic_v3。 // owner 读到规则后对 dynamic_v3 权威拒绝空值,fixed_v2 继续兼容旧调用方。 if r.GiftCount <= 0 || r.UnitAmount <= 0 { return errors.New("gift_count and unit_amount must be positive") } if r.GiftCount > 999 { return errors.New("gift_count cannot exceed 999") } // 网关只接受数量和单价;total_amount 由服务端计算,避免外部 App SDK 或恶意调用方传入不一致账面金额。 if r.GiftCount > (1<<63-1)/r.UnitAmount { return errors.New("gift amount is too large") } if currency := strings.ToUpper(strings.TrimSpace(r.Currency)); currency != "" && currency != "COIN" { // 当前 RTP、奖池和风控都以金币等价单位推进;未配置汇率前拒绝其他币种,避免污染奖池水位。 return errors.New("currency must be COIN") } if len(strings.TrimSpace(r.Currency)) > 16 { return errors.New("currency is too long") } return nil } func metadataJSON(value map[string]any) string { if len(value) == 0 { return "{}" } body, err := json.Marshal(value) if err != nil { // JSON decoder 已保证 map 可序列化;失败时返回空对象,让 owner service 不因展示元数据阻断抽奖。 return "{}" } return string(body) } func (h *Handler) isAllowedApp(appCode string) bool { if len(h.allowedApps) == 0 { return false } _, ok := h.allowedApps[normalizeAppCode(appCode)] return ok } func cloneAllowedApps(input map[string]struct{}) map[string]struct{} { out := make(map[string]struct{}, len(input)) for app := range input { if normalized := normalizeAppCode(app); normalized != "" { out[normalized] = struct{}{} } } return out } func normalizeAppCode(value string) string { return strings.ToLower(strings.TrimSpace(value)) }