// Package mysqltest provides real wallet-service MySQL repositories for tests. package mysqltest import ( "context" "database/sql" "encoding/json" "fmt" "strings" "testing" "time" "hyapp/internal/testutil/mysqlschema" "hyapp/services/wallet-service/internal/domain/ledger" mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql" ) // SetPointWithdrawalCoinSellerConfig seeds one app-scoped whitelist row for real ledger tests. func (r *Repository) SetPointWithdrawalCoinSellerConfig(appCode string, sellerUserID int64, pointAmount int64, sellerCoinAmount int64, countryCodes []string, status string, sortOrder int) { r.t.Helper() countriesJSON, err := json.Marshal(countryCodes) if err != nil { r.t.Fatalf("marshal point withdrawal countries failed: %v", err) } if status == "" { status = "active" } nowMS := time.Now().UnixMilli() if _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO point_withdrawal_coin_seller_configs ( app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, service_country_codes, status, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE sort_order = VALUES(sort_order), point_amount = VALUES(point_amount), seller_coin_amount = VALUES(seller_coin_amount), service_country_codes = VALUES(service_country_codes), status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`, appCode, sellerUserID, sortOrder, pointAmount, sellerCoinAmount, countriesJSON, status, nowMS, nowMS, ); err != nil { r.t.Fatalf("seed point withdrawal coin seller config failed: %v", err) } } // Repository wraps the production MySQL wallet repository with test seed helpers. type Repository struct { *mysqlstorage.Repository t testing.TB schema *mysqlschema.Schema } type VipRewardResourceSeed struct { ResourceID int64 ResourceCode string ResourceType string Name string GrantStrategy string SortOrder int } // CountRows returns a simple count from a known wallet table for persistence assertions. func (r *Repository) CountRows(table string, where string, args ...any) int { r.t.Helper() query := "SELECT COUNT(*) FROM " + validateTableName(table) if where != "" { query += " WHERE " + where } var count int if err := r.schema.DB.QueryRowContext(context.Background(), query, args...).Scan(&count); err != nil { r.t.Fatalf("count %s rows failed: %v\nquery=%s", table, err, query) } return count } // SetRechargeProductCode overwrites product_code to model legacy rows created before code matched product_name. func (r *Repository) SetRechargeProductCode(productID int64, productCode string) { r.t.Helper() if _, err := r.schema.DB.ExecContext(context.Background(), ` UPDATE wallet_recharge_products SET product_code = ? WHERE product_id = ?`, productCode, productID, ); err != nil { r.t.Fatalf("set recharge product code failed: %v", err) } } func (r *Repository) ActiveResourceEntitlement(userID int64, resourceID int64) (expiresAtMS int64, quantity int64, remainingQuantity int64) { r.t.Helper() err := r.schema.DB.QueryRowContext(context.Background(), ` SELECT expires_at_ms, quantity, remaining_quantity FROM user_resource_entitlements WHERE user_id = ? AND resource_id = ? AND status = 'active' ORDER BY expires_at_ms DESC, created_at_ms DESC LIMIT 1 `, userID, resourceID).Scan(&expiresAtMS, &quantity, &remainingQuantity) if err != nil { r.t.Fatalf("query active resource entitlement failed: %v", err) } return expiresAtMS, quantity, remainingQuantity } // ExpireEntitlement moves a granted entitlement into the past for expiry-path assertions. func (r *Repository) ExpireEntitlement(entitlementID string, expiresAtMS int64) { r.t.Helper() if _, err := r.schema.DB.ExecContext(context.Background(), ` UPDATE user_resource_entitlements SET expires_at_ms = ?, updated_at_ms = ? WHERE entitlement_id = ?`, expiresAtMS, time.Now().UnixMilli(), entitlementID, ); err != nil { r.t.Fatalf("expire entitlement failed: %v", err) } } // SetResourceGrantStatus lets service tests model legacy or abnormal grant rows without bypassing production grant creation. func (r *Repository) SetResourceGrantStatus(grantID string, status string) { r.t.Helper() if _, err := r.schema.DB.ExecContext(context.Background(), ` UPDATE resource_grants SET status = ?, updated_at_ms = ? WHERE grant_id = ?`, status, time.Now().UnixMilli(), grantID, ); err != nil { r.t.Fatalf("set resource grant status failed: %v", err) } } // HostSalarySettlementIDs returns the persisted settlement IDs for one host/cycle in creation order. func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string { r.t.Helper() rows, err := r.schema.DB.QueryContext(context.Background(), ` SELECT settlement_id FROM host_salary_settlement_records WHERE user_id = ? AND cycle_key = ? ORDER BY created_at_ms ASC, settlement_id ASC `, userID, cycleKey) if err != nil { r.t.Fatalf("query host salary settlement ids failed: %v", err) } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { r.t.Fatalf("scan host salary settlement id failed: %v", err) } ids = append(ids, id) } if err := rows.Err(); err != nil { r.t.Fatalf("iterate host salary settlement ids failed: %v", err) } return ids } // MoveHostPeriodCycle merges a real gift-created current-cycle account into another cycle for month-boundary tests. func (r *Repository) MoveHostPeriodCycle(userID int64, fromCycle string, toCycle string) { r.t.Helper() if fromCycle == "" || toCycle == "" || fromCycle == toCycle { return } tx, err := r.schema.DB.BeginTx(context.Background(), nil) if err != nil { r.t.Fatalf("begin move host period cycle failed: %v", err) } defer func() { _ = tx.Rollback() }() var sourceTotal int64 var sourceGiftTotal int64 var sourceLastGiftAt int64 var sourceUpdatedAt int64 err = tx.QueryRowContext(context.Background(), ` SELECT total_diamonds, gift_diamond_total, last_gift_at_ms, updated_at_ms FROM host_period_diamond_accounts WHERE user_id = ? AND cycle_key = ? FOR UPDATE `, userID, fromCycle).Scan(&sourceTotal, &sourceGiftTotal, &sourceLastGiftAt, &sourceUpdatedAt) if err != nil { r.t.Fatalf("read source host period cycle failed: %v", err) } var targetTotal int64 err = tx.QueryRowContext(context.Background(), ` SELECT total_diamonds FROM host_period_diamond_accounts WHERE user_id = ? AND cycle_key = ? FOR UPDATE `, userID, toCycle).Scan(&targetTotal) if err != nil { if err == sql.ErrNoRows { if _, err := tx.ExecContext(context.Background(), ` UPDATE host_period_diamond_accounts SET cycle_key = ? WHERE user_id = ? AND cycle_key = ? `, toCycle, userID, fromCycle); err != nil { r.t.Fatalf("move first host period account failed: %v", err) } } else { r.t.Fatalf("read target host period cycle failed: %v", err) } } else { if _, err := tx.ExecContext(context.Background(), ` UPDATE host_period_diamond_accounts SET total_diamonds = total_diamonds + ?, gift_diamond_total = gift_diamond_total + ?, version = version + 1, last_gift_at_ms = ?, updated_at_ms = ? WHERE user_id = ? AND cycle_key = ? `, sourceTotal, sourceGiftTotal, sourceLastGiftAt, sourceUpdatedAt, userID, toCycle); err != nil { r.t.Fatalf("merge target host period account failed: %v", err) } if _, err := tx.ExecContext(context.Background(), ` DELETE FROM host_period_diamond_accounts WHERE user_id = ? AND cycle_key = ? `, userID, fromCycle); err != nil { r.t.Fatalf("delete merged source host period account failed: %v", err) } } if _, err := tx.ExecContext(context.Background(), ` UPDATE host_period_diamond_entries SET cycle_key = ? WHERE user_id = ? AND cycle_key = ? `, toCycle, userID, fromCycle); err != nil { r.t.Fatalf("move host period entries failed: %v", err) } if err := tx.Commit(); err != nil { r.t.Fatalf("commit move host period cycle failed: %v", err) } } // HostPeriodGiftDiamondTotal returns the current accumulated gift diamonds for a host. func (r *Repository) HostPeriodGiftDiamondTotal(userID int64) int64 { r.t.Helper() var total int64 err := r.schema.DB.QueryRowContext(context.Background(), ` SELECT COALESCE(SUM(gift_diamond_total), 0) FROM host_period_diamond_accounts WHERE user_id = ?`, userID).Scan(&total) if err != nil { r.t.Fatalf("query host period gift diamond total failed: %v", err) } return total } // InsertRawGiftPrice allows tests to seed inactive or future prices without service helpers. func (r *Repository) InsertRawGiftPrice(giftID string, priceVersion string, status string, effectiveAtMs int64) { r.t.Helper() nowMs := time.Now().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO wallet_gift_prices ( gift_id, price_version, status, coin_price, gift_point_amount, heat_value, effective_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 1, 1, 1, ?, ?, ?) `, giftID, priceVersion, status, effectiveAtMs, nowMs, nowMs) if err != nil { r.t.Fatalf("insert raw gift price failed: %v", err) } } // NewRepository creates an isolated wallet schema and opens the production repository. func NewRepository(t testing.TB) *Repository { t.Helper() schema := mysqlschema.New(t, mysqlschema.Config{ EnvVar: "WALLET_SERVICE_MYSQL_TEST_DSN", InitDBPath: initDBPath(t), DatabasePrefix: "hyapp_wallet_test", }) repository, err := mysqlstorage.Open(context.Background(), schema.DSN) if err != nil { t.Fatalf("open wallet mysql repository failed: %v", err) } wrapped := &Repository{Repository: repository, t: t, schema: schema} t.Cleanup(wrapped.Close) return wrapped } // Close shuts down the repository; mysqlschema owns schema cleanup. func (r *Repository) Close() { r.t.Helper() if r.Repository != nil { _ = r.Repository.Close() } } // SetBalance prepares a sender COIN account for debit tests. func (r *Repository) SetBalance(userID int64, balance int64) { r.SetAssetBalanceForApp("lalu", userID, "COIN", balance) } // SetBalanceForApp prepares a sender COIN account under the requested app scope. func (r *Repository) SetBalanceForApp(appCode string, userID int64, balance int64) { r.SetAssetBalanceForApp(appCode, userID, "COIN", balance) } func (r *Repository) SeedVIPRewardGroup(level int32, groupID int64, resources []VipRewardResourceSeed) { r.t.Helper() nowMs := time.Now().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO resource_groups ( group_id, group_code, name, status, description, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 'active', '', 0, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at_ms = VALUES(updated_at_ms) `, groupID, fmt.Sprintf("vip_test_group_%d", groupID), fmt.Sprintf("VIP test group %d", groupID), nowMs, nowMs) if err != nil { r.t.Fatalf("seed vip reward group failed: %v", err) } if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM resource_group_items WHERE group_id = ?`, groupID); err != nil { r.t.Fatalf("clear vip reward group items failed: %v", err) } for index, resource := range resources { sortOrder := resource.SortOrder if sortOrder == 0 { sortOrder = (index + 1) * 10 } name := resource.Name if name == "" { name = resource.ResourceCode } _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO resources ( resource_id, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy, wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 'active', TRUE, TRUE, ?, '', 0, '', 0, 0, JSON_ARRAY(), '', '', '', NULL, ?, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE resource_type = VALUES(resource_type), name = VALUES(name), status = VALUES(status), grant_strategy = VALUES(grant_strategy), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms) `, resource.ResourceID, resource.ResourceCode, resource.ResourceType, name, resource.GrantStrategy, sortOrder, nowMs, nowMs) if err != nil { r.t.Fatalf("seed vip reward resource failed: %v", err) } _, err = r.schema.DB.ExecContext(context.Background(), ` INSERT INTO resource_group_items ( group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount, quantity, duration_ms, sort_order, created_at_ms, updated_at_ms ) VALUES (?, 'resource', ?, '', 0, 1, 0, ?, ?, ?) `, groupID, resource.ResourceID, sortOrder, nowMs, nowMs) if err != nil { r.t.Fatalf("seed vip reward group item failed: %v", err) } } if _, err := r.schema.DB.ExecContext(context.Background(), ` UPDATE vip_levels SET reward_resource_group_id = ?, updated_at_ms = ? WHERE level = ? `, groupID, nowMs, level); err != nil { r.t.Fatalf("update vip level reward group failed: %v", err) } } // SetAssetBalance prepares a sender account for debit tests. func (r *Repository) SetAssetBalance(userID int64, assetType string, balance int64) { r.SetAssetBalanceForApp("lalu", userID, assetType, balance) } // SetAssetBalanceForApp prepares an account in the same app scope that production ledger code will query. func (r *Repository) SetAssetBalanceForApp(appCode string, userID int64, assetType string, balance int64) { r.t.Helper() nowMs := time.Now().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO wallet_accounts (app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, 0, 1, ?, ?) ON DUPLICATE KEY UPDATE available_amount = VALUES(available_amount), frozen_amount = VALUES(frozen_amount), version = version + 1, updated_at_ms = VALUES(updated_at_ms) `, strings.TrimSpace(appCode), userID, assetType, balance, nowMs, nowMs) if err != nil { r.t.Fatalf("seed wallet balance failed: %v", err) } } // SeedWalletPolicyInstance writes the wallet runtime policy row consumed by gift settlement tests. func (r *Repository) SeedWalletPolicyInstance(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64) { r.SeedWalletPolicyInstanceWithAgency(appCode, instanceCode, regionID, status, hostPointRatioPPM, 0) } // SeedWalletPolicyInstanceWithAgency 让账务测试显式声明 Agency 比例,避免普通 policy fixture 意外改变旧用例语义。 func (r *Repository) SeedWalletPolicyInstanceWithAgency(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64) { r.t.Helper() nowMs := time.Now().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO wallet_policy_instances ( app_code, instance_code, template_code, template_version, region_id, status, effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm, agency_point_ratio_ppm, points_per_usd, withdraw_fee_bps, rule_json, published_by_admin_id, published_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 'v1', ?, ?, 0, 0, 'POINT', ?, ?, 100000, 500, JSON_OBJECT(), 90001, ?, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), host_point_ratio_ppm = VALUES(host_point_ratio_ppm), agency_point_ratio_ppm = VALUES(agency_point_ratio_ppm), published_at_ms = VALUES(published_at_ms), updated_at_ms = VALUES(updated_at_ms) `, strings.TrimSpace(appCode), strings.TrimSpace(instanceCode), strings.TrimSpace(instanceCode)+"_template", regionID, strings.TrimSpace(status), hostPointRatioPPM, agencyPointRatioPPM, nowMs, nowMs, nowMs) if err != nil { r.t.Fatalf("seed wallet policy instance failed: %v", err) } } // SetCoinSellerSalaryExchangeRateTier seeds an active salary transfer tier for coin seller transfer tests. func (r *Repository) SetCoinSellerSalaryExchangeRateTier(appCode string, regionID int64, minUSDMinor int64, maxUSDMinor int64, coinPerUSD int64) { r.t.Helper() nowMs := time.Now().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` 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 (?, ?, ?, ?, ?, 'active', 10, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE coin_per_usd = VALUES(coin_per_usd), status = VALUES(status), updated_at_ms = VALUES(updated_at_ms) `, appCode, regionID, minUSDMinor, maxUSDMinor, coinPerUSD, nowMs, nowMs) if err != nil { r.t.Fatalf("seed coin seller salary exchange rate tier failed: %v", err) } } // CreateH5RechargeProduct 通过生产仓储写入 H5 档位,测试只提供角色、区域、美元微单位和金币数。 func (r *Repository) CreateH5RechargeProduct(audienceType string, regionID int64, amountMicro int64, coinAmount int64) ledger.RechargeProduct { r.t.Helper() product, err := r.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{ AppCode: "lalu", AudienceType: audienceType, AmountMicro: amountMicro, CoinAmount: coinAmount, ProductName: fmt.Sprintf("H5 %s %d", audienceType, coinAmount), Platform: ledger.RechargeProductPlatformWeb, RegionIDs: []int64{regionID}, Enabled: true, OperatorUserID: 90001, }) if err != nil { r.t.Fatalf("create h5 recharge product failed: %v", err) } return product } // ThirdPartyPaymentMethodID 返回 initdb/迁移种子里的真实 MiFaPay method_id,用于 H5 下单和跨国家拒绝测试。 func (r *Repository) ThirdPartyPaymentMethodID(countryCode string, payWay string, payType string) int64 { return r.ThirdPartyProviderPaymentMethodID(ledger.PaymentProviderMifaPay, countryCode, payWay, payType) } // ThirdPartyProviderPaymentMethodID 返回指定三方 provider 的真实 method_id,覆盖 V5Pay 和 MiFaPay 共用的支付方式模型。 func (r *Repository) ThirdPartyProviderPaymentMethodID(providerCode string, countryCode string, payWay string, payType string) int64 { r.t.Helper() var methodID int64 err := r.schema.DB.QueryRowContext(context.Background(), ` SELECT method_id FROM third_party_payment_methods WHERE app_code = 'lalu' AND provider_code = ? AND country_code = ? AND pay_way = ? AND pay_type = ? LIMIT 1`, providerCode, countryCode, payWay, payType, ).Scan(&methodID) if err != nil { r.t.Fatalf("query third party payment method %s/%s/%s/%s failed: %v", providerCode, countryCode, payWay, payType, err) } return methodID } // SetThirdPartyPaymentRate updates the production payment method row so service tests can lock provider amount semantics. func (r *Repository) SetThirdPartyPaymentRate(methodID int64, rate string) { r.t.Helper() if _, err := r.schema.DB.ExecContext(context.Background(), ` UPDATE third_party_payment_methods SET usd_to_currency_rate = ?, updated_at_ms = ? WHERE method_id = ?`, strings.TrimSpace(rate), time.Now().UnixMilli(), methodID, ); err != nil { r.t.Fatalf("set third party payment rate failed: %v", err) } } // SetGiftPrice overrides or inserts a gift settlement price for server-side billing tests. func (r *Repository) SetGiftPrice(giftID string, priceVersion string, coinPrice int64, giftPointAmount int64, heatValue int64) { r.SetGiftPriceForApp("lalu", giftID, priceVersion, coinPrice, giftPointAmount, heatValue) } // SetGiftPriceForApp overrides or inserts a gift settlement price under the requested app scope. func (r *Repository) SetGiftPriceForApp(appCode string, giftID string, priceVersion string, coinPrice int64, giftPointAmount int64, heatValue int64) { r.t.Helper() appCode = strings.TrimSpace(appCode) nowMs := time.Now().UnixMilli() // DebitGift 真实链路先校验 active gift_config,再读取 wallet_gift_prices;测试 seed 必须同时补齐资源、礼物配置和区域关系。 result, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO resources ( app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy, price_type, coin_price, gift_point_amount, usage_scope_json, created_at_ms, updated_at_ms ) VALUES (?, ?, 'gift', ?, 'active', TRUE, TRUE, 'new_entitlement', 'coin', ?, ?, '[]', ?, ?) ON DUPLICATE KEY UPDATE resource_id = LAST_INSERT_ID(resource_id), status = VALUES(status), coin_price = VALUES(coin_price), gift_point_amount = VALUES(gift_point_amount), updated_at_ms = VALUES(updated_at_ms) `, appCode, "test-gift-"+giftID, giftID, coinPrice, giftPointAmount, nowMs, nowMs) if err != nil { r.t.Fatalf("seed gift resource failed: %v", err) } resourceID, err := result.LastInsertId() if err != nil { r.t.Fatalf("read gift resource id failed: %v", err) } if _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO gift_configs ( app_code, gift_id, resource_id, status, name, sort_order, presentation_json, gift_type_code, effective_from_ms, effective_to_ms, effect_types_json, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 'active', ?, 0, JSON_OBJECT(), 'normal', 0, 0, JSON_ARRAY(), 0, 0, ?, ?) ON DUPLICATE KEY UPDATE resource_id = VALUES(resource_id), status = VALUES(status), name = VALUES(name), gift_type_code = VALUES(gift_type_code), effective_from_ms = VALUES(effective_from_ms), effective_to_ms = VALUES(effective_to_ms), updated_at_ms = VALUES(updated_at_ms) `, appCode, giftID, resourceID, giftID, nowMs, nowMs); err != nil { r.t.Fatalf("seed gift config failed: %v", err) } if _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO gift_config_regions (app_code, gift_id, region_id, created_at_ms, updated_at_ms) VALUES (?, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) `, appCode, giftID, nowMs, nowMs); err != nil { r.t.Fatalf("seed gift config region failed: %v", err) } _, err = r.schema.DB.ExecContext(context.Background(), ` INSERT INTO wallet_gift_prices ( app_code, gift_id, price_version, status, coin_price, gift_point_amount, heat_value, effective_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 'active', ?, ?, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), coin_price = VALUES(coin_price), gift_point_amount = VALUES(gift_point_amount), heat_value = VALUES(heat_value), updated_at_ms = VALUES(updated_at_ms) `, appCode, giftID, priceVersion, coinPrice, giftPointAmount, heatValue, nowMs, nowMs) if err != nil { r.t.Fatalf("seed wallet gift price failed: %v", err) } } // SetGiftType updates a seeded gift config to the requested gift type. func (r *Repository) SetGiftType(giftID string, giftTypeCode string) { r.t.Helper() _, err := r.schema.DB.ExecContext(context.Background(), ` UPDATE gift_configs SET gift_type_code = ?, updated_at_ms = ? WHERE gift_id = ?`, giftTypeCode, time.Now().UnixMilli(), giftID) if err != nil { r.t.Fatalf("set gift type failed: %v", err) } } // SetGiftDiamondRatio configures the gift diamond ratio for a room region and gift type. func (r *Repository) SetGiftDiamondRatio(regionID int64, giftTypeCode string, ratioPercent string) { r.t.Helper() nowMs := time.Now().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO gift_diamond_ratio_configs ( region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, 'active', ?, ?, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), ratio_percent = VALUES(ratio_percent), updated_at_ms = VALUES(updated_at_ms)`, regionID, giftTypeCode, ratioPercent, defaultGiftReturnCoinRatioPercent(giftTypeCode), nowMs, nowMs) if err != nil { r.t.Fatalf("set gift diamond ratio failed: %v", err) } } // SetGiftReturnCoinRatio configures the receiver COIN return ratio without changing host-period diamond rules. func (r *Repository) SetGiftReturnCoinRatio(regionID int64, giftTypeCode string, ratioPercent string) { r.t.Helper() nowMs := time.Now().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO gift_diamond_ratio_configs ( region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, 'active', ?, ?, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), coin_return_ratio_percent = VALUES(coin_return_ratio_percent), updated_at_ms = VALUES(updated_at_ms)`, regionID, giftTypeCode, defaultGiftDiamondRatioPercent(giftTypeCode), ratioPercent, nowMs, nowMs) if err != nil { r.t.Fatalf("set gift return coin ratio failed: %v", err) } } func defaultGiftDiamondRatioPercent(giftTypeCode string) string { switch strings.TrimSpace(giftTypeCode) { case "lucky": return "10.00" case "super_lucky": return "1.00" default: return "100.00" } } func defaultGiftReturnCoinRatioPercent(giftTypeCode string) string { switch strings.TrimSpace(giftTypeCode) { case "lucky": return "10.00" case "super_lucky": return "1.00" default: return "30.00" } } // SetRechargePolicy 配置区域充值定价,用于外部充值类事实测试。 func (r *Repository) SetRechargePolicy(regionID int64, policyVersion string, coinAmount int64, usdMinorAmount int64) { r.t.Helper() nowMs := time.Now().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO wallet_recharge_policies ( region_id, policy_version, status, currency_code, coin_amount, usd_minor_amount, effective_from_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, 'active', 'USD', ?, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), currency_code = VALUES(currency_code), coin_amount = VALUES(coin_amount), usd_minor_amount = VALUES(usd_minor_amount), updated_at_ms = VALUES(updated_at_ms) `, regionID, policyVersion, coinAmount, usdMinorAmount, nowMs, nowMs) if err != nil { r.t.Fatalf("seed wallet recharge policy failed: %v", err) } } // SetRechargeStats prepares cumulative recharge stats for recharge-related wallet tests. func (r *Repository) SetRechargeStats(userID int64, totalCoinAmount int64) { r.t.Helper() nowMs := time.Now().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO wallet_user_recharge_stats ( user_id, recharge_count, first_transaction_id, first_recharged_at_ms, total_coin_amount, total_usd_minor_amount, created_at_ms, updated_at_ms ) VALUES (?, 1, 'test_recharge', ?, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE total_coin_amount = VALUES(total_coin_amount), updated_at_ms = VALUES(updated_at_ms) `, userID, nowMs, totalCoinAmount, nowMs, nowMs) if err != nil { r.t.Fatalf("seed wallet recharge stats failed: %v", err) } } // SetVIPLevelRechargeThreshold writes a legacy VIP threshold directly, so tests can verify old rows no longer affect purchase eligibility. func (r *Repository) SetVIPLevelRechargeThreshold(level int32, totalCoinAmount int64) { r.t.Helper() nowMs := time.Now().UnixMilli() if _, err := r.schema.DB.ExecContext(context.Background(), ` UPDATE vip_levels SET required_recharge_coin_amount = ?, updated_at_ms = ? WHERE level = ?`, totalCoinAmount, nowMs, level, ); err != nil { r.t.Fatalf("set vip level recharge threshold failed: %v", err) } } // SetVIPLevelStatusForApp 只用于测试需要经过真实购买/体验卡入口的等级启停。 func (r *Repository) SetVIPLevelStatusForApp(appCode string, level int32, status string, priceCoin int64) { r.t.Helper() if _, err := r.schema.DB.ExecContext(context.Background(), ` UPDATE vip_levels SET status = ?, price_coin = ?, updated_at_ms = ? WHERE app_code = ? AND level = ?`, status, priceCoin, time.Now().UTC().UnixMilli(), appCode, level, ); err != nil { r.t.Fatalf("set vip level status failed: %v", err) } } // SetVIPDailyCoinRebateAmount 写入测试专用等级金额并递增 program 版本,模拟后台完整配置发布。 // amount<=0 时仍写入,供服务端校验/运行防线测试构造异常历史数据。 func (r *Repository) SetVIPDailyCoinRebateAmount(appCode string, level int32, amount int64, status string) { r.t.Helper() nowMS := time.Now().UTC().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO vip_level_benefits ( app_code, level, benefit_code, name, benefit_type, unlock_level, status, trial_enabled, resource_id, resource_type, execution_scope, auto_equip, sort_order, metadata_json, created_at_ms, updated_at_ms ) VALUES (?, ?, 'daily_coin_rebate', '金币返现', 'function', ?, ?, FALSE, 0, '', 'wallet', FALSE, 250, JSON_OBJECT('coin_amount', ?), ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), trial_enabled = FALSE, execution_scope = 'wallet', metadata_json = VALUES(metadata_json), updated_at_ms = VALUES(updated_at_ms)`, appCode, level, level, status, amount, nowMS, nowMS, ) if err != nil { r.t.Fatalf("set vip daily coin rebate amount failed: %v", err) } if _, err := r.schema.DB.ExecContext(context.Background(), ` UPDATE vip_program_configs SET config_version = config_version + 1, updated_at_ms = ? WHERE app_code = ?`, nowMS, appCode); err != nil { r.t.Fatalf("advance vip program version failed: %v", err) } } // SeedPaidVIPAtWindow 构造有完整 history 的付费 VIP,用于精确覆盖 UTC 日切边界。 func (r *Repository) SeedPaidVIPAtWindow(appCode string, userID int64, level int32, startedAtMS int64, expiresAtMS int64) { r.t.Helper() var name string var programType string var configVersion int64 if err := r.schema.DB.QueryRowContext(context.Background(), ` SELECT l.name, p.program_type, p.config_version FROM vip_levels l JOIN vip_program_configs p ON p.app_code = l.app_code WHERE l.app_code = ? AND l.level = ?`, appCode, level, ).Scan(&name, &programType, &configVersion); err != nil { r.t.Fatalf("read vip seed level failed: %v", err) } _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO user_vip_memberships ( app_code, user_id, level, name, status, program_type, config_version, started_at_ms, expires_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE level = VALUES(level), name = VALUES(name), status = 'active', program_type = VALUES(program_type), config_version = VALUES(config_version), started_at_ms = VALUES(started_at_ms), expires_at_ms = VALUES(expires_at_ms), updated_at_ms = VALUES(updated_at_ms)`, appCode, userID, level, name, programType, configVersion, startedAtMS, expiresAtMS, startedAtMS, startedAtMS, ) if err != nil { r.t.Fatalf("seed paid vip membership failed: %v", err) } if _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO user_vip_history ( app_code, user_id, action, previous_level, new_level, previous_expires_at_ms, new_expires_at_ms, program_type, config_version, order_id, created_at_ms ) VALUES (?, ?, 'test_seed', 0, ?, 0, ?, ?, ?, '', ?)`, appCode, userID, level, expiresAtMS, programType, configVersion, startedAtMS, ); err != nil { r.t.Fatalf("seed paid vip history failed: %v", err) } } // SeedLegacyPaidVIPAtWindow 模拟 060 新增有效期列后的存量历史:会员主表仍有完整窗口, // 但旧 history 因 ALTER DEFAULT 只有 0。运行时不得把这个 0 当作“日切时已过期”。 func (r *Repository) SeedLegacyPaidVIPAtWindow(appCode string, userID int64, level int32, startedAtMS int64, expiresAtMS int64) { r.t.Helper() r.SeedPaidVIPAtWindow(appCode, userID, level, startedAtMS, expiresAtMS) if _, err := r.schema.DB.ExecContext(context.Background(), ` UPDATE user_vip_history SET action = 'legacy_test_seed', previous_expires_at_ms = 0, new_expires_at_ms = 0 WHERE app_code = ? AND user_id = ? AND action = 'test_seed' AND created_at_ms = ?`, appCode, userID, startedAtMS, ); err != nil { r.t.Fatalf("convert paid vip history to legacy zero-expiry snapshot failed: %v", err) } } // ApplyVIPHistoryMigrationBaseline 在隔离真实 MySQL schema 中复制 060 的基线 INSERT。 // baselineAtMS 可固定在待测日切前,用于验证后续日切能读取该完整快照;重复调用不应增行。 func (r *Repository) ApplyVIPHistoryMigrationBaseline(baselineAtMS int64) { r.t.Helper() if _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO user_vip_history ( app_code, user_id, action, previous_level, new_level, previous_expires_at_ms, new_expires_at_ms, program_type, config_version, order_id, created_at_ms ) SELECT memberships.app_code, memberships.user_id, 'migration_baseline', 0, memberships.level, 0, memberships.expires_at_ms, memberships.program_type, memberships.config_version, 'vip_configurable_060_baseline', ? FROM user_vip_memberships AS memberships WHERE memberships.level > 0 AND memberships.expires_at_ms > 0 AND NOT EXISTS ( SELECT 1 FROM user_vip_history AS complete_history WHERE complete_history.app_code = memberships.app_code AND complete_history.user_id = memberships.user_id AND complete_history.new_level > 0 AND complete_history.new_expires_at_ms > 0 ) AND NOT EXISTS ( SELECT 1 FROM user_vip_history AS existing_baseline WHERE existing_baseline.app_code = memberships.app_code AND existing_baseline.user_id = memberships.user_id AND existing_baseline.order_id = 'vip_configurable_060_baseline' )`, baselineAtMS, ); err != nil { r.t.Fatalf("apply vip history migration baseline failed: %v", err) } } // WalletOutboxPayload returns the latest payload for a precise event assertion. func (r *Repository) WalletOutboxPayload(appCode string, eventType string, userID int64) string { r.t.Helper() var payload string if err := r.schema.DB.QueryRowContext(context.Background(), ` SELECT CAST(payload AS CHAR) FROM wallet_outbox WHERE app_code = ? AND event_type = ? AND user_id = ? ORDER BY created_at_ms DESC, event_id DESC LIMIT 1`, appCode, eventType, userID, ).Scan(&payload); err != nil { r.t.Fatalf("read wallet outbox payload failed: %v", err) } return payload } // SetHostSalaryPolicy seeds the wallet runtime policy snapshot used by salary settlement tests. func (r *Repository) SetHostSalaryPolicy(policy ledger.HostSalaryPolicy) { r.t.Helper() nowMs := time.Now().UnixMilli() if policy.PolicyID == 0 { policy.PolicyID = 1 } if policy.Status == "" { policy.Status = "active" } if policy.SettlementMode == "" { policy.SettlementMode = ledger.HostSalarySettlementModeDaily } if policy.SettlementTriggerMode == "" { // 测试默认覆盖生产 cron 路径;需要验证人工政策时再显式传 manual。 policy.SettlementTriggerMode = ledger.HostSalarySettlementTriggerAutomatic } if policy.GiftCoinToDiamondRatio == "" { policy.GiftCoinToDiamondRatio = "1" } if policy.ResidualDiamondToUSDRate == "" { policy.ResidualDiamondToUSDRate = "0" } _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO host_agency_salary_policies ( 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), region_id = VALUES(region_id), status = VALUES(status), settlement_mode = VALUES(settlement_mode), settlement_trigger_mode = VALUES(settlement_trigger_mode), gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio), residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate), effective_from_ms = VALUES(effective_from_ms), effective_to_ms = VALUES(effective_to_ms), updated_at_ms = VALUES(updated_at_ms) `, policy.PolicyID, policy.Name, policy.RegionID, policy.Status, policy.SettlementMode, policy.SettlementTriggerMode, policy.GiftCoinToDiamondRatio, policy.ResidualDiamondToUSDRate, policy.EffectiveFromMs, policy.EffectiveToMs, nowMs, nowMs) if err != nil { r.t.Fatalf("seed host salary policy failed: %v", err) } if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM host_agency_salary_policy_levels WHERE policy_id = ?`, policy.PolicyID); err != nil { r.t.Fatalf("clear host salary policy levels failed: %v", err) } for _, level := range policy.Levels { if level.Status == "" { level.Status = "active" } if _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO host_agency_salary_policy_levels ( 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, policy.PolicyID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor, level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder, nowMs, nowMs, ); err != nil { r.t.Fatalf("seed host salary policy level %d failed: %v", level.LevelNo, err) } } } func initDBPath(t testing.TB) string { t.Helper() return mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_wallet_service.sql", ) } func validateTableName(table string) string { switch table { case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records", "payment_orders", "wallet_user_recharge_stats", "external_recharge_orders", "third_party_payment_methods", "host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies", "host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records", "coin_seller_stock_records", "gift_diamond_ratio_configs", "wallet_diamond_exchange_rules", "vip_program_configs", "vip_levels", "vip_level_benefits", "user_vip_memberships", "user_vip_settings", "vip_purchase_orders", "user_vip_history", "user_vip_trial_cards", "vip_command_locks", "vip_daily_coin_rebate_runs", "vip_daily_coin_rebates", "resources", "resource_groups", "resource_group_items", "resource_shop_items", "resource_shop_purchase_orders", "gift_type_configs", "gift_configs", "gift_config_regions", "user_gift_wall", "wallet_projection_events", "resource_grants", "resource_grant_items", "user_resource_entitlements", "user_resource_equipment": return table default: panic(fmt.Sprintf("unsupported wallet test table %q", table)) } }