288 lines
11 KiB
Go
288 lines
11 KiB
Go
// Package mysql 实现 user-service 的 MySQL 持久化边界。
|
||
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"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
|
||
}
|
||
// auth_sessions 是热表;只允许 INSTANT 常量默认列在启动迁移中执行,绝不在进程启动时隐式 COPY 大表或构建 family 索引。
|
||
instantSessionColumns := []struct {
|
||
name string
|
||
ddl string
|
||
}{
|
||
{name: "token_family_id", ddl: "token_family_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '令牌族根 session ID'"},
|
||
{name: "generation", ddl: "generation BIGINT NOT NULL DEFAULT 0 COMMENT '令牌族内单调代际,首代为 0'"},
|
||
{name: "parent_session_id", ddl: "parent_session_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '直接父代 session ID'"},
|
||
{name: "rotated_to_session_id", ddl: "rotated_to_session_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '轮换产生的直接子代 session ID'"},
|
||
{name: "rotation_at_ms", ddl: "rotation_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '轮换时间,UTC epoch ms'"},
|
||
{name: "rotation_request_id", ddl: "rotation_request_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '首次轮换 refresh 幂等键'"},
|
||
}
|
||
for _, column := range instantSessionColumns {
|
||
if err := r.ensureInstantColumn(ctx, "auth_sessions", column.name, column.ddl); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// family 索引会扫描登录热表,只能由离线 018 在低峰构建;缺失/同名错列时启动直接失败,不能静默全表查询。
|
||
if err := r.requireIndexDefinition(ctx, "auth_sessions", "idx_auth_sessions_token_family", []string{"app_code", "token_family_id", "generation"}, false); err != nil {
|
||
return err
|
||
}
|
||
// 独立小表创建不扫描 auth_sessions;family 索引仍只允许由离线 018 migration 在低峰用 INPLACE/LOCK=NONE 创建。
|
||
if _, err := r.db.ExecContext(ctx, `
|
||
CREATE TABLE IF NOT EXISTS auth_refresh_outcomes (
|
||
app_code VARCHAR(32) NOT NULL,
|
||
parent_session_id VARCHAR(96) NOT NULL,
|
||
child_session_id VARCHAR(96) NOT NULL,
|
||
refresh_request_id VARCHAR(96) NOT NULL,
|
||
token_ciphertext VARBINARY(4096) NOT NULL,
|
||
created_at_ms BIGINT NOT NULL,
|
||
expires_at_ms BIGINT NOT NULL,
|
||
PRIMARY KEY (app_code, parent_session_id),
|
||
KEY idx_auth_refresh_outcomes_request (app_code, refresh_request_id),
|
||
KEY idx_auth_refresh_outcomes_expires (expires_at_ms)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||
`); err != nil {
|
||
return err
|
||
}
|
||
if _, err := r.db.ExecContext(ctx, `
|
||
CREATE TABLE IF NOT EXISTS auth_session_denylist_jobs (
|
||
job_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||
app_code VARCHAR(32) NOT NULL,
|
||
session_id VARCHAR(96) NOT NULL,
|
||
reason VARCHAR(64) NOT NULL,
|
||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||
attempts INT NOT NULL DEFAULT 0,
|
||
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
|
||
locked_by VARCHAR(96) NOT NULL DEFAULT '',
|
||
locked_until_ms BIGINT NOT NULL DEFAULT 0,
|
||
last_error VARCHAR(512) NOT NULL DEFAULT '',
|
||
deny_until_ms BIGINT NOT NULL DEFAULT 0,
|
||
created_at_ms BIGINT NOT NULL,
|
||
updated_at_ms BIGINT NOT NULL,
|
||
UNIQUE KEY uk_auth_session_denylist_job (app_code, session_id),
|
||
KEY idx_auth_session_denylist_pending (status, next_retry_at_ms, job_id),
|
||
KEY idx_auth_session_denylist_cleanup (deny_until_ms, job_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||
`); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureInstantColumn(ctx, "auth_session_denylist_jobs", "deny_until_ms", "deny_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '历史 access JWT 最晚拒绝时刻(含安全余量),UTC epoch ms'"); err != nil {
|
||
return err
|
||
}
|
||
// 从旧三列 unique 收敛到每 sid 一行前必须先离线去重;启动时不能先 DROP 后因重复 ADD 失败而留下无唯一约束表。
|
||
if err := r.requireIndexDefinition(ctx, "auth_session_denylist_jobs", "uk_auth_session_denylist_job", []string{"app_code", "session_id"}, true); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureIndexDefinition(ctx,
|
||
"auth_session_denylist_jobs",
|
||
"idx_auth_session_denylist_cleanup",
|
||
[]string{"deny_until_ms", "job_id"},
|
||
"ALTER TABLE auth_session_denylist_jobs ADD INDEX idx_auth_session_denylist_cleanup (deny_until_ms, job_id)",
|
||
); 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) requireIndexDefinition(ctx context.Context, tableName string, indexName string, columns []string, unique bool) error {
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT COLUMN_NAME, NON_UNIQUE
|
||
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))
|
||
nonUnique := -1
|
||
for rows.Next() {
|
||
var column string
|
||
var rowNonUnique int
|
||
if err := rows.Scan(&column, &rowNonUnique); err != nil {
|
||
return err
|
||
}
|
||
if nonUnique == -1 {
|
||
nonUnique = rowNonUnique
|
||
} else if nonUnique != rowNonUnique {
|
||
return fmt.Errorf("required index %s.%s has inconsistent NON_UNIQUE metadata", tableName, indexName)
|
||
}
|
||
existing = append(existing, column)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
wantNonUnique := 1
|
||
if unique {
|
||
wantNonUnique = 0
|
||
}
|
||
if !equalStringSlices(existing, columns) || nonUnique != wantNonUnique {
|
||
return fmt.Errorf("required index %s.%s has columns %v and NON_UNIQUE=%d, want columns %v and NON_UNIQUE=%d; apply migration 018 before startup", tableName, indexName, existing, nonUnique, columns, wantNonUnique)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) ensureInstantColumn(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
|
||
}
|
||
// 固定 DDL 若不支持 INSTANT 会直接失败,让操作者改走低峰 Online DDL,而不是启动时静默全表重建。
|
||
// MySQL 8.4 的 INSTANT 算法不接受显式 LOCK 子句;只指定算法仍保证不退化成 COPY/INPLACE。
|
||
_, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` ADD COLUMN `+columnDDL+`, ALGORITHM=INSTANT`)
|
||
return err
|
||
}
|
||
|
||
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)
|
||
}
|