155 lines
5.0 KiB
Go
155 lines
5.0 KiB
Go
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))
|
||
}
|