2026-06-18 22:41:52 +08:00

448 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}