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, ¤t); 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)) }