2026-07-12 00:47:20 +08:00

156 lines
5.1 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"
"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)
}