812 lines
29 KiB
Go
812 lines
29 KiB
Go
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, JackpotUserRoundRtpMaxPpm: 960_000, JackpotUser_48HRtpMaxPpm: 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))
|
||
}
|