645 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_agency_salary_policies
ADD COLUMN settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式automatic/manual' AFTER settlement_mode`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN cycle_key VARCHAR(7) NOT NULL DEFAULT '' COMMENT '政策归属工资周期UTC yyyy-MM' AFTER policy_id`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN policy_version BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '不可变政策版本,等于后台政策 ID' AFTER cycle_key`); err != nil && !isDuplicateColumnError(err) {
return err
}
// 提现限制随工资政策版本发布;启动兼容只补小型配置表列,绝不回查钱包或提现流水推导计数。
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN coin_seller_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT 'month' COMMENT '用户找币商提现限制周期day/week/month' AFTER residual_diamond_to_usd_rate`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN coin_seller_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT '用户找币商提现次数0 表示不限' AFTER coin_seller_withdrawal_limit_period`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN platform_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT 'month' COMMENT '用户找平台提现限制周期day/week/month' AFTER coin_seller_withdrawal_limit_count`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN platform_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT '用户找平台提现次数0 表示不限' AFTER platform_withdrawal_limit_period`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN platform_withdrawal_allowed_days VARCHAR(96) NOT NULL DEFAULT '' COMMENT '用户找平台提现允许的 UTC 月内日,逗号分隔,空表示每天' AFTER platform_withdrawal_limit_count`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS point_withdrawal_policy_counters (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
channel VARCHAR(24) NOT NULL,
period VARCHAR(16) NOT NULL,
period_key VARCHAR(10) NOT NULL,
withdrawal_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, channel, period, period_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户自然周期提现次数计数表'`); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS point_withdrawal_limit_reservations (
app_code VARCHAR(32) NOT NULL,
withdrawal_ref VARCHAR(128) NOT NULL,
freeze_command_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
channel VARCHAR(24) NOT NULL,
policy_id BIGINT UNSIGNED NOT NULL,
policy_version BIGINT UNSIGNED NOT NULL,
period VARCHAR(16) NOT NULL,
period_key VARCHAR(10) NOT NULL,
counted BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at_ms BIGINT NOT NULL,
released_at_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (app_code, withdrawal_ref),
UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, freeze_command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台积分提现次数预留表'`); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS host_salary_policy_cycle_bindings (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
region_id BIGINT NOT NULL,
cycle_key VARCHAR(7) NOT NULL,
policy_id BIGINT UNSIGNED NOT NULL,
policy_version BIGINT UNSIGNED NOT NULL,
source_cycle_key VARCHAR(7) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, region_id, cycle_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期不可变政策绑定表'`); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_salary_policy_cycle_bindings
ADD COLUMN source_cycle_key VARCHAR(7) NOT NULL DEFAULT '' COMMENT '来源月份;等于 cycle_key 表示显式发布,否则表示继承' AFTER policy_version`); err != nil && !isDuplicateColumnError(err) {
return err
}
// 迁移前绑定视为显式版本;只有新代码的运行时继承会写入与目标月不同的来源月。
if _, err := db.ExecContext(ctx, `
UPDATE host_salary_policy_cycle_bindings
SET source_cycle_key = cycle_key
WHERE source_cycle_key = ''`); err != nil {
return err
}
var legacyBindingIndexCount int
// 旧唯一键会阻止同一政策覆盖多个区域或被复制到新周期;信息 schema 是常数级元数据查询,避免盲目 DDL 报错。
if err := db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'host_salary_policy_cycle_bindings'
AND INDEX_NAME = 'uk_host_salary_policy_cycle_version'`).Scan(&legacyBindingIndexCount); err != nil {
return err
}
if legacyBindingIndexCount > 0 {
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_salary_policy_cycle_bindings
DROP INDEX uk_host_salary_policy_cycle_version, ALGORITHM=INPLACE, LOCK=NONE`); err != nil {
return err
}
}
if _, err := db.ExecContext(ctx, `
UPDATE host_agency_salary_policies
SET cycle_key = DATE_FORMAT(
DATE_ADD('1970-01-01 00:00:00', INTERVAL (COALESCE(NULLIF(effective_from_ms, 0), created_at_ms) DIV 1000) SECOND),
'%Y-%m'
),
policy_version = policy_id
WHERE cycle_key = '' OR policy_version = 0`); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
INSERT IGNORE INTO host_salary_policy_cycle_bindings (
app_code, region_id, cycle_key, policy_id, policy_version, source_cycle_key, created_at_ms
)
SELECT policy.app_code, policy.region_id, policy.cycle_key, policy.policy_id, policy.policy_version, policy.cycle_key, policy.created_at_ms
FROM host_agency_salary_policies policy
WHERE policy.cycle_key <> ''
AND policy.status = 'active'
AND NOT EXISTS (
SELECT 1
FROM host_agency_salary_policies newer
WHERE newer.app_code = policy.app_code
AND newer.region_id = policy.region_id
AND newer.cycle_key = policy.cycle_key
AND newer.status = 'active'
AND (
newer.effective_from_ms > policy.effective_from_ms
OR (newer.effective_from_ms = policy.effective_from_ms AND newer.policy_id > policy.policy_id)
)
)`); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD INDEX idx_host_agency_salary_policy_cycle (app_code, region_id, cycle_key, status, settlement_mode, settlement_trigger_mode)`); err != nil && !isDuplicateKeyNameError(err) {
return err
}
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
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_salary_settlement_progress
ADD COLUMN last_policy_version BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一次使用的不可变政策版本' AFTER last_policy_id`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_salary_settlement_records
ADD COLUMN policy_version BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '使用的不可变政策版本' AFTER policy_id`); 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 countryCodeGLOBAL 表示全球方式',
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 ms0 表示未同步' AFTER paid_net_micro`,
`ALTER TABLE payment_orders ADD COLUMN paid_sync_attempt_count INT NOT NULL DEFAULT 0 COMMENT 'Google 实付同步连续失败次数' AFTER paid_synced_at_ms`,
`ALTER TABLE payment_orders ADD COLUMN paid_sync_last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Google 实付同步最近错误' AFTER paid_sync_attempt_count`,
`ALTER TABLE payment_orders ADD COLUMN paid_sync_next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 实付同步下次重试时间UTC epoch ms' AFTER paid_sync_last_error`,
}
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
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE payment_orders
ADD INDEX idx_payment_orders_paid_sync (provider, paid_synced_at_ms, paid_sync_next_retry_at_ms, created_at_ms)`); 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',
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定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='钱包运行侧收益政策实例表'`)
if err != nil {
return err
}
if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
return err
}
_, err = db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS agency_point_share_entries (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
transaction_id VARCHAR(96) NOT NULL,
host_user_id BIGINT NOT NULL,
agency_owner_user_id BIGINT NOT NULL,
sender_user_id BIGINT NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
point_delta BIGINT NOT NULL,
ratio_ppm BIGINT NOT NULL,
share_base_type VARCHAR(32) NOT NULL DEFAULT 'unknown',
share_base_amount BIGINT NOT NULL DEFAULT 0,
policy_instance_code VARCHAR(96) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, transaction_id, agency_owner_user_id),
KEY idx_agency_point_share_owner_time (app_code, agency_owner_user_id, created_at_ms),
KEY idx_agency_point_share_host_time (app_code, host_user_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agency POINT 礼物分成流水'`)
if err != nil {
return err
}
// 存量库由旧版本创建表时没有公式基数字段;启动补列必须幂等,并在新代码接受送礼前完成,
// 否则账本已加钱而审计流水 INSERT 失败只能依赖事务回滚兜底。
if _, err := db.ExecContext(ctx, `ALTER TABLE agency_point_share_entries ADD COLUMN share_base_type VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '分成基数unknown/charge_amount/host_income' AFTER ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `ALTER TABLE agency_point_share_entries ADD COLUMN share_base_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次分成计算基数share_base_type=unknown 时数值不可解释' AFTER share_base_type`); err != nil && !isDuplicateColumnError(err) {
return err
}
return nil
}
// ensurePointWithdrawalCoinSellerSchema 创建跨 Fami/Huwaa/Lalu 共用、按 app_code 隔离的提现币商配置。
// 表放在 wallet 数据库保证兑换时读取比例和写账本处于同一可信服务边界H5 不能自报比例。
func ensurePointWithdrawalCoinSellerSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
seller_user_id BIGINT NOT NULL COMMENT '币商用户 ID',
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越小越靠前',
point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母',
seller_coin_amount BIGINT NOT NULL COMMENT '兑换比例中的币商金币分子',
service_country_codes JSON NOT NULL COMMENT '服务国家码数组;空数组表示不限国家',
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, seller_user_id),
KEY idx_point_withdraw_seller_list (app_code, status, sort_order, seller_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT 提现币商白名单与兑换比例'`)
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" {
if _, err = db.ExecContext(ctx, `
ALTER TABLE user_resource_equipment
DROP PRIMARY KEY,
ADD PRIMARY KEY (app_code, user_id, resource_type, entitlement_id)`); err != nil {
return err
}
}
// 命令表是 append-only 幂等事实,主键查询不会扫描 equipment 或 entitlement 大表。
// response_json 固化首个成功回包,使资源随后到期/下架也不会改变同 command_id 的重试语义。
_, err = db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS user_resource_equipment_commands (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
command_id VARCHAR(128) NOT NULL COMMENT '装备业务幂等 ID',
request_hash CHAR(64) NOT NULL COMMENT '用户、资源和指定 entitlement 的请求语义哈希',
user_id BIGINT NOT NULL COMMENT '操作用户 ID',
resource_id BIGINT NOT NULL COMMENT '目标资源 ID',
requested_entitlement_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '客户端指定的 entitlement空表示服务端选择',
result_entitlement_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '首个成功命中的 entitlement',
resource_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '首个成功结果的资源类型',
response_json JSON NULL COMMENT '首个成功 UserResourceEntitlement 回包快照',
created_at_ms BIGINT NOT NULL COMMENT '首次命令时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '结果完成时间UTC epoch ms',
PRIMARY KEY (app_code, command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备命令幂等事实表'`)
return err
}
// ensureVipBenefitResourceBindingHistorySchema 保证任何曾被 tiered VIP 绑定的资源都不会在
// 后台改绑、删除权益或关闭 program 后退化成普通资源。历史表只随小型配置矩阵追加,运行时
// 装备门禁按 (app_code,resource_id) 覆盖索引点查,不扫描用户 entitlement/equipment 流水。
func ensureVipBenefitResourceBindingHistorySchema(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS vip_benefit_resource_binding_history (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
history_id CHAR(64) NOT NULL COMMENT '配置版本、阶段、等级和权益的稳定哈希',
config_version BIGINT NOT NULL COMMENT '写入该快照的 VIP 配置版本',
change_phase VARCHAR(16) NOT NULL COMMENT 'baseline/before/after',
program_type VARCHAR(32) NOT NULL COMMENT '快照时的 VIP program 类型',
level INT NOT NULL COMMENT 'VIP 等级',
benefit_code VARCHAR(64) NOT NULL COMMENT '权益编码',
benefit_status VARCHAR(32) NOT NULL COMMENT '权益快照状态',
trial_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '体验卡是否可用快照',
resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '当时绑定资源 ID0 表示未绑定',
resource_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '当时要求的资源类型',
auto_equip BOOLEAN NOT NULL DEFAULT FALSE COMMENT '当时自动装备策略',
recorded_at_ms BIGINT NOT NULL COMMENT '记录时间UTC epoch ms',
PRIMARY KEY (app_code, history_id),
UNIQUE KEY uk_vip_binding_history_snapshot (app_code, config_version, change_phase, level, benefit_code),
KEY idx_vip_binding_history_resource (app_code, resource_id, program_type, benefit_code, level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 权益资源绑定不可变历史'`); err != nil {
return err
}
// baseline 只复制当前配置小表,并通过唯一键保持启动幂等。历史资源从此即使被 DELETE
// 也可被装备/appearance 门禁识别;不触碰任何用户级大表。
_, err := db.ExecContext(ctx, `
INSERT IGNORE INTO vip_benefit_resource_binding_history (
app_code, history_id, config_version, change_phase, program_type, level,
benefit_code, benefit_status, trial_enabled, resource_id, resource_type,
auto_equip, recorded_at_ms
)
SELECT b.app_code,
SHA2(CONCAT_WS('|', b.app_code, p.config_version, 'baseline', b.level, b.benefit_code), 256),
p.config_version, 'baseline', p.program_type, b.level, b.benefit_code,
b.status, b.trial_enabled, b.resource_id, b.resource_type, b.auto_equip,
GREATEST(b.updated_at_ms, p.updated_at_ms)
FROM vip_level_benefits AS b
JOIN vip_program_configs AS p ON p.app_code = b.app_code`)
return err
}
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()
}