448 lines
15 KiB
Go
448 lines
15 KiB
Go
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
|
||
}
|