167 lines
5.7 KiB
Go
167 lines
5.7 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
|
||
}
|
||
|
||
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))
|
||
}
|