package mysql import ( "context" "database/sql" "errors" "strings" mysqlDriver "github.com/go-sql-driver/mysql" ) func ensureResourceShopPurchaseOrderIndexes(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, ` ALTER TABLE resource_shop_purchase_orders ADD INDEX idx_resource_shop_purchase_time (app_code, created_at_ms, order_id)`); err != nil && !isDuplicateKeyNameError(err) { return err } if _, err := db.ExecContext(ctx, ` ALTER TABLE resource_shop_purchase_orders ADD INDEX idx_resource_shop_purchase_resource_time (app_code, resource_id, created_at_ms, order_id)`); err != nil && !isDuplicateKeyNameError(err) { return err } return nil } func ensureResourceGrantRevokeSchema(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, ` ALTER TABLE resource_grants ADD COLUMN revoked_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '撤销时间,UTC epoch ms' AFTER operator_user_id`); err != nil && !isDuplicateColumnError(err) { return err } if _, err := db.ExecContext(ctx, ` ALTER TABLE resource_grants ADD COLUMN revoked_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '撤销操作人用户 ID' AFTER revoked_at_ms`); err != nil && !isDuplicateColumnError(err) { return err } if _, err := db.ExecContext(ctx, ` ALTER TABLE resource_grants ADD COLUMN revoke_reason VARCHAR(256) NOT NULL DEFAULT '' COMMENT '撤销原因' AFTER revoked_by_user_id`); err != nil && !isDuplicateColumnError(err) { return err } return nil } func isDuplicateKeyNameError(err error) bool { var mysqlErr *mysqlDriver.MySQLError return errors.As(err, &mysqlErr) && mysqlErr.Number == 1061 } func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, ` ALTER TABLE host_salary_settlement_records ADD COLUMN trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '触发方式:automatic/manual' AFTER settlement_type`); err != nil && !isDuplicateColumnError(err) { return err } if _, err := db.ExecContext(ctx, ` ALTER TABLE host_salary_settlement_records ADD COLUMN settlement_role VARCHAR(24) NOT NULL DEFAULT 'all' COMMENT '结算身份:all/host/agency' AFTER trigger_mode`); err != nil && !isDuplicateColumnError(err) { return err } return nil } func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, ` ALTER TABLE wallet_recharge_products ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '充值档位适用用户类型:normal/coin_seller' AFTER app_code`); err != nil && !isDuplicateColumnError(err) { return err } if _, err := db.ExecContext(ctx, ` ALTER TABLE wallet_recharge_products ADD COLUMN app_visible BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否在 App 普通充值列表展示;不影响 Google Play 验单' AFTER status`); err != nil && !isDuplicateColumnError(err) { return err } if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS third_party_payment_channels ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码', provider_name VARCHAR(64) NOT NULL COMMENT '三方支付渠道名称', status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, provider_code), KEY idx_third_party_payment_channels_status (app_code, status, sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付渠道配置表'`); err != nil { return err } if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS third_party_payment_methods ( method_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '支付方式 ID', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码', country_code VARCHAR(16) NOT NULL COMMENT 'MiFaPay countryCode,GLOBAL 表示全球方式', country_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '国家/地区名称', currency_code VARCHAR(8) NOT NULL COMMENT '下单币种', pay_way VARCHAR(64) NOT NULL COMMENT 'MiFaPay payWay', pay_type VARCHAR(64) NOT NULL COMMENT 'MiFaPay payType', method_name VARCHAR(128) NOT NULL COMMENT '支付方式展示名', logo_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '支付方式 logo', status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', usd_to_currency_rate DECIMAL(24,8) NOT NULL DEFAULT 0 COMMENT '1 USD 可兑换本币金额;0 表示未配置,不在 H5 展示', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (method_id), UNIQUE KEY uk_third_party_payment_method (app_code, provider_code, country_code, pay_way, pay_type), KEY idx_third_party_payment_methods_country (app_code, provider_code, country_code, status, sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付国家和方式配置表'`); err != nil { return err } if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS external_recharge_orders ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', order_id VARCHAR(96) NOT NULL COMMENT '平台外部充值订单 ID', command_id VARCHAR(128) NOT NULL COMMENT '客户端命令幂等 ID', target_user_id BIGINT NOT NULL COMMENT '充值目标用户 ID', target_region_id BIGINT NOT NULL COMMENT '充值目标区域 ID', target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT '充值目标国家 ID', target_country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '充值目标国家码', audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '商品适用用户类型', product_id BIGINT NOT NULL COMMENT '充值商品 ID', product_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '充值商品编码快照', product_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '充值商品名称快照', coin_amount BIGINT NOT NULL COMMENT '到账金币数量', usd_minor_amount BIGINT NOT NULL COMMENT '美元最小单位金额', provider_code VARCHAR(32) NOT NULL COMMENT '支付渠道:mifapay/usdt_trc20', payment_method_id BIGINT NOT NULL DEFAULT 0 COMMENT '三方支付方式 ID', country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '支付国家码', currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '支付币种', provider_amount_minor BIGINT NOT NULL DEFAULT 0 COMMENT '三方下单金额最小单位', pay_way VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payWay', pay_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payType', pay_url TEXT NULL COMMENT '三方收银台地址', provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '三方订单号', tx_hash VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT-TRC20 交易哈希', receive_address VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT 收款地址', status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/redirected/credited/failed', failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因', wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '入账钱包交易 ID', provider_payload JSON NULL COMMENT '三方原始响应或链上交易快照', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, order_id), UNIQUE KEY uk_external_recharge_command (app_code, command_id), KEY idx_external_recharge_user_time (app_code, target_user_id, created_at_ms), KEY idx_external_recharge_provider_order (app_code, provider_code, provider_order_id), KEY idx_external_recharge_wallet_tx (app_code, wallet_transaction_id), KEY idx_external_recharge_reconcile (app_code, status, provider_code, updated_at_ms, created_at_ms), KEY idx_external_recharge_tx (app_code, provider_code, tx_hash) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'`); err != nil { return err } if _, err := db.ExecContext(ctx, ` ALTER TABLE external_recharge_orders ADD COLUMN target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT '充值目标国家 ID' AFTER target_region_id`); err != nil && !isDuplicateColumnError(err) { return err } return seedThirdPartyPaymentDefaults(ctx, db) } // ensureGooglePaymentPaidDetailsSchema 给 payment_orders 补 Google Orders API 实付明细列; // 财务列表按 wallet_transaction_id 反查 Google 支付订单,需要独立索引避免回表扫描。 func ensureGooglePaymentPaidDetailsSchema(ctx context.Context, db *sql.DB) error { alters := []string{ `ALTER TABLE payment_orders ADD COLUMN paid_currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '用户实付币种,来自 Google Orders API' AFTER amount_micro`, `ALTER TABLE payment_orders ADD COLUMN paid_amount_micro BIGINT NOT NULL DEFAULT 0 COMMENT '用户实付总额微单位(实付币种)' AFTER paid_currency_code`, `ALTER TABLE payment_orders ADD COLUMN paid_tax_micro BIGINT NOT NULL DEFAULT 0 COMMENT '订单税额微单位(实付币种)' AFTER paid_amount_micro`, `ALTER TABLE payment_orders ADD COLUMN paid_net_micro BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 扣费后开发者净收入微单位(实付币种)' AFTER paid_tax_micro`, `ALTER TABLE payment_orders ADD COLUMN paid_synced_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实付明细同步时间,UTC epoch ms;0 表示未同步' AFTER paid_net_micro`, } for _, stmt := range alters { if _, err := db.ExecContext(ctx, stmt); err != nil && !isDuplicateColumnError(err) { return err } } if _, err := db.ExecContext(ctx, ` ALTER TABLE payment_orders ADD INDEX idx_payment_orders_wallet_tx (app_code, wallet_transaction_id)`); err != nil && !isDuplicateKeyNameError(err) { return err } return nil } func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error { _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS wallet_policy_instances ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码', instance_code VARCHAR(96) NOT NULL COMMENT '后台策略实例编码', template_code VARCHAR(96) NOT NULL COMMENT '模板编码快照', template_version VARCHAR(64) NOT NULL COMMENT '模板版本快照', region_id BIGINT NOT NULL DEFAULT 0 COMMENT '适用地区,0 表示 App 兜底', status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'UTC epoch ms', effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效', host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产', host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm', points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例', withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps', rule_json JSON NOT NULL COMMENT '完整政策快照', published_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, published_at_ms BIGINT NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, instance_code, region_id), KEY idx_wallet_policy_active (app_code, region_id, status, effective_from_ms, effective_to_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包运行侧收益政策实例表'`) return err } func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域,0 表示全局默认', gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码', status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态', ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例,百分比', coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '收礼人金币返还比例,百分比', created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID', updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, region_id, gift_type_code), KEY idx_gift_diamond_ratio_region (app_code, region_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'`); err != nil { return err } if added, err := ensureGiftReturnCoinRatioColumn(ctx, db); err != nil { return err } else if added { if err := seedGiftReturnCoinRatioDefaults(ctx, db); err != nil { return err } } _, err := db.ExecContext(ctx, ` INSERT IGNORE INTO gift_diamond_ratio_configs ( app_code, 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 ('lalu', 0, 'normal', 'active', 100.00, 30.00, 0, 0, 0, 0), ('lalu', 0, 'lucky', 'active', 10.00, 10.00, 0, 0, 0, 0), ('lalu', 0, 'super_lucky', 'active', 1.00, 1.00, 0, 0, 0, 0)`) return err } func ensureGiftReturnCoinRatioColumn(ctx context.Context, db *sql.DB) (bool, error) { if _, err := db.ExecContext(ctx, ` ALTER TABLE gift_diamond_ratio_configs ADD COLUMN coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '收礼人金币返还比例,百分比' AFTER ratio_percent`); err != nil { if isDuplicateColumnError(err) { return false, nil } return false, err } return true, nil } func seedGiftReturnCoinRatioDefaults(ctx context.Context, db *sql.DB) error { _, err := db.ExecContext(ctx, ` UPDATE gift_diamond_ratio_configs SET coin_return_ratio_percent = CASE gift_type_code WHEN 'lucky' THEN 10.00 WHEN 'super_lucky' THEN 1.00 ELSE 30.00 END`) return err } func ensureCoinSellerSalaryExchangeRateSchema(ctx context.Context, db *sql.DB) error { _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', region_id BIGINT NOT NULL COMMENT '币商所属区域 ID', tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '区间 ID', min_usd_minor BIGINT NOT NULL COMMENT '起始美元金额,单位美分,包含', max_usd_minor BIGINT NOT NULL COMMENT '结束美元金额,单位美分,包含', coin_per_usd BIGINT NOT NULL COMMENT '每 1 USD 工资可兑换的币商专用金币数量', status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID', updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (tier_id), KEY idx_coin_seller_salary_rates_region (app_code, region_id, status, min_usd_minor, max_usd_minor), KEY idx_coin_seller_salary_rates_sort (app_code, region_id, status, sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商工资兑换比例区间表'`) return err } func ensureUserResourceEquipmentSchema(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS user_resource_equipment ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', user_id BIGINT NOT NULL COMMENT '用户 ID', resource_type VARCHAR(32) NOT NULL COMMENT '资源类型', entitlement_id VARCHAR(96) NOT NULL COMMENT '权益 ID', resource_id BIGINT NOT NULL COMMENT '资源 ID', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, user_id, resource_type, entitlement_id), KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表'`); err != nil { return err } primaryColumns, err := tablePrimaryKeyColumns(ctx, db, "user_resource_equipment") if err != nil { return err } if strings.Join(primaryColumns, ",") == "app_code,user_id,resource_type" { _, err = db.ExecContext(ctx, ` ALTER TABLE user_resource_equipment DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, user_id, resource_type, entitlement_id)`) return err } return nil } func tablePrimaryKeyColumns(ctx context.Context, db *sql.DB, tableName string) ([]string, error) { rows, err := db.QueryContext(ctx, ` SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY' ORDER BY ORDINAL_POSITION ASC`, tableName) if err != nil { return nil, err } defer rows.Close() columns := make([]string, 0, 4) for rows.Next() { var column string if err := rows.Scan(&column); err != nil { return nil, err } columns = append(columns, column) } return columns, rows.Err() }