2026-06-28 15:11:56 +08:00

1060 lines
35 KiB
Go

package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
_ "github.com/go-sql-driver/mysql"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
defaultAppCode = "lalu"
countryCode = "US"
regionID = int64(3)
gameID = "dice"
)
type config struct {
appCode string
runID string
userDSN string
walletDSN string
userAddr string
walletAddr string
roomAddr string
gameAddr string
countryID int64
roomID string
admin account
sender account
host account
seller account
gamePlayer account
gameRival account
normalGift string
luckyGift string
superGift string
luckyPool string
superPool string
normalPrice int64
luckyPrice int64
superPrice int64
}
type account struct {
userID int64
displayUserID string
username string
}
type runner struct {
cfg config
userDB *sql.DB
walletDB *sql.DB
authClient userv1.AuthServiceClient
hostClient userv1.UserHostAdminServiceClient
walletClient walletv1.WalletServiceClient
cronClient walletv1.WalletCronServiceClient
roomClient roomv1.RoomCommandServiceClient
gameAdmin gamev1.GameAdminServiceClient
gameApp gamev1.GameAppServiceClient
}
func main() {
var cfg config
flag.StringVar(&cfg.appCode, "app", envDefault("APP_CODE", defaultAppCode), "app code")
flag.StringVar(&cfg.runID, "run-id", envDefault("RUN_ID", ""), "stable run id")
flag.StringVar(&cfg.userDSN, "user-dsn", envDefault("USER_MYSQL_DSN", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true"), "user mysql dsn")
flag.StringVar(&cfg.walletDSN, "wallet-dsn", envDefault("WALLET_MYSQL_DSN", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true"), "wallet mysql dsn")
flag.StringVar(&cfg.userAddr, "user-addr", envDefault("USER_GRPC_ADDR", "127.0.0.1:13005"), "user-service grpc address")
flag.StringVar(&cfg.walletAddr, "wallet-addr", envDefault("WALLET_GRPC_ADDR", "127.0.0.1:13004"), "wallet-service grpc address")
flag.StringVar(&cfg.roomAddr, "room-addr", envDefault("ROOM_GRPC_ADDR", "127.0.0.1:13001"), "room-service grpc address")
flag.StringVar(&cfg.gameAddr, "game-addr", envDefault("GAME_GRPC_ADDR", "127.0.0.1:13008"), "game-service grpc address")
flag.Parse()
if strings.TrimSpace(cfg.runID) == "" {
cfg.runID = fmt.Sprintf("%d", time.Now().UnixNano())
}
cfg.runID = sanitizeID(cfg.runID)
cfg.roomID = shortID("dbroom", cfg.runID, 44)
cfg.normalGift = shortID("dbg_normal", cfg.runID, 60)
cfg.luckyGift = shortID("dbg_lucky", cfg.runID, 60)
cfg.superGift = shortID("dbg_super", cfg.runID, 60)
cfg.luckyPool = "lucky"
cfg.superPool = "super_lucky"
cfg.normalPrice = 200
cfg.luckyPrice = 120
cfg.superPrice = 160
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
defer cancel()
userDB, err := openDB(ctx, cfg.userDSN)
if err != nil {
log.Fatalf("open user db: %v", err)
}
defer userDB.Close()
walletDB, err := openDB(ctx, cfg.walletDSN)
if err != nil {
log.Fatalf("open wallet db: %v", err)
}
defer walletDB.Close()
userConn := mustDial(ctx, cfg.userAddr)
defer userConn.Close()
walletConn := mustDial(ctx, cfg.walletAddr)
defer walletConn.Close()
roomConn := mustDial(ctx, cfg.roomAddr)
defer roomConn.Close()
gameConn := mustDial(ctx, cfg.gameAddr)
defer gameConn.Close()
r := &runner{
cfg: cfg,
userDB: userDB,
walletDB: walletDB,
authClient: userv1.NewAuthServiceClient(userConn),
hostClient: userv1.NewUserHostAdminServiceClient(userConn),
walletClient: walletv1.NewWalletServiceClient(walletConn),
cronClient: walletv1.NewWalletCronServiceClient(walletConn),
roomClient: roomv1.NewRoomCommandServiceClient(roomConn),
gameAdmin: gamev1.NewGameAdminServiceClient(gameConn),
gameApp: gamev1.NewGameAppServiceClient(gameConn),
}
if err := r.run(ctx); err != nil {
log.Fatalf("databi real flow failed: %v", err)
}
}
func (r *runner) run(ctx context.Context) error {
if err := r.ensureUserMasterData(ctx); err != nil {
return err
}
if err := r.ensureSalaryConfig(ctx); err != nil {
return err
}
if err := r.ensureGameConfig(ctx); err != nil {
return err
}
if err := r.createAccounts(ctx); err != nil {
return err
}
if err := r.createBusinessRoles(ctx); err != nil {
return err
}
if err := r.prepareWalletAndGifts(ctx); err != nil {
return err
}
if err := r.simulateRechargeEvents(ctx); err != nil {
return err
}
if err := r.runRoomFlow(ctx); err != nil {
return err
}
if err := r.runSalaryFlow(ctx); err != nil {
return err
}
if err := r.runGameFlow(ctx); err != nil {
return err
}
if err := r.waitForAsyncSources(ctx); err != nil {
return err
}
fmt.Printf("databi real business flow completed run_id=%s app=%s country_id=%d room_id=%s\n", r.cfg.runID, r.cfg.appCode, r.cfg.countryID, r.cfg.roomID)
return nil
}
func (r *runner) ensureUserMasterData(ctx context.Context) error {
now := time.Now().UnixMilli()
_, err := r.userDB.ExecContext(ctx, `
INSERT INTO countries (
app_code, country_name, country_code, iso_alpha3, iso_numeric, country_display_name,
phone_country_code, flag, enabled, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, 'United States', ?, 'USA', '840', 'United States', '+1', 'US', 1, 900, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
country_display_name = VALUES(country_display_name),
enabled = 1,
updated_at_ms = VALUES(updated_at_ms)`,
r.cfg.appCode, countryCode, now, now)
if err != nil {
return fmt.Errorf("seed country: %w", err)
}
if err := r.userDB.QueryRowContext(ctx,
`SELECT country_id FROM countries WHERE app_code = ? AND country_code = ?`,
r.cfg.appCode, countryCode,
).Scan(&r.cfg.countryID); err != nil {
return fmt.Errorf("read country id: %w", err)
}
_, err = r.userDB.ExecContext(ctx, `
INSERT INTO region_countries (
app_code, region_id, country_code, status, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 'active', 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE
region_id = VALUES(region_id),
status = 'active',
updated_at_ms = VALUES(updated_at_ms)`,
r.cfg.appCode, regionID, countryCode, now, now)
if err != nil {
return fmt.Errorf("seed region country: %w", err)
}
fmt.Printf("seeded user master data country=%s country_id=%d region_id=%d\n", countryCode, r.cfg.countryID, regionID)
return nil
}
func (r *runner) ensureSalaryConfig(ctx context.Context) error {
now := time.Now().UnixMilli()
_, err := r.walletDB.ExecContext(ctx, `
INSERT INTO host_agency_salary_policies (
app_code, policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode,
gift_coin_to_diamond_ratio, residual_diamond_to_usd_rate, effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
) VALUES (?, 990001, 'Databi Local Daily Policy', ?, 'active', 'daily', 'automatic', 1.000000, 0.000000000000, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE
region_id = VALUES(region_id),
status = 'active',
settlement_mode = 'daily',
settlement_trigger_mode = 'automatic',
gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio),
updated_at_ms = VALUES(updated_at_ms)`,
r.cfg.appCode, regionID, now, now)
if err != nil {
return fmt.Errorf("seed salary policy: %w", err)
}
_, err = r.walletDB.ExecContext(ctx, `
INSERT INTO host_agency_salary_policy_levels (
app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, 990001, 1, 100, 150, 0, 50, 'active', 1, ?, ?)
ON DUPLICATE KEY UPDATE
required_diamonds = VALUES(required_diamonds),
host_salary_usd_minor = VALUES(host_salary_usd_minor),
agency_salary_usd_minor = VALUES(agency_salary_usd_minor),
status = 'active',
updated_at_ms = VALUES(updated_at_ms)`,
r.cfg.appCode, now, now)
if err != nil {
return fmt.Errorf("seed salary policy level: %w", err)
}
_, err = r.walletDB.ExecContext(ctx,
`DELETE FROM coin_seller_salary_exchange_rate_tiers WHERE app_code = ? AND region_id = ? AND sort_order = 990001`,
r.cfg.appCode, regionID)
if err != nil {
return fmt.Errorf("clear salary exchange tier: %w", err)
}
_, err = r.walletDB.ExecContext(ctx, `
INSERT INTO coin_seller_salary_exchange_rate_tiers (
app_code, region_id, min_usd_minor, max_usd_minor, coin_per_usd, status,
sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, 1, 99999999, 1000, 'active', 990001, 0, 0, ?, ?)`,
r.cfg.appCode, regionID, now, now)
if err != nil {
return fmt.Errorf("seed salary exchange tier: %w", err)
}
fmt.Println("seeded wallet salary policy and salary-to-seller exchange tier")
return nil
}
func (r *runner) ensureGameConfig(ctx context.Context) error {
meta := r.gameMeta("game-admin")
if _, err := r.gameAdmin.UpsertPlatform(ctx, &gamev1.UpsertPlatformRequest{
Meta: meta,
Platform: &gamev1.GamePlatform{
AppCode: r.cfg.appCode,
PlatformCode: "dice",
PlatformName: "Databi Dice",
Status: "active",
ApiBaseUrl: "https://local.hyapp.invalid/dice",
SortOrder: 1,
AdapterType: "yomi_v4",
CallbackSecret: "local-databi-secret",
CallbackIpWhitelist: []string{},
AdapterConfigJson: "{}",
},
}); err != nil {
return fmt.Errorf("upsert game platform: %w", err)
}
if _, err := r.gameAdmin.UpsertCatalog(ctx, &gamev1.UpsertCatalogRequest{
Meta: meta,
Game: &gamev1.GameCatalogItem{
AppCode: r.cfg.appCode,
GameId: gameID,
PlatformCode: "dice",
ProviderGameId: "dice",
GameName: "Databi Dice",
Category: "self",
IconUrl: "https://local.hyapp.invalid/dice.png",
CoverUrl: "https://local.hyapp.invalid/dice-cover.png",
LaunchMode: "h5_popup",
Orientation: "portrait",
MinCoin: 100,
Status: "active",
SortOrder: 1,
Tags: []string{"self"},
},
}); err != nil {
return fmt.Errorf("upsert game catalog: %w", err)
}
if _, err := r.gameAdmin.UpdateDiceConfig(ctx, &gamev1.UpdateDiceConfigRequest{
Meta: meta,
Config: &gamev1.DiceConfig{
AppCode: r.cfg.appCode,
GameId: gameID,
Status: "active",
StakeOptions: []*gamev1.DiceStakeOption{{
StakeCoin: 100,
Enabled: true,
SortOrder: 1,
}},
FeeBps: 500,
PoolBps: 100,
MinPlayers: 2,
MaxPlayers: 2,
RobotEnabled: false,
RobotMatchWaitMs: 3000,
},
}); err != nil {
return fmt.Errorf("upsert dice config: %w", err)
}
fmt.Println("seeded game platform/catalog/dice config through game-service RPC")
return nil
}
func (r *runner) createAccounts(ctx context.Context) error {
admin, err := r.quickCreate(ctx, "admin")
if err != nil {
return err
}
r.cfg.admin = admin
fmt.Printf("created admin user_id=%d display=%s\n", admin.userID, admin.displayUserID)
for _, spec := range []struct {
key string
target *account
}{
{key: "sender", target: &r.cfg.sender},
{key: "host", target: &r.cfg.host},
{key: "seller", target: &r.cfg.seller},
{key: "game_player", target: &r.cfg.gamePlayer},
{key: "game_rival", target: &r.cfg.gameRival},
} {
acc, err := r.registerAppAccount(ctx, spec.key)
if err != nil {
return err
}
*spec.target = acc
fmt.Printf("registered app user %-11s user_id=%d display=%s\n", spec.key, acc.userID, acc.displayUserID)
}
return nil
}
func (r *runner) quickCreate(ctx context.Context, role string) (account, error) {
username := shortID("databi_"+role, r.cfg.runID, 28)
deviceID := shortID("device_"+role, r.cfg.runID, 60)
resp, err := r.authClient.QuickCreateAccount(ctx, &userv1.QuickCreateAccountRequest{
Meta: &userv1.RequestMeta{
RequestId: r.commandID("quick_create_" + role),
Caller: "databi-real-flow",
SentAtMs: time.Now().UnixMilli(),
DeviceId: deviceID,
ClientIp: "127.0.0.1",
UserAgent: "databi-real-flow",
CountryByIp: countryCode,
AppCode: r.cfg.appCode,
Platform: "ios",
Language: "en",
Timezone: "Asia/Shanghai",
},
Password: "Databi@123456",
Username: username,
Avatar: "https://local.hyapp.invalid/avatar.png",
Gender: "female",
Country: countryCode,
DeviceId: deviceID,
Device: "local",
OsVersion: "17.0",
Birth: "1995-01-01",
AppVersion: "1.0.0",
BuildNumber: "1",
Source: "databi_real_flow",
InstallChannel: "local",
Platform: "ios",
Language: "en",
Timezone: "Asia/Shanghai",
})
if err != nil {
return account{}, fmt.Errorf("quick create %s: %w", role, err)
}
token := resp.GetToken()
if token.GetUserId() <= 0 {
return account{}, fmt.Errorf("quick create %s returned empty user id", role)
}
return account{userID: token.GetUserId(), displayUserID: token.GetDisplayUserId(), username: username}, nil
}
func (r *runner) registerAppAccount(ctx context.Context, role string) (account, error) {
username := shortID("databi_"+role, r.cfg.runID, 28)
deviceID := shortID("device_"+role, r.cfg.runID, 60)
resp, err := r.authClient.RegisterThirdParty(ctx, &userv1.RegisterThirdPartyRequest{
Meta: &userv1.RequestMeta{
RequestId: r.commandID("register_third_party_" + role),
Caller: "databi-real-flow",
SentAtMs: time.Now().UnixMilli(),
DeviceId: deviceID,
ClientIp: "127.0.0.1",
UserAgent: "databi-real-flow",
CountryByIp: countryCode,
AppCode: r.cfg.appCode,
Platform: "ios",
Language: "en",
Timezone: "Asia/Shanghai",
},
Provider: "wechat",
Credential: "openid_" + role + "_" + r.cfg.runID,
DeviceId: deviceID,
Username: username,
Avatar: "https://local.hyapp.invalid/avatar.png",
Gender: "female",
Country: countryCode,
Device: "local",
OsVersion: "17.0",
Birth: "1995-01-01",
AppVersion: "1.0.0",
BuildNumber: "1",
Source: "databi_app_registration",
InstallChannel: "local",
Platform: "ios",
Language: "en",
Timezone: "Asia/Shanghai",
})
if err != nil {
return account{}, fmt.Errorf("register app user %s: %w", role, err)
}
token := resp.GetToken()
if token.GetUserId() <= 0 {
return account{}, fmt.Errorf("register app user %s returned empty user id", role)
}
if !resp.GetIsNewUser() {
return account{}, fmt.Errorf("register app user %s reused an existing third-party credential", role)
}
return account{userID: token.GetUserId(), displayUserID: token.GetDisplayUserId(), username: username}, nil
}
func (r *runner) createBusinessRoles(ctx context.Context) error {
if _, err := r.hostClient.CreateCoinSeller(ctx, &userv1.CreateCoinSellerRequest{
Meta: r.userMeta("create_coin_seller"),
CommandId: r.commandID("create_coin_seller"),
AdminUserId: r.cfg.admin.userID,
TargetUserId: r.cfg.seller.userID,
Reason: "databi real flow",
}); err != nil {
return fmt.Errorf("create coin seller: %w", err)
}
if _, err := r.hostClient.CreateAgency(ctx, &userv1.CreateAgencyRequest{
Meta: r.userMeta("create_agency"),
CommandId: r.commandID("create_agency"),
AdminUserId: r.cfg.admin.userID,
OwnerUserId: r.cfg.host.userID,
Name: shortID("Databi Agency", r.cfg.runID, 80),
JoinEnabled: true,
Reason: "databi real flow",
}); err != nil {
return fmt.Errorf("create agency/host: %w", err)
}
fmt.Printf("created roles seller_user_id=%d host_agency_owner_user_id=%d\n", r.cfg.seller.userID, r.cfg.host.userID)
return nil
}
func (r *runner) prepareWalletAndGifts(ctx context.Context) error {
credits := []struct {
userID int64
amount int64
label string
}{
{userID: r.cfg.sender.userID, amount: 1_000_000, label: "sender"},
{userID: r.cfg.gamePlayer.userID, amount: 10_000, label: "game_player"},
{userID: r.cfg.gameRival.userID, amount: 10_000, label: "game_rival"},
}
for _, credit := range credits {
if _, err := r.walletClient.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
CommandId: r.commandID("manual_credit_" + credit.label),
TargetUserId: credit.userID,
AssetType: "COIN",
Amount: credit.amount,
OperatorUserId: r.cfg.admin.userID,
Reason: "databi real flow manual grant",
EvidenceRef: "local-real-flow",
AppCode: r.cfg.appCode,
}); err != nil {
return fmt.Errorf("admin credit %s: %w", credit.label, err)
}
}
if _, err := r.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
CommandId: r.commandID("seller_stock"),
SellerUserId: r.cfg.seller.userID,
StockType: "usdt_purchase",
CoinAmount: 500_000,
PaidCurrencyCode: "USDT",
PaidAmountMicro: 10_000_000,
PaymentRef: r.commandID("seller_stock_payment"),
EvidenceRef: "local-real-flow",
OperatorUserId: r.cfg.admin.userID,
Reason: "databi real flow seller stock",
AppCode: r.cfg.appCode,
SellerCountryId: r.cfg.countryID,
SellerRegionId: regionID,
}); err != nil {
return fmt.Errorf("credit seller stock: %w", err)
}
if _, err := r.walletClient.TransferCoinFromSeller(ctx, &walletv1.TransferCoinFromSellerRequest{
CommandId: r.commandID("seller_transfer"),
SellerUserId: r.cfg.seller.userID,
TargetUserId: r.cfg.sender.userID,
Amount: 50_000,
Reason: "databi real flow seller transfer",
AppCode: r.cfg.appCode,
SellerRegionId: regionID,
TargetRegionId: regionID,
TargetCountryId: r.cfg.countryID,
}); err != nil {
return fmt.Errorf("seller transfer coin: %w", err)
}
if _, err := r.walletClient.CreditTaskReward(ctx, &walletv1.CreditTaskRewardRequest{
CommandId: r.commandID("task_reward"),
AppCode: r.cfg.appCode,
TargetUserId: r.cfg.sender.userID,
Amount: 1_000,
TaskType: "daily",
TaskId: "databi_daily",
CycleKey: time.Now().UTC().Format("2006-01-02"),
Reason: "databi real flow platform reward",
}); err != nil {
return fmt.Errorf("task reward: %w", err)
}
if _, err := r.walletClient.CreditLuckyGiftReward(ctx, &walletv1.CreditLuckyGiftRewardRequest{
CommandId: r.commandID("super_lucky_reward"),
AppCode: r.cfg.appCode,
TargetUserId: r.cfg.sender.userID,
Amount: 2_000,
DrawId: r.commandID("super_lucky_draw"),
RoomId: r.cfg.roomID,
GiftId: r.cfg.superGift,
PoolId: r.cfg.superPool,
Reason: "databi real flow super lucky payout",
VisibleRegionId: regionID,
CountryId: r.cfg.countryID,
}); err != nil {
return fmt.Errorf("lucky gift reward: %w", err)
}
if err := r.createGift(ctx, r.cfg.normalGift, "normal", r.cfg.normalPrice); err != nil {
return err
}
if err := r.createGift(ctx, r.cfg.luckyGift, "lucky", r.cfg.luckyPrice); err != nil {
return err
}
if err := r.createGift(ctx, r.cfg.superGift, "super_lucky", r.cfg.superPrice); err != nil {
return err
}
fmt.Println("prepared wallet balances, seller stock/transfer, platform reward and gift configs")
return nil
}
func (r *runner) createGift(ctx context.Context, giftID string, giftType string, price int64) error {
if err := r.ensureGiftType(ctx, giftType); err != nil {
return err
}
mgrGrant := true
resourceResp, err := r.walletClient.CreateResource(ctx, &walletv1.CreateResourceRequest{
RequestId: r.commandID("resource_" + giftID),
AppCode: r.cfg.appCode,
ResourceCode: "res_" + giftID,
ResourceType: "gift",
Name: "Databi " + giftType,
Status: "active",
Grantable: true,
GrantStrategy: "increase_quantity",
UsageScopes: []string{"gift"},
AssetUrl: "https://local.hyapp.invalid/" + giftID + ".png",
PreviewUrl: "https://local.hyapp.invalid/" + giftID + "-preview.png",
AnimationUrl: "https://local.hyapp.invalid/" + giftID + ".svga",
MetadataJson: "{}",
SortOrder: 900,
OperatorUserId: r.cfg.admin.userID,
ManagerGrantEnabled: &mgrGrant,
PriceType: "coin",
CoinPrice: price,
})
if err != nil {
return fmt.Errorf("create resource %s: %w", giftID, err)
}
if _, err := r.walletClient.CreateGiftConfig(ctx, &walletv1.CreateGiftConfigRequest{
RequestId: r.commandID("gift_config_" + giftID),
AppCode: r.cfg.appCode,
GiftId: giftID,
ResourceId: resourceResp.GetResource().GetResourceId(),
Status: "active",
Name: "Databi " + giftType,
SortOrder: 900,
PresentationJson: "{}",
PriceVersion: "v1",
CoinPrice: price,
HeatValue: price,
EffectiveAtMs: time.Now().UnixMilli(),
OperatorUserId: r.cfg.admin.userID,
RegionIds: []int64{regionID},
GiftTypeCode: giftType,
ChargeAssetType: "COIN",
EffectiveFromMs: 0,
EffectiveToMs: 0,
}); err != nil {
return fmt.Errorf("create gift config %s: %w", giftID, err)
}
return nil
}
func (r *runner) ensureGiftType(ctx context.Context, giftType string) error {
var count int64
if err := r.walletDB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM gift_type_configs WHERE app_code = ? AND type_code = ?`,
r.cfg.appCode, giftType,
).Scan(&count); err != nil {
return fmt.Errorf("read gift type %s: %w", giftType, err)
}
if count > 0 {
return nil
}
if _, err := r.walletClient.UpsertGiftTypeConfig(ctx, &walletv1.UpsertGiftTypeConfigRequest{
RequestId: r.commandID("gift_type_" + giftType),
AppCode: r.cfg.appCode,
TypeCode: giftType,
Name: giftType,
TabKey: shortID("databi_"+giftType, r.cfg.runID, 32),
Status: "active",
SortOrder: 900,
OperatorUserId: r.cfg.admin.userID,
}); err != nil {
return fmt.Errorf("upsert gift type %s: %w", giftType, err)
}
return nil
}
func (r *runner) simulateRechargeEvents(ctx context.Context) error {
if err := r.insertSimulatedRecharge(ctx, "google", 199, 1_990_000); err != nil {
return err
}
if err := r.insertSimulatedRecharge(ctx, "mifapay", 299, 2_990_000); err != nil {
return err
}
fmt.Println("inserted simulated google/mifapay recharge outbox events")
return nil
}
func (r *runner) insertSimulatedRecharge(ctx context.Context, channel string, usdMinor int64, amountMicro int64) error {
now := time.Now().UnixMilli()
eventID := r.commandID("sim_recharge_" + channel)
payload := map[string]any{
"app_code": r.cfg.appCode,
"provider": channel,
"channel": channel,
"recharge_type": channel,
"target_user_id": r.cfg.sender.userID,
"target_country_id": r.cfg.countryID,
"country_id": r.cfg.countryID,
"target_region_id": regionID,
"region_id": regionID,
"amount_micro": amountMicro,
"recharge_usd_minor": usdMinor,
"recharge_currency_code": "USD",
"recharge_sequence": 1,
"created_at_ms": now,
}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = r.walletDB.ExecContext(ctx, `
INSERT INTO wallet_outbox (
app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, payload, status, worker_id, lock_until_ms, retry_count,
next_retry_at_ms, last_error, created_at_ms, updated_at_ms
) VALUES (?, ?, 'WalletRechargeRecorded', ?, ?, ?, 'COIN', 0, 0, CAST(? AS JSON), 'pending', '', NULL, 0, NULL, NULL, ?, ?)
ON DUPLICATE KEY UPDATE payload = VALUES(payload), updated_at_ms = VALUES(updated_at_ms)`,
r.cfg.appCode, eventID, eventID, eventID, r.cfg.sender.userID, string(raw), now, now)
if err != nil {
return fmt.Errorf("insert simulated %s recharge: %w", channel, err)
}
return nil
}
func (r *runner) runRoomFlow(ctx context.Context) error {
if _, err := r.roomClient.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: r.roomMeta("create_room", r.cfg.sender.userID),
Mode: "voice",
VisibleRegionId: regionID,
RoomName: "Databi Real Flow",
RoomAvatar: "https://local.hyapp.invalid/room.png",
RoomDescription: "databi real flow",
RoomShortId: firstNonEmpty(r.cfg.sender.displayUserID, fmt.Sprintf("%d", r.cfg.sender.userID)),
OwnerCountryCode: countryCode,
}); err != nil {
return fmt.Errorf("create room: %w", err)
}
for _, acc := range []account{r.cfg.host, r.cfg.sender} {
if _, err := r.roomClient.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: r.roomMeta("join_"+acc.username, acc.userID),
Role: "member",
ActorCountryId: r.cfg.countryID,
ActorRegionId: regionID,
ActorDisplayProfile: &roomv1.RoomUserDisplayProfile{
UserId: acc.userID,
Nickname: acc.username,
Avatar: "https://local.hyapp.invalid/avatar.png",
},
}); err != nil {
return fmt.Errorf("join room user %d: %w", acc.userID, err)
}
}
micResp, err := r.roomClient.MicUp(ctx, &roomv1.MicUpRequest{
Meta: r.roomMeta("mic_up", r.cfg.host.userID),
SeatNo: 1,
TargetDisplayProfile: &roomv1.RoomUserDisplayProfile{
UserId: r.cfg.host.userID,
Nickname: r.cfg.host.username,
Avatar: "https://local.hyapp.invalid/avatar.png",
},
})
if err != nil {
return fmt.Errorf("mic up: %w", err)
}
if _, err := r.roomClient.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: r.roomMeta("mic_confirm", r.cfg.host.userID),
TargetUserId: r.cfg.host.userID,
MicSessionId: micResp.GetMicSessionId(),
RoomVersion: micResp.GetResult().GetRoomVersion(),
EventTimeMs: time.Now().UnixMilli(),
Source: "databi-real-flow",
}); err != nil {
return fmt.Errorf("confirm mic publishing: %w", err)
}
time.Sleep(2200 * time.Millisecond)
if _, err := r.roomClient.MicHeartbeat(ctx, &roomv1.MicHeartbeatRequest{
Meta: r.roomMeta("mic_heartbeat", r.cfg.host.userID),
TargetUserId: r.cfg.host.userID,
MicSessionId: micResp.GetMicSessionId(),
}); err != nil {
return fmt.Errorf("mic heartbeat: %w", err)
}
if _, err := r.roomClient.MicDown(ctx, &roomv1.MicDownRequest{
Meta: r.roomMeta("mic_down", r.cfg.host.userID),
TargetUserId: r.cfg.host.userID,
Reason: "databi-real-flow",
}); err != nil {
return fmt.Errorf("mic down: %w", err)
}
for _, gift := range []struct {
id string
count int32
poolID string
}{
{id: r.cfg.normalGift, count: 1},
{id: r.cfg.luckyGift, count: 1, poolID: r.cfg.luckyPool},
{id: r.cfg.superGift, count: 1, poolID: r.cfg.superPool},
} {
if _, err := r.roomClient.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: r.roomMeta("send_gift_"+gift.id, r.cfg.sender.userID),
TargetUserId: r.cfg.host.userID,
GiftId: gift.id,
GiftCount: gift.count,
TargetType: "user",
PoolId: gift.poolID,
TargetIsHost: true,
TargetHostRegionId: regionID,
TargetAgencyOwnerUserId: r.cfg.host.userID,
SenderRegionId: regionID,
SenderCountryId: r.cfg.countryID,
SenderDisplayProfile: &roomv1.SendGiftDisplayProfile{
UserId: r.cfg.sender.userID,
Username: r.cfg.sender.username,
Avatar: "https://local.hyapp.invalid/avatar.png",
},
TargetDisplayProfiles: []*roomv1.SendGiftDisplayProfile{{
UserId: r.cfg.host.userID,
Username: r.cfg.host.username,
Avatar: "https://local.hyapp.invalid/avatar.png",
}},
}); err != nil {
return fmt.Errorf("send gift %s: %w", gift.id, err)
}
}
fmt.Println("completed room join, mic, normal/lucky/super-lucky gift flows")
return nil
}
func (r *runner) runSalaryFlow(ctx context.Context) error {
cycleKey := time.Now().UTC().Format("2006-01")
resp, err := r.cronClient.ProcessHostSalaryDailySettlementBatch(ctx, &walletv1.CronBatchRequest{
RequestId: r.commandID("salary_cron_request"),
AppCode: r.cfg.appCode,
RunId: r.commandID("salary_cron"),
WorkerId: "databi-real-flow",
BatchSize: 10,
LockTtlMs: 30_000,
TriggerMode: "automatic",
CycleKey: cycleKey,
SettlementRole: "",
})
if err != nil {
return fmt.Errorf("salary daily settlement cron: %w", err)
}
fmt.Printf("salary settlement cron success=%d failure=%d\n", resp.GetSuccessCount(), resp.GetFailureCount())
if _, err := r.walletClient.TransferSalaryToCoinSeller(ctx, &walletv1.TransferSalaryToCoinSellerRequest{
CommandId: r.commandID("salary_to_seller"),
SourceUserId: r.cfg.host.userID,
SellerUserId: r.cfg.seller.userID,
SalaryAssetType: "HOST_SALARY_USD",
SalaryUsdMinor: 100,
RegionId: regionID,
Reason: "databi real flow salary to seller",
AppCode: r.cfg.appCode,
}); err != nil {
return fmt.Errorf("transfer salary to seller: %w", err)
}
fmt.Println("completed host/agency salary settlement and salary-to-seller transfer")
return nil
}
func (r *runner) runGameFlow(ctx context.Context) error {
createResp, err := r.gameApp.CreateDiceMatch(ctx, &gamev1.CreateDiceMatchRequest{
Meta: r.gameMeta("dice_create"),
UserId: r.cfg.gamePlayer.userID,
GameId: gameID,
RoomId: r.cfg.roomID,
RegionId: regionID,
StakeCoin: 100,
MinPlayers: 2,
MaxPlayers: 2,
CountryId: r.cfg.countryID,
})
if err != nil {
return fmt.Errorf("create dice match: %w", err)
}
matchID := createResp.GetMatch().GetMatchId()
if _, err := r.gameApp.JoinDiceMatch(ctx, &gamev1.JoinDiceMatchRequest{
Meta: r.gameMeta("dice_join"),
UserId: r.cfg.gameRival.userID,
MatchId: matchID,
GameId: gameID,
}); err != nil {
return fmt.Errorf("join dice match: %w", err)
}
for attempt := 1; attempt <= 5; attempt++ {
rollResp, err := r.gameApp.RollDiceMatch(ctx, &gamev1.RollDiceMatchRequest{
Meta: r.gameMeta(fmt.Sprintf("dice_roll_%d", attempt)),
UserId: r.cfg.gamePlayer.userID,
MatchId: matchID,
GameId: gameID,
})
if err != nil {
return fmt.Errorf("roll dice match attempt %d: %w", attempt, err)
}
status := rollResp.GetMatch().GetStatus()
fmt.Printf("dice roll attempt=%d status=%s result=%s\n", attempt, status, rollResp.GetMatch().GetResult())
if status == "settled" {
fmt.Println("completed game dice settlement")
return nil
}
time.Sleep(2300 * time.Millisecond)
}
return fmt.Errorf("dice match %s did not settle after retries", matchID)
}
func (r *runner) waitForAsyncSources(ctx context.Context) error {
checks := []struct {
name string
db *sql.DB
query string
args []any
}{
{
name: "mic daily stats",
db: r.userDB,
query: `SELECT COUNT(*) FROM user_outbox WHERE app_code = ? AND event_type = 'UserMicDailyStatsUpdated' AND created_at_ms >= ?`,
args: []any{r.cfg.appCode, runStartMS(r.cfg.runID)},
},
{
name: "game settled outbox",
db: nil,
query: "",
},
}
for _, check := range checks {
if check.db == nil {
continue
}
if err := waitForCount(ctx, check.db, check.query, check.args...); err != nil {
return fmt.Errorf("wait %s: %w", check.name, err)
}
}
time.Sleep(1500 * time.Millisecond)
return nil
}
func (r *runner) userMeta(request string) *userv1.RequestMeta {
return &userv1.RequestMeta{
RequestId: r.commandID(request),
Caller: "databi-real-flow",
SentAtMs: time.Now().UnixMilli(),
AppCode: r.cfg.appCode,
Platform: "ios",
Language: "en",
Timezone: "Asia/Shanghai",
}
}
func (r *runner) roomMeta(action string, actorUserID int64) *roomv1.RequestMeta {
return &roomv1.RequestMeta{
RequestId: r.commandID(action + "_request"),
CommandId: r.commandID(action),
ActorUserId: actorUserID,
RoomId: r.cfg.roomID,
GatewayNodeId: "databi-real-flow",
SentAtMs: time.Now().UnixMilli(),
AppCode: r.cfg.appCode,
}
}
func (r *runner) gameMeta(request string) *gamev1.RequestMeta {
return &gamev1.RequestMeta{
RequestId: r.commandID(request),
Caller: "databi-real-flow",
GatewayNodeId: "databi-real-flow",
ActorUserId: r.cfg.admin.userID,
SentAtMs: time.Now().UnixMilli(),
AppCode: r.cfg.appCode,
}
}
func (r *runner) commandID(action string) string {
return shortID("databi_"+action, r.cfg.runID, 120)
}
func openDB(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", strings.TrimSpace(dsn))
if err != nil {
return nil, err
}
db.SetMaxOpenConns(4)
db.SetMaxIdleConns(2)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
func mustDial(ctx context.Context, addr string) *grpc.ClientConn {
conn, err := grpc.DialContext(ctx, addr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock())
if err != nil {
log.Fatalf("dial %s: %v", addr, err)
}
return conn
}
func waitForCount(ctx context.Context, db *sql.DB, query string, args ...any) error {
deadline := time.Now().Add(20 * time.Second)
for {
var count int64
if err := db.QueryRowContext(ctx, query, args...).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out waiting for count > 0")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
}
func runStartMS(runID string) int64 {
if value, err := time.Parse("20060102150405", runID); err == nil {
return value.UnixMilli()
}
return time.Now().Add(-10 * time.Minute).UnixMilli()
}
func shortID(prefix string, suffix string, limit int) string {
id := sanitizeID(prefix) + "_" + sanitizeID(suffix)
if len(id) <= limit {
return id
}
if limit <= 8 {
return id[:limit]
}
tailLen := limit - len(sanitizeID(prefix)) - 1
if tailLen <= 0 {
return id[:limit]
}
if tailLen > len(sanitizeID(suffix)) {
tailLen = len(sanitizeID(suffix))
}
return sanitizeID(prefix) + "_" + sanitizeID(suffix)[:tailLen]
}
func sanitizeID(value string) string {
value = strings.TrimSpace(value)
if value == "" {
value = fmt.Sprintf("%d", time.Now().UnixNano())
}
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '_' || r == '-':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func envDefault(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}