package mysql import ( "context" "database/sql" "errors" "fmt" "strings" "time" _ "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 } // 标准库默认不限制连接数,高并发下会打爆 MySQL max_connections,必须显式设上限。 db.SetMaxOpenConns(20) db.SetMaxIdleConns(10) db.SetConnMaxLifetime(30 * time.Minute) 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.ensureActivityOutboxDeliveryColumns(ctx); err != nil { return err } if err := r.ensureActivityOutboxDeliveryIndexes(ctx); err != nil { return err } if err := r.ensureBroadcastOutboxIndexes(ctx); err != nil { return err } if err := r.ensureWeeklyStarTables(ctx); err != nil { return err } if err := r.ensureAgencyOpeningTables(ctx); err != nil { return err } if err := r.ensureWheelTables(ctx); err != nil { return err } if err := r.ensureRoomTurnoverRewardTables(ctx); err != nil { return err } if err := r.ensureCumulativeRechargeRewardTables(ctx); err != nil { return err } if err := r.ensureInviteActivityRewardTables(ctx); err != nil { return err } if err := r.ensureCPWeeklyRankTables(ctx); err != nil { return err } if err := r.ensureTaskDefinitionMetadataColumns(ctx); err != nil { return err } if err := r.ensureTaskRewardPolicyTables(ctx); err != nil { return err } return nil } func (r *Repository) ensureTaskRewardPolicyTables(ctx context.Context) error { _, err := r.db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS task_reward_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 '模板版本快照', status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '任务默认奖励资产:COIN/POINT', rule_json JSON NOT NULL COMMENT '完整策略快照', published_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布管理员 ID', published_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发布时间,UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, instance_code), KEY idx_task_reward_policy_active (app_code, status, published_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务奖励运行侧策略实例表'`) return err } func (r *Repository) ensureTaskDefinitionMetadataColumns(ctx context.Context) error { // 本地和测试环境会走自动迁移,逐列检查避免旧库重复启动时报 duplicate column;线上仍可用 deploy/mysql/migrations 的显式 SQL。 additions := []struct { column string sql string }{ {"audience_type", `ALTER TABLE task_definitions ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT 'all' COMMENT '适用人群:all/newbie' AFTER description`}, {"icon_key", `ALTER TABLE task_definitions ADD COLUMN icon_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'App 任务图标键' AFTER audience_type`}, {"icon_url", `ALTER TABLE task_definitions ADD COLUMN icon_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'App 任务图标 URL' AFTER icon_key`}, {"action_type", `ALTER TABLE task_definitions ADD COLUMN action_type VARCHAR(64) NOT NULL DEFAULT 'none' COMMENT 'App 跳转类型' AFTER icon_url`}, {"action_param", `ALTER TABLE task_definitions ADD COLUMN action_param VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'App 跳转参数' AFTER action_type`}, {"action_payload_json", `ALTER TABLE task_definitions ADD COLUMN action_payload_json JSON NULL COMMENT 'App 跳转扩展 JSON' AFTER action_param`}, {"dimension_filter_json", `ALTER TABLE task_definitions ADD COLUMN dimension_filter_json JSON NULL COMMENT '任务事件维度过滤 JSON' AFTER action_payload_json`}, {"reward_asset_type", `ALTER TABLE task_definitions ADD COLUMN reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '奖励资产类型:COIN/POINT' AFTER reward_coin_amount`}, } for _, addition := range additions { exists, err := r.columnExists(ctx, "task_definitions", addition.column) if err != nil { return err } if !exists { // 任务定义新增的是展示、跳转和事件过滤元数据,默认值保证旧任务无需人工补字段即可继续查询和领取。 if _, err := r.db.ExecContext(ctx, addition.sql); err != nil { return err } } } progressAdditions := []struct { column string sql string }{ {"reward_asset_type", `ALTER TABLE user_task_progress ADD COLUMN reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '奖励资产类型快照:COIN/POINT' AFTER reward_coin_amount`}, } for _, addition := range progressAdditions { exists, err := r.columnExists(ctx, "user_task_progress", addition.column) if err != nil { return err } if !exists { if _, err := r.db.ExecContext(ctx, addition.sql); err != nil { return err } } } claimAdditions := []struct { column string sql string }{ {"reward_asset_type", `ALTER TABLE task_reward_claims ADD COLUMN reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '奖励资产类型快照:COIN/POINT' AFTER reward_coin_amount`}, } for _, addition := range claimAdditions { exists, err := r.columnExists(ctx, "task_reward_claims", addition.column) if err != nil { return err } if !exists { if _, err := r.db.ExecContext(ctx, addition.sql); err != nil { return err } } } eventAdditions := []struct { column string sql string }{ {"metric_type", `ALTER TABLE task_event_consumption ADD COLUMN metric_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '任务指标类型' AFTER status`}, {"value_delta", `ALTER TABLE task_event_consumption ADD COLUMN value_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次事件进度增量' AFTER metric_type`}, {"dimensions_json", `ALTER TABLE task_event_consumption ADD COLUMN dimensions_json JSON NULL COMMENT '事件维度快照 JSON' AFTER value_delta`}, } for _, addition := range eventAdditions { exists, err := r.columnExists(ctx, "task_event_consumption", addition.column) if err != nil { return err } if !exists { // 事件消费表补充 metric/value/dimensions 快照,主要用于排查“事件到了但任务没涨”的过滤原因。 if _, err := r.db.ExecContext(ctx, addition.sql); 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) ensureActivityOutboxDeliveryColumns(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) ensureActivityOutboxDeliveryIndexes(ctx context.Context) error { // activity_outbox 仍服务 activity-service 自己的异步事件;索引按 event_type 收敛,避免不同活动补偿 worker 互相扫全表。 additions := []struct { name string sql string }{ {"idx_activity_outbox_event_retry", `CREATE INDEX idx_activity_outbox_event_retry ON activity_outbox (app_code, event_type, status, next_retry_at_ms, created_at_ms, outbox_id)`}, {"idx_activity_outbox_event_lock", `CREATE INDEX idx_activity_outbox_event_lock ON activity_outbox (app_code, event_type, status, lock_until_ms, created_at_ms, outbox_id)`}, {"idx_activity_outbox_retention", `CREATE INDEX idx_activity_outbox_retention ON activity_outbox (app_code, status, updated_at_ms, outbox_id)`}, } for _, addition := range additions { exists, err := r.indexExists(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) ensureBroadcastOutboxIndexes(ctx context.Context) error { additions := []struct { name string statement string }{ {"idx_im_broadcast_outbox_retention", `CREATE INDEX idx_im_broadcast_outbox_retention ON im_broadcast_outbox (app_code, status, updated_at_ms, event_id)`}, } for _, addition := range additions { exists, err := r.indexExists(ctx, "im_broadcast_outbox", addition.name) if err != nil { return err } if !exists { if _, err := r.db.ExecContext(ctx, addition.statement); err != nil { return err } } } return nil } 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 }