2026-07-16 18:51:58 +08:00

288 lines
11 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 实现 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_sessionsfamily 索引仍只允许由离线 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)
}