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 now func() time.Time } 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, now: time.Now} } type sendRequest struct { AppCode string `json:"app_code"` RequestID string `json:"request_id"` ExternalUserID string `json:"external_user_id"` 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), GiftCount: req.GiftCount, UnitAmount: req.UnitAmount, TotalAmount: req.GiftCount * req.UnitAmount, Currency: strings.TrimSpace(req.Currency), MetadataJSON: metadataJSON(req.Metadata), // 外部 HTTP 契约不暴露 pool_id/paid_at_ms;pool 由 owner service 默认规则选择,支付完成时间使用网关服务端 UTC 时间, // 避免外部 App 通过请求体影响奖池分桶、预算日或风控窗口。 PaidAtMS: normalizePaidAt(h.now), }) 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 r.GiftCount <= 0 || r.UnitAmount <= 0 { return errors.New("gift_count and unit_amount must be positive") } // 网关只接受数量和单价;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 normalizePaidAt(now func() time.Time) int64 { return now().UTC().UnixMilli() } 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)) }