// Package mysql 实现 user-service 的 MySQL 持久化边界。 package mysql import ( "context" "database/sql" "strings" "time" "hyapp/pkg/xerr" ) // Repository 是 user-service 的 MySQL 持久化入口。 // 方法按领域拆到 user_repository、identity_repository 和 auth_repository,避免一个文件混合多条业务线。 type Repository struct { // db 是标准库连接池,事务和普通查询都从这里创建。 db *sql.DB } // Open 创建 MySQL 连接池并做启动 ping。 func Open(ctx context.Context, dsn string) (*Repository, error) { if strings.TrimSpace(dsn) == "" { // 本地和线上运行都必须显式提供 DSN;空 DSN 代表应用装配错误。 return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required") } // sql.Open 只创建连接池,真实连通性由 PingContext 验证。 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 { // 启动探测失败时关闭连接池,避免返回半初始化 repository。 _ = db.Close() return nil, err } return &Repository{db: db}, nil } // Migrate 补齐允许服务启动时在线迁移的向后兼容字段和索引。 func (r *Repository) Migrate(ctx context.Context) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } // app_version/build_number 必须先于新版登录审计 INSERT 落地;逐列探测使旧库可滚动升级, // 同时避免对已经执行 initdb 或离线 migration 的数据库重复 ALTER。 if err := r.ensureColumn(ctx, "login_audit", "app_version", "app_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '本次认证客户端版本' AFTER platform"); err != nil { return err } if err := r.ensureColumn(ctx, "login_audit", "build_number", "build_number VARCHAR(64) NOT NULL DEFAULT '' COMMENT '本次认证客户端构建号' AFTER app_version"); err != nil { return err } // Dashboard 必须先按用户分区,再按 created_at_ms/id 取最近成功登录。校验完整列序而不只检查索引名, // 防止历史环境中同名但列定义不完整的索引让查询退化成全表排序。 return r.ensureIndexDefinition(ctx, "login_audit", "idx_login_audit_latest_success", []string{"app_code", "user_id", "result", "blocked", "login_type", "created_at_ms", "id"}, "ALTER TABLE login_audit ADD INDEX idx_login_audit_latest_success (app_code, user_id, result, blocked, login_type, created_at_ms, id)", ) } func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error { var count int if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`, tableName, columnName, ).Scan(&count); err != nil { return err } if count > 0 { return nil } // tableName/columnDDL 只来自上方固定常量,不接受外部输入;这里不能用占位符绑定 DDL 标识符。 _, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` ADD COLUMN `+columnDDL) return err } func (r *Repository) ensureIndexDefinition(ctx context.Context, tableName string, indexName string, columns []string, createDDL string) error { rows, err := r.db.QueryContext(ctx, `SELECT COLUMN_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? ORDER BY SEQ_IN_INDEX`, tableName, indexName, ) if err != nil { return err } defer rows.Close() existing := make([]string, 0, len(columns)) for rows.Next() { var column string if err := rows.Scan(&column); err != nil { return err } existing = append(existing, column) } if err := rows.Err(); err != nil { return err } if equalStringSlices(existing, columns) { return nil } if len(existing) > 0 { // 同名索引定义不一致时必须先移除,否则 ADD INDEX 会以 duplicate key name 失败。 if _, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` DROP INDEX `+indexName); err != nil { return err } } _, err = r.db.ExecContext(ctx, createDDL) return err } func equalStringSlices(left []string, right []string) bool { if len(left) != len(right) { return false } for index := range left { if left[index] != right[index] { return false } } return true } // Close 释放 MySQL 连接池。 func (r *Repository) Close() error { if r == nil || r.db == nil { // Close 允许重复调用和 nil 调用,方便 App.Close 幂等释放。 return nil } return r.db.Close() } // Ping 验证 MySQL 当前可用。 func (r *Repository) Ping(ctx context.Context) error { if r == nil || r.db == nil { // 没有连接池时明确返回 unavailable。 return xerr.New(xerr.Unavailable, "mysql repository is not configured") } return r.db.PingContext(ctx) }