812 lines
29 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 main
import (
"bytes"
"context"
"crypto/rand"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const (
demoGiftID = "local_lucky_meteor_100"
demoResourceCode = "local_lucky_meteor_resource"
demoPoolID = "local_dynamic_v3"
demoGiftPrice = int64(100)
)
//go:embed index.html
var indexHTML []byte
type demoConfig struct {
ListenAddr string
GatewayBaseURL string
AdminBaseURL string
AdminUsername string
AdminPassword string
WalletAddr string
LuckyGiftAddr string
AppCode string
InitialCoins int64
}
type demoServer struct {
config demoConfig
http *http.Client
proxy *httputil.ReverseProxy
walletConn *grpc.ClientConn
luckyConn *grpc.ClientConn
wallet walletv1.WalletServiceClient
luckyAdmin luckygiftv1.AdminLuckyGiftServiceClient
// setupMu serializes fixture creation because quick accounts, an enabled rule and a room are one local-test unit.
// A failed setup never publishes a half-filled in-memory fixture to the page.
setupMu sync.Mutex
fixture *demoFixture
}
type demoFixture struct {
AppCode string `json:"app_code"`
RoomID string `json:"room_id"`
RoomName string `json:"room_name"`
RegionID int64 `json:"region_id"`
GiftID string `json:"gift_id"`
GiftName string `json:"gift_name"`
GiftPrice int64 `json:"gift_price"`
PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
InitialCoins int64 `json:"initial_coins"`
SenderToken string `json:"sender_token"`
Sender fixtureUser `json:"sender"`
Host fixtureUser `json:"host"`
CreatedAtMS int64 `json:"created_at_ms"`
hostToken string
}
type fixtureUser struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Name string `json:"name"`
DeviceID string `json:"device_id"`
}
type quickCreateResponse struct {
UID string `json:"uid"`
Account string `json:"account"`
AccessToken string `json:"access_token"`
Token struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
AccessToken string `json:"access_token"`
} `json:"token"`
}
type gatewayEnvelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data json.RawMessage `json:"data"`
}
type adminEnvelope struct {
Code any `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
type giftRatioItem struct {
GiftTypeCode string `json:"giftTypeCode"`
RatioPercent string `json:"ratioPercent"`
ReturnCoinRatioPercent string `json:"returnCoinRatioPercent"`
}
type giftRatioList struct {
RegionID int64 `json:"regionId"`
Items []giftRatioItem `json:"items"`
}
func newDemoServer(config demoConfig) (*demoServer, error) {
if strings.TrimSpace(config.AppCode) == "" || config.InitialCoins <= 0 {
return nil, errors.New("app and initial-coins must be configured")
}
gatewayURL, err := url.Parse(strings.TrimRight(config.GatewayBaseURL, "/"))
if err != nil || gatewayURL.Scheme == "" || gatewayURL.Host == "" {
return nil, fmt.Errorf("invalid gateway URL %q", config.GatewayBaseURL)
}
walletConn, err := grpc.NewClient(config.WalletAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("connect wallet-service: %w", err)
}
luckyConn, err := grpc.NewClient(config.LuckyGiftAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = walletConn.Close()
return nil, fmt.Errorf("connect lucky-gift-service: %w", err)
}
proxy := httputil.NewSingleHostReverseProxy(gatewayURL)
originalDirector := proxy.Director
proxy.Director = func(request *http.Request) {
originalDirector(request)
request.Host = gatewayURL.Host
}
proxy.ModifyResponse = func(response *http.Response) error {
response.Header.Set("Cache-Control", "no-store")
return nil
}
server := &demoServer{
config: config,
http: &http.Client{Timeout: 15 * time.Second},
proxy: proxy,
walletConn: walletConn,
luckyConn: luckyConn,
wallet: walletv1.NewWalletServiceClient(walletConn),
luckyAdmin: luckygiftv1.NewAdminLuckyGiftServiceClient(luckyConn),
}
proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, proxyErr error) {
server.writeError(writer, http.StatusBadGateway, "gateway unavailable: "+proxyErr.Error())
}
return server, nil
}
func (s *demoServer) Close() {
_ = s.walletConn.Close()
_ = s.luckyConn.Close()
}
func (s *demoServer) Routes() http.Handler {
mux := http.NewServeMux()
// 根页面必须使用精确模式;若写成 GET /Go 1.22+ 会把它视为路径前缀,
// 与允许多种 HTTP 方法的 /api/ 反向代理产生歧义并在启动时直接 panic。
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /index.html", s.handleIndex)
mux.HandleFunc("POST /__local/setup", s.handleSetup)
mux.HandleFunc("POST /__local/heartbeat", s.handleHeartbeat)
mux.HandleFunc("GET /__local/state", s.handleState)
mux.Handle("/api/", s.proxy)
return mux
}
func (s *demoServer) handleIndex(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.Header().Set("Cache-Control", "no-store")
_, _ = writer.Write(indexHTML)
}
func (s *demoServer) handleSetup(writer http.ResponseWriter, request *http.Request) {
s.setupMu.Lock()
defer s.setupMu.Unlock()
ctx, cancel := context.WithTimeout(request.Context(), 35*time.Second)
defer cancel()
fixture := s.fixture
var err error
if fixture == nil {
fixture, err = s.createFixture(ctx)
if err == nil {
s.fixture = fixture
}
} else {
// "准备测试数据" is intentionally a reset boundary: the sender starts at exactly one million coins,
// while the same authenticated users and room remain real owner-service rows for repeatable UI work.
err = s.setSenderBalance(ctx, fixture, s.config.InitialCoins)
if err == nil {
err = s.heartbeatFixture(ctx, fixture)
}
}
if err != nil {
s.writeError(writer, http.StatusBadGateway, err.Error())
return
}
s.writeOK(writer, fixture)
}
func (s *demoServer) createFixture(ctx context.Context) (*demoFixture, error) {
stamp := time.Now().UTC().UnixMilli()
host, hostToken, err := s.quickCreate(ctx, "星河主播", fmt.Sprintf("lucky-demo-host-%d", stamp))
if err != nil {
return nil, fmt.Errorf("create host: %w", err)
}
sender, senderToken, err := s.quickCreate(ctx, "幸运玩家", fmt.Sprintf("lucky-demo-sender-%d", stamp))
if err != nil {
return nil, fmt.Errorf("create sender: %w", err)
}
operatorID, err := parsePositiveInt64(host.UserID, "host user_id")
if err != nil {
return nil, err
}
if err := s.ensureGiftCatalog(ctx, operatorID); err != nil {
return nil, fmt.Errorf("ensure lucky gift: %w", err)
}
roomID, regionID, err := s.createRoom(ctx, hostToken)
if err != nil {
return nil, fmt.Errorf("create voice room: %w", err)
}
if err := s.ensureGiftReturnPolicy(ctx, regionID); err != nil {
return nil, fmt.Errorf("ensure regional lucky gift return: %w", err)
}
rule, err := s.ensureDynamicRule(ctx, operatorID)
if err != nil {
return nil, fmt.Errorf("ensure dynamic_v3 rule: %w", err)
}
// CreateRoom already establishes owner presence, but an explicit JoinRoom keeps the fixture aligned with the
// public client lifecycle and refreshes the same lease before the sender starts interacting with the room.
if err := s.joinRoom(ctx, hostToken, roomID, "host"); err != nil {
return nil, fmt.Errorf("join host: %w", err)
}
if err := s.joinRoom(ctx, senderToken, roomID, "sender"); err != nil {
return nil, fmt.Errorf("join sender: %w", err)
}
fixture := &demoFixture{
AppCode: s.config.AppCode,
RoomID: roomID,
RoomName: "星河幸运房",
RegionID: regionID,
GiftID: demoGiftID,
GiftName: "幸运流星",
GiftPrice: demoGiftPrice,
PoolID: demoPoolID,
RuleVersion: rule.GetRuleVersion(),
TargetRTPPPM: rule.GetTargetRtpPpm(),
InitialCoins: s.config.InitialCoins,
SenderToken: senderToken,
Sender: sender,
Host: host,
CreatedAtMS: time.Now().UTC().UnixMilli(),
hostToken: hostToken,
}
if err := s.setSenderBalance(ctx, fixture, s.config.InitialCoins); err != nil {
return nil, err
}
if err := s.assertGiftPanel(ctx, fixture); err != nil {
return nil, err
}
return fixture, nil
}
func (s *demoServer) quickCreate(ctx context.Context, name, deviceID string) (fixtureUser, string, error) {
body := map[string]any{
"password": "LocalLucky#2026",
"username": name,
"country": "SG",
"gender": "unknown",
"device_id": deviceID,
// 账号注册协议当前只接受 android/ios页面仍通过本地 Web 调用 gateway
// 这里的 android 仅是落库的真实测试账号平台快照,不伪造第三方凭证。
"platform": "android",
"language": "zh-CN",
"timezone": "Asia/Shanghai",
"source": "quick_account",
"install_channel": "local_lucky_room_demo",
}
var data quickCreateResponse
if err := s.gatewayJSON(ctx, http.MethodPost, "/api/v1/auth/account/quick-create", "", body, &data); err != nil {
return fixtureUser{}, "", err
}
token := data.AccessToken
if token == "" {
token = data.Token.AccessToken
}
userID := strings.TrimSpace(data.Token.UserID)
displayID := strings.TrimSpace(data.UID)
if displayID == "" {
displayID = strings.TrimSpace(data.Token.DisplayUserID)
}
if userID == "" || displayID == "" || token == "" {
return fixtureUser{}, "", fmt.Errorf("quick-create response is incomplete")
}
if _, err := strconv.ParseInt(userID, 10, 64); err != nil {
return fixtureUser{}, "", fmt.Errorf("quick-create user_id is invalid: %w", err)
}
return fixtureUser{UserID: userID, DisplayUserID: displayID, Name: name, DeviceID: deviceID}, token, nil
}
func (s *demoServer) createRoom(ctx context.Context, token string) (string, int64, error) {
body := map[string]any{
"command_id": commandID("room-create"),
"seat_count": 10,
"mode": "voice",
"room_name": "星河幸运房",
"room_avatar": "https://cdn.example.com/room/lucky-gift-demo.png",
"room_description": "真实用户、真实房间、真实钱包的 dynamic_v3 幸运礼物联调房",
}
var data struct {
Room struct {
RoomID string `json:"room_id"`
VisibleRegionID int64 `json:"visible_region_id"`
} `json:"room"`
}
if err := s.gatewayJSON(ctx, http.MethodPost, "/api/v1/rooms/create", token, body, &data); err != nil {
return "", 0, err
}
if strings.TrimSpace(data.Room.RoomID) == "" {
return "", 0, errors.New("create room response is missing room_id")
}
if data.Room.VisibleRegionID <= 0 {
return "", 0, errors.New("create room response is missing visible_region_id")
}
return data.Room.RoomID, data.Room.VisibleRegionID, nil
}
func (s *demoServer) joinRoom(ctx context.Context, token, roomID, role string) error {
return s.gatewayJSON(ctx, http.MethodPost, "/api/v1/rooms/join", token, map[string]any{
"room_id": roomID,
"command_id": commandID("room-join-" + role),
"role": "audience",
"response_mode": "lite",
}, nil)
}
func (s *demoServer) heartbeatFixture(ctx context.Context, fixture *demoFixture) error {
for role, token := range map[string]string{"host": fixture.hostToken, "sender": fixture.SenderToken} {
if err := s.gatewayJSON(ctx, http.MethodPost, "/api/v1/rooms/heartbeat", token, map[string]any{
"room_id": fixture.RoomID,
"command_id": commandID("room-heartbeat-" + role),
}, nil); err != nil {
return fmt.Errorf("heartbeat %s: %w", role, err)
}
}
return nil
}
func (s *demoServer) handleHeartbeat(writer http.ResponseWriter, request *http.Request) {
s.setupMu.Lock()
defer s.setupMu.Unlock()
if s.fixture == nil {
s.writeError(writer, http.StatusConflict, "fixture is not ready")
return
}
ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second)
defer cancel()
if err := s.heartbeatFixture(ctx, s.fixture); err != nil {
s.writeError(writer, http.StatusBadGateway, err.Error())
return
}
s.writeOK(writer, map[string]any{"room_id": s.fixture.RoomID, "heartbeat_at_ms": time.Now().UTC().UnixMilli()})
}
func (s *demoServer) ensureGiftCatalog(ctx context.Context, operatorID int64) error {
resources, err := s.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{
RequestId: commandID("resource-list"), AppCode: s.config.AppCode, ResourceType: "gift", Keyword: demoResourceCode, Page: 1, PageSize: 100,
})
if err != nil {
return err
}
var resourceID int64
for _, resource := range resources.GetResources() {
if resource.GetResourceCode() == demoResourceCode {
resourceID = resource.GetResourceId()
break
}
}
if resourceID == 0 {
created, createErr := s.wallet.CreateResource(ctx, &walletv1.CreateResourceRequest{
RequestId: commandID("resource-create"), AppCode: s.config.AppCode,
ResourceCode: demoResourceCode, ResourceType: "gift", Name: "幸运流星", Status: "active",
// 礼物资源若被运营发放会累加背包数量grant_strategy 使用钱包域的明确枚举,
// 不能把展示层的“永久”概念写进资源 owner 的协议。
Grantable: true, GrantStrategy: "increase_quantity", UsageScopes: []string{"gift_panel"},
MetadataJson: `{"local_demo":true,"visual":"meteor"}`, SortOrder: 10, OperatorUserId: operatorID,
PriceType: "coin", CoinPrice: demoGiftPrice,
})
if createErr != nil {
return createErr
}
resourceID = created.GetResource().GetResourceId()
}
gifts, err := s.wallet.ListGiftConfigs(ctx, &walletv1.ListGiftConfigsRequest{
RequestId: commandID("gift-list"), AppCode: s.config.AppCode, Keyword: demoGiftID, Page: 1, PageSize: 100,
})
if err != nil {
return err
}
for _, gift := range gifts.GetGifts() {
if gift.GetGiftId() != demoGiftID {
continue
}
_, err = s.wallet.UpdateGiftConfig(ctx, &walletv1.UpdateGiftConfigRequest{
RequestId: commandID("gift-update"), AppCode: s.config.AppCode, GiftId: demoGiftID,
ResourceId: resourceID, Status: "active", Name: "幸运流星", SortOrder: 10,
PresentationJson: `{"lucky_pool_id":"` + demoPoolID + `","visual":"meteor"}`,
PriceVersion: "local-dynamic-v3", CoinPrice: demoGiftPrice, HeatValue: demoGiftPrice,
OperatorUserId: operatorID, RegionIds: []int64{0}, GiftTypeCode: "super_lucky", ChargeAssetType: "COIN",
})
return err
}
_, err = s.wallet.CreateGiftConfig(ctx, &walletv1.CreateGiftConfigRequest{
RequestId: commandID("gift-create"), AppCode: s.config.AppCode, GiftId: demoGiftID,
ResourceId: resourceID, Status: "active", Name: "幸运流星", SortOrder: 10,
PresentationJson: `{"lucky_pool_id":"` + demoPoolID + `","visual":"meteor"}`,
PriceVersion: "local-dynamic-v3", CoinPrice: demoGiftPrice, HeatValue: demoGiftPrice,
OperatorUserId: operatorID, RegionIds: []int64{0}, GiftTypeCode: "super_lucky", ChargeAssetType: "COIN",
})
return err
}
func (s *demoServer) ensureGiftReturnPolicy(ctx context.Context, regionID int64) error {
if regionID <= 0 {
return errors.New("room region must be positive")
}
adminToken, err := s.adminLogin(ctx)
if err != nil {
return err
}
var current giftRatioList
path := "/api/v1/admin/operations/gift-diamond-ratios?region_id=" + strconv.FormatInt(regionID, 10)
if err := s.adminJSON(ctx, http.MethodGet, path, adminToken, nil, &current); err != nil {
return err
}
ratios := make(map[string]any, len(current.Items))
returns := make(map[string]any, len(current.Items))
foundSuperLucky := false
needsUpdate := false
for _, item := range current.Items {
giftType := strings.TrimSpace(item.GiftTypeCode)
if giftType == "" || strings.TrimSpace(item.RatioPercent) == "" || strings.TrimSpace(item.ReturnCoinRatioPercent) == "" {
return errors.New("gift ratio owner returned an incomplete item")
}
ratios[giftType] = item.RatioPercent
returns[giftType] = item.ReturnCoinRatioPercent
if giftType == "super_lucky" {
foundSuperLucky = true
needsUpdate = item.ReturnCoinRatioPercent != "1.00"
returns[giftType] = "1.00"
}
}
if !foundSuperLucky || len(ratios) != 3 {
return errors.New("gift ratio owner did not return all three gift types")
}
if !needsUpdate {
return nil
}
// 98/1/1 的 dynamic_v3 只能消费 wallet 固化的真实 1% 收礼返币。这里通过后台 owner API
// 仅修正当前真实房间区域的 super_lucky 返币,并把 GET 到的其他比例原样回写;不能直改钱包表、
// 不能篡改已扣费回执,也不能让 lucky service 在金额不一致时继续开奖。
var updated giftRatioList
if err := s.adminJSON(ctx, http.MethodPut, "/api/v1/admin/operations/gift-diamond-ratios", adminToken, map[string]any{
"regionId": regionID, "ratios": ratios, "returnCoinRatios": returns,
}, &updated); err != nil {
return err
}
for _, item := range updated.Items {
if item.GiftTypeCode == "super_lucky" && item.ReturnCoinRatioPercent == "1.00" {
return nil
}
}
return errors.New("super_lucky return ratio was not persisted as 1.00%")
}
func (s *demoServer) ensureDynamicRule(ctx context.Context, operatorID int64) (*luckygiftv1.LuckyGiftRuleConfig, error) {
meta := s.luckyMeta("rule-get")
current, err := s.luckyAdmin.GetLuckyGiftConfig(ctx, &luckygiftv1.GetLuckyGiftConfigRequest{Meta: meta, PoolId: demoPoolID})
if err == nil {
config := current.GetConfig()
if config.GetEnabled() && config.GetStrategyVersion() == "dynamic_v3" && config.GetPoolRatePpm() == 980_000 && config.GetProfitRatePpm() == 10_000 && config.GetAnchorRatePpm() == 10_000 {
return config, nil
}
}
stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, 3)
for index, name := range []string{"novice", "normal", "advanced"} {
min7, min30 := int64(0), int64(0)
if index == 1 {
min30 = 1
}
if index == 2 {
min7, min30 = 1, 1
}
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
Stage: name, MinRecharge_7DCoins: min7, MinRecharge_30DCoins: min30,
Tiers: []*luckygiftv1.LuckyGiftRuleTier{
{Stage: name, TierId: name + "_none", MultiplierPpm: 0, BaseWeightPpm: 50_000, Enabled: true},
{Stage: name, TierId: name + "_0_5x", MultiplierPpm: 500_000, BaseWeightPpm: 40_000, Enabled: true},
{Stage: name, TierId: name + "_1x", MultiplierPpm: 1_000_000, BaseWeightPpm: 860_000, Enabled: true},
{Stage: name, TierId: name + "_2x", MultiplierPpm: 2_000_000, BaseWeightPpm: 50_000, Enabled: true},
},
})
}
upserted, err := s.luckyAdmin.UpsertLuckyGiftConfig(ctx, &luckygiftv1.UpsertLuckyGiftConfigRequest{
Meta: s.luckyMeta("rule-upsert"), OperatorAdminId: operatorID,
Config: &luckygiftv1.LuckyGiftRuleConfig{
AppCode: s.config.AppCode, PoolId: demoPoolID, Enabled: true, StrategyVersion: "dynamic_v3",
TargetRtpPpm: 980_000, PoolRatePpm: 980_000, ProfitRatePpm: 10_000, AnchorRatePpm: 10_000,
SettlementWindowWager: 1_000_000, ControlBandPpm: 30_000, GiftPriceReference: demoGiftPrice,
NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000,
EffectiveFromMs: time.Now().UTC().Add(-time.Second).UnixMilli(), CreatedByAdminId: operatorID,
InitialPoolCoins: 1_000_000, LossStreakGuarantee: 5,
LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPpm: 700_000,
HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPpm: 1_300_000,
RechargeBoostWindowMs: 300_000, RechargeBoostFactorPpm: 1_100_000,
JackpotMultiplierPpms: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRtpMaxPpm: 980_000, JackpotUserDayRtpMaxPpm: 960_000, JackpotUser_72HRtpMaxPpm: 960_000,
JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5,
MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 5_000_000,
DeviceDailyPayoutCap: 5_000_000, RoomHourlyPayoutCap: 10_000_000, AnchorDailyPayoutCap: 10_000_000,
Stages: stages,
},
})
if err != nil {
return nil, err
}
return upserted.GetConfig(), nil
}
func (s *demoServer) setSenderBalance(ctx context.Context, fixture *demoFixture, wanted int64) error {
current, err := s.gatewayCoinBalance(ctx, fixture.SenderToken)
if err != nil {
return fmt.Errorf("read sender balance: %w", err)
}
delta := wanted - current
if delta == 0 {
return nil
}
adminToken, err := s.adminLogin(ctx)
if err != nil {
return fmt.Errorf("admin login: %w", err)
}
body := map[string]any{
"commandId": commandID("coin-adjust"),
"targetUserId": fixture.Sender.UserID,
"amount": delta,
"reason": "local_lucky_gift_demo",
"remark": fmt.Sprintf("set sender initial COIN balance to %d", wanted),
}
if err := s.adminJSON(ctx, http.MethodPost, "/api/v1/admin/operations/coin-adjustments", adminToken, body, nil); err != nil {
return fmt.Errorf("adjust sender balance by %d: %w", delta, err)
}
after, err := s.gatewayCoinBalance(ctx, fixture.SenderToken)
if err != nil {
return err
}
if after != wanted {
return fmt.Errorf("sender balance mismatch after adjustment: got %d want %d", after, wanted)
}
return nil
}
func (s *demoServer) adminLogin(ctx context.Context) (string, error) {
var data struct {
AccessToken string `json:"accessToken"`
}
if err := s.adminJSON(ctx, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": s.config.AdminUsername,
"password": s.config.AdminPassword,
}, &data); err != nil {
return "", err
}
if data.AccessToken == "" {
return "", errors.New("admin login response is missing accessToken")
}
return data.AccessToken, nil
}
func (s *demoServer) gatewayCoinBalance(ctx context.Context, token string) (int64, error) {
var data struct {
Balances []struct {
AssetType string `json:"asset_type"`
AvailableAmount int64 `json:"available_amount"`
} `json:"balances"`
}
if err := s.gatewayJSON(ctx, http.MethodGet, "/api/v1/wallet/me/balances?asset_type=COIN", token, nil, &data); err != nil {
return 0, err
}
for _, balance := range data.Balances {
if strings.EqualFold(balance.AssetType, "COIN") {
return balance.AvailableAmount, nil
}
}
return 0, nil
}
func (s *demoServer) assertGiftPanel(ctx context.Context, fixture *demoFixture) error {
var data struct {
Gifts []struct {
GiftID string `json:"gift_id"`
} `json:"gifts"`
}
if err := s.gatewayJSON(ctx, http.MethodGet, "/api/v1/rooms/"+url.PathEscape(fixture.RoomID)+"/gift-panel", fixture.SenderToken, nil, &data); err != nil {
return fmt.Errorf("load gift panel: %w", err)
}
for _, gift := range data.Gifts {
if gift.GiftID == fixture.GiftID {
return nil
}
}
return fmt.Errorf("real gift panel does not contain %s", fixture.GiftID)
}
func (s *demoServer) handleState(writer http.ResponseWriter, request *http.Request) {
s.setupMu.Lock()
fixture := s.fixture
s.setupMu.Unlock()
if fixture == nil {
s.writeError(writer, http.StatusConflict, "fixture is not ready")
return
}
ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second)
defer cancel()
senderBalance, err := s.gatewayCoinBalance(ctx, fixture.SenderToken)
if err != nil {
s.writeError(writer, http.StatusBadGateway, err.Error())
return
}
userID, err := parsePositiveInt64(fixture.Sender.UserID, "sender user_id")
if err != nil {
s.writeError(writer, http.StatusInternalServerError, err.Error())
return
}
draws, err := s.luckyAdmin.ListLuckyGiftDraws(ctx, &luckygiftv1.ListLuckyGiftDrawsRequest{
Meta: s.luckyMeta("draw-list"), UserId: userID, RoomId: fixture.RoomID, PoolId: fixture.PoolID, Page: 1, PageSize: 20,
})
if err != nil {
s.writeError(writer, http.StatusBadGateway, "list lucky draws: "+err.Error())
return
}
recent := make([]map[string]any, 0, len(draws.GetDraws()))
for _, draw := range draws.GetDraws() {
recent = append(recent, map[string]any{
"draw_id": draw.GetDrawId(), "command_id": draw.GetCommandId(), "tier_id": draw.GetSelectedTierId(),
"multiplier_ppm": draw.GetMultiplierPpm(), "reward_coins": draw.GetEffectiveRewardCoins(),
"reward_status": draw.GetRewardStatus(), "created_at_ms": draw.GetCreatedAtMs(),
})
}
s.writeOK(writer, map[string]any{
"sender_balance": senderBalance,
"draw_total": draws.GetTotal(),
"recent_draws": recent,
"server_time_ms": time.Now().UTC().UnixMilli(),
})
}
func (s *demoServer) gatewayJSON(ctx context.Context, method, path, token string, body any, out any) error {
headers := map[string]string{"X-App-Code": s.config.AppCode, "X-App-Platform": "web", "X-App-Language": "zh-CN"}
if token != "" {
headers["Authorization"] = "Bearer " + token
}
raw, status, err := s.doJSON(ctx, strings.TrimRight(s.config.GatewayBaseURL, "/")+path, method, headers, body)
if err != nil {
return err
}
var envelope gatewayEnvelope
if decodeErr := json.Unmarshal(raw, &envelope); decodeErr != nil {
return fmt.Errorf("gateway returned invalid JSON (status %d): %w", status, decodeErr)
}
if status < 200 || status >= 300 || envelope.Code != "OK" {
return fmt.Errorf("gateway request failed status=%d code=%s message=%s request_id=%s", status, envelope.Code, envelope.Message, envelope.RequestID)
}
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("decode gateway data: %w", err)
}
}
return nil
}
func (s *demoServer) adminJSON(ctx context.Context, method, path, token string, body any, out any) error {
headers := map[string]string{"X-App-Code": s.config.AppCode}
if token != "" {
headers["Authorization"] = "Bearer " + token
}
raw, status, err := s.doJSON(ctx, strings.TrimRight(s.config.AdminBaseURL, "/")+path, method, headers, body)
if err != nil {
return err
}
var envelope adminEnvelope
if decodeErr := json.Unmarshal(raw, &envelope); decodeErr != nil {
return fmt.Errorf("admin returned invalid JSON (status %d): %w", status, decodeErr)
}
if status < 200 || status >= 300 || !adminCodeOK(envelope.Code) {
return fmt.Errorf("admin request failed status=%d code=%v message=%s", status, envelope.Code, envelope.Message)
}
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("decode admin data: %w", err)
}
}
return nil
}
func (s *demoServer) doJSON(ctx context.Context, endpoint, method string, headers map[string]string, body any) ([]byte, int, error) {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, 0, err
}
reader = bytes.NewReader(raw)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, 0, err
}
request.Header.Set("Accept", "application/json")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
for key, value := range headers {
request.Header.Set(key, value)
}
response, err := s.http.Do(request)
if err != nil {
return nil, 0, err
}
defer response.Body.Close()
raw, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
return raw, response.StatusCode, err
}
func (s *demoServer) luckyMeta(prefix string) *luckygiftv1.RequestMeta {
return &luckygiftv1.RequestMeta{
RequestId: commandID(prefix), Caller: "lucky-gift-room-demo", SentAtMs: time.Now().UTC().UnixMilli(), AppCode: s.config.AppCode,
}
}
func (s *demoServer) writeOK(writer http.ResponseWriter, data any) {
writeJSON(writer, http.StatusOK, map[string]any{"code": "OK", "message": "ok", "data": data})
}
func (s *demoServer) writeError(writer http.ResponseWriter, status int, message string) {
writeJSON(writer, status, map[string]any{"code": "LOCAL_DEMO_ERROR", "message": message, "data": nil})
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.Header().Set("Cache-Control", "no-store")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
func adminCodeOK(code any) bool {
switch value := code.(type) {
case float64:
return value == 0 || value == 200
case string:
return value == "0" || value == "200" || strings.EqualFold(value, "OK")
case nil:
return true
default:
return false
}
}
func parsePositiveInt64(value, label string) (int64, error) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || parsed <= 0 {
return 0, fmt.Errorf("%s must be a positive int64", label)
}
return parsed, nil
}
func commandID(prefix string) string {
randomBytes := make([]byte, 6)
if _, err := rand.Read(randomBytes); err != nil {
return fmt.Sprintf("demo-%s-%d", prefix, time.Now().UTC().UnixNano())
}
return fmt.Sprintf("demo-%s-%d-%s", prefix, time.Now().UTC().UnixMilli(), hex.EncodeToString(randomBytes))
}