package activityapi import ( "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "time" activityv1 "hyapp.local/api/proto/activity/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/transport/http/httpkit" ) type wheelDrawBody struct { Amount int64 `json:"amount"` CommandID string `json:"command_id"` RoomID string `json:"roomId"` Times int32 `json:"times"` } type wheelConfigData struct { WheelID string `json:"wheel_id"` Enabled bool `json:"enabled"` DrawPriceCoins int64 `json:"draw_price_coins"` Rewards []wheelPrizeData `json:"rewards"` } type wheelPrizeData struct { TierID string `json:"tier_id"` DisplayName string `json:"display_name"` RewardType string `json:"reward_type"` RewardID string `json:"reward_id"` RewardCount int64 `json:"reward_count"` RewardCoins int64 `json:"reward_coins"` Enabled bool `json:"enabled"` MetadataJSON string `json:"metadata_json"` PreviewURL string `json:"preview_url"` AnimationURL string `json:"animation_url"` Value int64 `json:"value"` } type wheelDrawData struct { DrawID string `json:"draw_id"` DrawIDs []string `json:"draw_ids"` CommandID string `json:"command_id"` WheelID string `json:"wheel_id"` SelectedTierID string `json:"selected_tier_id"` RewardType string `json:"reward_type"` RewardID string `json:"reward_id"` RewardCount int64 `json:"reward_count"` RewardCoins int64 `json:"reward_coins"` RewardStatus string `json:"reward_status"` WalletTransactionID string `json:"wallet_transaction_id"` CoinBalanceAfter int64 `json:"coin_balance_after"` MetadataJSON string `json:"metadata_json"` RewardValue int64 `json:"rewardValue"` Rewards []wheelPrizeData `json:"rewards"` } // getWheelConfig 返回 H5 渲染所需的当前奖池配置;只暴露资源图字段,不暴露后台 RTP 控制窗口。 func (h *Handler) getWheelConfig(writer http.ResponseWriter, request *http.Request) { if h.wheel == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.wheel.GetWheelConfig(request.Context(), &activityv1.GetWheelConfigRequest{ Meta: httpkit.ActivityMeta(request), WheelId: wheelIDFromRequest(request), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, wheelConfigFromProto(resp.GetConfig())) } // drawWheel 先完成钱包扣费,再把扣费事实传给 activity-service 抽奖;任何一步失败都直接返回错误,不伪造中奖。 func (h *Handler) drawWheel(writer http.ResponseWriter, request *http.Request) { if h.wheel == nil || h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } var body wheelDrawBody if err := httpkit.DecodeJSON(request.Body, &body); err != nil && !errors.Is(err, io.EOF) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body") return } wheelID := wheelIDFromRequest(request) times := body.Times if times <= 0 { times = 1 } configResp, err := h.wheel.GetWheelConfig(request.Context(), &activityv1.GetWheelConfigRequest{ Meta: httpkit.ActivityMeta(request), WheelId: wheelID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } config := configResp.GetConfig() if config == nil || !config.GetEnabled() { httpkit.WriteError(writer, request, http.StatusConflict, httpkit.CodeInvalidArgument, "wheel disabled") return } expectedAmount := config.GetDrawPriceCoins() * int64(times) if body.Amount > 0 && body.Amount != expectedAmount { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "draw amount mismatch") return } commandID := strings.TrimSpace(body.CommandID) if commandID == "" { commandID = idgen.New("wheel") } debitResp, err := h.walletClient.DebitWheelDraw(request.Context(), &walletv1.DebitWheelDrawRequest{ CommandId: commandID, AppCode: appcode.FromContext(request.Context()), UserId: auth.UserIDFromContext(request.Context()), WheelId: wheelID, DrawCount: times, Amount: expectedAmount, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } drawResp, err := h.wheel.ExecuteWheelDraw(request.Context(), &activityv1.ExecuteWheelDrawRequest{ Wheel: &activityv1.WheelDrawMeta{ Meta: httpkit.ActivityMeta(request), CommandId: commandID, WheelId: wheelID, UserId: auth.UserIDFromContext(request.Context()), DeviceId: wheelDeviceID(request), DrawCount: times, CoinSpent: debitResp.GetCoinSpent(), PaidAtMs: time.Now().UTC().UnixMilli(), VisibleRegionId: 0, }, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } data := wheelDrawFromProto(drawResp.GetResult()) data.WalletTransactionID = debitResp.GetTransactionId() data.CoinBalanceAfter = debitResp.GetCoinBalanceAfter() httpkit.WriteOK(writer, request, data) } // listWheelHistory 返回当前用户的转盘抽奖记录;读侧复用 activity-service 明细,避免 H5 直接拼后台接口。 func (h *Handler) listWheelHistory(writer http.ResponseWriter, request *http.Request) { if h.wheel == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.wheel.ListWheelDraws(request.Context(), &activityv1.ListWheelDrawsRequest{ Meta: httpkit.ActivityMeta(request), WheelId: wheelIDFromRequest(request), UserId: auth.UserIDFromContext(request.Context()), Page: int32(queryInt(request, "pageNo", 1)), PageSize: int32(queryInt(request, "pageSize", 20)), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } records := make([]map[string]any, 0, len(resp.GetDraws())) for _, item := range resp.GetDraws() { draw := wheelDrawFromProto(item) records = append(records, map[string]any{ "createdTime": item.GetCreatedAtMs(), "drawTimes": len(item.GetDrawIds()), "rewardValue": draw.RewardValue, "rewards": draw.Rewards, }) } httpkit.WriteOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()}) } // listWheelHints 返回最近中奖提示;昵称不在 activity-service 明细里,这里用用户 ID 占位,后续可接 user profile 批量补齐。 func (h *Handler) listWheelHints(writer http.ResponseWriter, request *http.Request) { if h.wheel == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.wheel.ListWheelDraws(request.Context(), &activityv1.ListWheelDrawsRequest{ Meta: httpkit.ActivityMeta(request), WheelId: wheelIDFromRequest(request), Status: "granted", Page: 1, PageSize: 20, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } values := make([]map[string]any, 0, len(resp.GetDraws())) for _, item := range resp.GetDraws() { reward := wheelPrizeFromDraw(item) values = append(values, map[string]any{ "avatar": reward.PreviewURL, "nickname": fmt.Sprintf("Winner %d", len(values)+1), "value": reward.DisplayName, }) } httpkit.WriteOK(writer, request, map[string]any{"values": values}) } func wheelIDFromRequest(request *http.Request) string { value := strings.TrimSpace(request.PathValue("wheel_id")) if value == "" { return "classic" } return value } func wheelDeviceID(request *http.Request) string { for _, value := range []string{ httpkit.FirstQueryOrHeader(request, "device_id", "X-Device-ID", "X-Hyapp-Device-ID"), auth.SessionIDFromContext(request.Context()), } { if strings.TrimSpace(value) != "" { return value } } return "h5" } func wheelConfigFromProto(config *activityv1.WheelRuleConfig) wheelConfigData { if config == nil { return wheelConfigData{Rewards: []wheelPrizeData{}} } rewards := make([]wheelPrizeData, 0, len(config.GetTiers())) for _, tier := range config.GetTiers() { rewards = append(rewards, wheelPrizeFromTier(tier)) } return wheelConfigData{ WheelID: config.GetWheelId(), Enabled: config.GetEnabled(), DrawPriceCoins: config.GetDrawPriceCoins(), Rewards: rewards, } } func wheelPrizeFromTier(tier *activityv1.WheelPrizeTier) wheelPrizeData { if tier == nil { return wheelPrizeData{} } previewURL, animationURL := wheelAssetURLs(tier.GetRewardType(), tier.GetMetadataJson()) return wheelPrizeData{ TierID: tier.GetTierId(), DisplayName: tier.GetDisplayName(), RewardType: tier.GetRewardType(), RewardID: tier.GetRewardId(), RewardCount: tier.GetRewardCount(), RewardCoins: tier.GetRewardCoins(), Enabled: tier.GetEnabled(), MetadataJSON: tier.GetMetadataJson(), PreviewURL: previewURL, AnimationURL: animationURL, Value: wheelPrizeDisplayValue(tier.GetRewardCoins(), tier.GetRtpValueCoins(), tier.GetRewardCount(), tier.GetMetadataJson()), } } func wheelDrawFromProto(item *activityv1.WheelDrawResult) wheelDrawData { if item == nil { return wheelDrawData{Rewards: []wheelPrizeData{}} } reward := wheelPrizeFromDraw(item) rewards := make([]wheelPrizeData, 0, len(item.GetRewards())) for _, detail := range item.GetRewards() { rewards = append(rewards, wheelPrizeFromDrawReward(detail)) } if len(rewards) == 0 { rewards = []wheelPrizeData{reward} } rewards = mergeWheelPrizeData(rewards) return wheelDrawData{ DrawID: item.GetDrawId(), DrawIDs: item.GetDrawIds(), CommandID: item.GetCommandId(), WheelID: item.GetWheelId(), SelectedTierID: item.GetSelectedTierId(), RewardType: item.GetRewardType(), RewardID: item.GetRewardId(), RewardCount: item.GetRewardCount(), RewardCoins: item.GetRewardCoins(), RewardStatus: item.GetRewardStatus(), WalletTransactionID: item.GetWalletTransactionId(), CoinBalanceAfter: item.GetCoinBalanceAfter(), MetadataJSON: item.GetMetadataJson(), RewardValue: wheelPrizeTotalValue(rewards), Rewards: rewards, } } func wheelPrizeFromDraw(item *activityv1.WheelDrawResult) wheelPrizeData { if item == nil { return wheelPrizeData{} } previewURL, animationURL := wheelAssetURLs(item.GetRewardType(), item.GetMetadataJson()) return wheelPrizeData{ TierID: item.GetSelectedTierId(), DisplayName: item.GetSelectedTierId(), RewardType: item.GetRewardType(), RewardID: item.GetRewardId(), RewardCount: item.GetRewardCount(), RewardCoins: item.GetRewardCoins(), MetadataJSON: item.GetMetadataJson(), PreviewURL: previewURL, AnimationURL: animationURL, Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount(), item.GetMetadataJson()), } } func wheelPrizeFromDrawReward(item *activityv1.WheelDrawReward) wheelPrizeData { if item == nil { return wheelPrizeData{} } previewURL, animationURL := wheelAssetURLs(item.GetRewardType(), item.GetMetadataJson()) return wheelPrizeData{ TierID: item.GetSelectedTierId(), DisplayName: item.GetSelectedTierId(), RewardType: item.GetRewardType(), RewardID: item.GetRewardId(), RewardCount: item.GetRewardCount(), RewardCoins: item.GetRewardCoins(), MetadataJSON: item.GetMetadataJson(), PreviewURL: previewURL, AnimationURL: animationURL, Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount(), item.GetMetadataJson()), } } func mergeWheelPrizeData(items []wheelPrizeData) []wheelPrizeData { merged := make([]wheelPrizeData, 0, len(items)) byKey := make(map[string]int, len(items)) for _, item := range items { key := wheelPrizeMergeKey(item) if index, ok := byKey[key]; ok { merged[index].RewardCount += item.RewardCount continue } byKey[key] = len(merged) merged = append(merged, item) } return merged } func wheelPrizeTotalValue(items []wheelPrizeData) int64 { var total int64 for _, item := range items { count := item.RewardCount if count <= 0 { count = 1 } total += item.Value * count } return total } func wheelPrizeMergeKey(item wheelPrizeData) string { return strings.Join([]string{ item.RewardType, item.RewardID, item.MetadataJSON, item.PreviewURL, item.AnimationURL, fmt.Sprintf("%d", item.Value), }, "|") } func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardCount int64, metadataJSON string) int64 { // H5 只需要一个展示值,不能把后台 RTP 字段原样暴露出去;金币奖品展示金币数,礼物可展示内部估值。 // 道具和装扮不计入 RTP 时内部估值会是 0,所以优先读取 metadata 里资源原价,避免页面只能展示 reward_count=1。 if rewardCoins > 0 { return rewardCoins } if displayValue := wheelMetadataDisplayValue(metadataJSON); displayValue > 0 { return displayValue } if internalValueCoins > 0 { return internalValueCoins } if rewardCount > 0 { return rewardCount } return 1 } func wheelMetadataDisplayValue(metadataJSON string) int64 { var metadata map[string]any if err := json.NewDecoder(strings.NewReader(strings.TrimSpace(metadataJSON))).Decode(&metadata); err != nil && err != io.EOF { return 0 } return firstMetadataInt64(metadata, "display_value", "displayValue", "coin_price", "coinPrice", "price", "value") } func wheelAssetURLs(rewardType string, metadataJSON string) (string, string) { var metadata map[string]any if err := json.NewDecoder(strings.NewReader(strings.TrimSpace(metadataJSON))).Decode(&metadata); err != nil && err != io.EOF { return "", "" } preview := firstMetadataString(metadata, "preview_url", "previewUrl", "asset_url", "assetUrl") if strings.EqualFold(strings.TrimSpace(rewardType), "coin") { return preview, "" } animation := firstMetadataString(metadata, "animation_url", "animationUrl") return preview, animation } func firstMetadataString(metadata map[string]any, keys ...string) string { for _, key := range keys { if value, ok := metadata[key].(string); ok && strings.TrimSpace(value) != "" { return value } } return "" } func firstMetadataInt64(metadata map[string]any, keys ...string) int64 { for _, key := range keys { value, ok := metadata[key] if !ok { continue } switch typed := value.(type) { case float64: if typed > 0 { return int64(typed) } case string: parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) if err == nil && parsed > 0 { return parsed } } } return 0 }