package mysql import ( "context" "database/sql" "errors" "fmt" "strings" _ "github.com/go-sql-driver/mysql" "hyapp/pkg/appcode" "hyapp/pkg/xerr" activitydomain "hyapp/services/activity-service/internal/domain/activity" ) // Repository 是 activity-service 的 MySQL 存储入口。 type Repository struct { db *sql.DB } // Open 创建 MySQL 连接池并做启动 ping。 func Open(ctx context.Context, dsn string) (*Repository, error) { if strings.TrimSpace(dsn) == "" { return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required") } db, err := sql.Open("mysql", dsn) if err != nil { return nil, err } if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, err } return &Repository{db: db}, nil } // Migrate performs narrow, idempotent local schema fixes for activity-service. func (r *Repository) Migrate(ctx context.Context) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } if err := r.ensureLuckyDrawPoolID(ctx); err != nil { return err } if err := r.ensureLuckyGiftOutboxColumns(ctx); err != nil { return err } if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil { return err } if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil { return err } return nil } // Close 释放 MySQL 连接池。 func (r *Repository) Close() error { if r == nil || r.db == nil { return nil } return r.db.Close() } // Ping 验证 MySQL 当前可用。 func (r *Repository) Ping(ctx context.Context) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } return r.db.PingContext(ctx) } func (r *Repository) ensureLuckyDrawPoolID(ctx context.Context) error { const table = "lucky_draw_records" hasPoolID, err := r.columnExists(ctx, table, "pool_id") if err != nil { return err } if !hasPoolID { if _, err := r.db.ExecContext(ctx, ` ALTER TABLE lucky_draw_records ADD COLUMN pool_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '幸运礼物奖池 ID' AFTER anchor_id`); err != nil { return err } } if _, err := r.db.ExecContext(ctx, `UPDATE lucky_draw_records SET pool_id = gift_id WHERE pool_id = ''`); err != nil { return err } hasPoolIndex, err := r.indexExists(ctx, table, "idx_lucky_draw_pool") if err != nil { return err } if !hasPoolIndex { if _, err := r.db.ExecContext(ctx, `CREATE INDEX idx_lucky_draw_pool ON lucky_draw_records (app_code, pool_id, created_at_ms)`); err != nil { return err } } return nil } func (r *Repository) ensureLuckyGiftOutboxColumns(ctx context.Context) error { additions := []struct { name string sql string }{ {"locked_by", `ALTER TABLE activity_outbox ADD COLUMN locked_by VARCHAR(128) NOT NULL DEFAULT '' COMMENT '当前锁定 worker' AFTER next_retry_at_ms`}, {"lock_until_ms", `ALTER TABLE activity_outbox ADD COLUMN lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '锁过期时间,UTC epoch ms' AFTER locked_by`}, {"last_error", `ALTER TABLE activity_outbox ADD COLUMN last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次失败原因' AFTER lock_until_ms`}, } for _, addition := range additions { exists, err := r.columnExists(ctx, "activity_outbox", addition.name) if err != nil { return err } if !exists { if _, err := r.db.ExecContext(ctx, addition.sql); err != nil { return err } } } return nil } func (r *Repository) ensureLuckyDrawRewardSettlementColumns(ctx context.Context) error { additions := []struct { name string sql string }{ {"reward_transaction_id", `ALTER TABLE lucky_draw_records ADD COLUMN reward_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '返奖钱包交易 ID' AFTER reward_status`}, {"reward_failure_reason", `ALTER TABLE lucky_draw_records ADD COLUMN reward_failure_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '返奖失败原因' AFTER reward_transaction_id`}, } for _, addition := range additions { exists, err := r.columnExists(ctx, "lucky_draw_records", addition.name) if err != nil { return err } if !exists { if _, err := r.db.ExecContext(ctx, addition.sql); err != nil { return err } } } return nil } func (r *Repository) ensureDefaultLuckyGiftRules(ctx context.Context) error { // 本地和 Docker 开发库通常保留 MySQL volume;这里补齐缺省 lucky/super_lucky 契约,但不覆盖后台已发布规则。 const seedNowMS int64 = 1779259000000 const seedTiersJSON = `[ {"pool":"novice","tier_id":"novice_none","reward_coins":0,"multiplier_ppm":0,"weight":0,"high_water_only":false,"enabled":true}, {"pool":"novice","tier_id":"novice_1x","reward_coins":100,"multiplier_ppm":1000000,"weight":0,"high_water_only":false,"enabled":true}, {"pool":"novice","tier_id":"novice_2x","reward_coins":200,"multiplier_ppm":2000000,"weight":0,"high_water_only":false,"enabled":true}, {"pool":"intermediate","tier_id":"intermediate_none","reward_coins":0,"multiplier_ppm":0,"weight":0,"high_water_only":false,"enabled":true}, {"pool":"intermediate","tier_id":"intermediate_1x","reward_coins":100,"multiplier_ppm":1000000,"weight":0,"high_water_only":false,"enabled":true}, {"pool":"intermediate","tier_id":"intermediate_2x","reward_coins":200,"multiplier_ppm":2000000,"weight":0,"high_water_only":false,"enabled":true}, {"pool":"advanced","tier_id":"advanced_none","reward_coins":0,"multiplier_ppm":0,"weight":0,"high_water_only":false,"enabled":true}, {"pool":"advanced","tier_id":"advanced_1x","reward_coins":100,"multiplier_ppm":1000000,"weight":0,"high_water_only":false,"enabled":true}, {"pool":"advanced","tier_id":"advanced_2x","reward_coins":200,"multiplier_ppm":2000000,"weight":0,"high_water_only":false,"enabled":true} ]` _, err := r.db.ExecContext(ctx, ` INSERT IGNORE INTO lucky_gift_rules ( app_code, gift_id, enabled, rule_version, gift_price, target_rtp_ppm, pool_rate_ppm, global_window_draws, gift_window_draws, novice_draw_limit, intermediate_draw_limit, high_multiplier, high_water_pool_multiple, platform_pool_weight_ppm, room_pool_weight_ppm, gift_pool_weight_ppm, initial_platform_pool, initial_gift_pool, initial_room_pool, platform_reserve, gift_reserve, room_reserve, max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap, room_atmosphere_rate_ppm, room_atmosphere_initial, room_atmosphere_reserve, activity_budget, activity_daily_limit, large_tier_enabled, tiers_json, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES ('lalu', 'lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, ?, 0, ?, ?), ('lalu', 'super_lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, ?, 0, ?, ?)`, seedTiersJSON, seedNowMS, seedNowMS, seedTiersJSON, seedNowMS, seedNowMS, ) return err } func (r *Repository) columnExists(ctx context.Context, table string, column string) (bool, error) { database, err := r.currentDatabase(ctx) if err != nil { return false, err } var count int if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?`, database, table, column, ).Scan(&count); err != nil { return false, err } return count > 0, nil } func (r *Repository) indexExists(ctx context.Context, table string, index string) (bool, error) { database, err := r.currentDatabase(ctx) if err != nil { return false, err } var count int if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?`, database, table, index, ).Scan(&count); err != nil { return false, err } return count > 0, nil } func (r *Repository) currentDatabase(ctx context.Context) (string, error) { var database sql.NullString if err := r.db.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&database); err != nil { return "", err } if !database.Valid || strings.TrimSpace(database.String) == "" { return "", fmt.Errorf("mysql database is not selected") } return database.String, nil } // GetActivity 从 MySQL 活动配置表读取同步查询投影。 func (r *Repository) GetActivity(ctx context.Context, activityID string) (activitydomain.Activity, error) { if r == nil || r.db == nil { return activitydomain.Activity{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } row := r.db.QueryRowContext(ctx, `SELECT app_code, activity_id, status, updated_at_ms FROM activities WHERE app_code = ? AND activity_id = ?`, appcode.FromContext(ctx), activityID, ) var activity activitydomain.Activity var status string if err := row.Scan(&activity.AppCode, &activity.ActivityID, &status, &activity.UpdatedAtMs); err != nil { if errors.Is(err, sql.ErrNoRows) { // 未配置活动时返回 NotFound,上层可决定是否降级为 inactive。 return activitydomain.Activity{}, xerr.New(xerr.NotFound, "activity not found") } return activitydomain.Activity{}, err } activity.Status = activitydomain.Status(status) return activity, nil }