333 lines
12 KiB
Go
333 lines
12 KiB
Go
// Admin 网页用户与 Session 的数据库读写。
|
|
package repository
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"cmautobuy/admin/model"
|
|
"github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
var (
|
|
// ErrUsersAlreadyExist 表示首次初始化入口已经永久关闭。
|
|
ErrUsersAlreadyExist = errors.New("管理员已经初始化")
|
|
ErrUserNotFound = errors.New("用户不存在")
|
|
ErrSessionNotFound = errors.New("网页登录会话不存在")
|
|
ErrUsernameExists = errors.New("用户名已经存在")
|
|
ErrLastActiveAdmin = errors.New("不能禁用最后一个有效管理员")
|
|
ErrUserPasswordChanged = errors.New("用户密码已经变更")
|
|
)
|
|
|
|
// CountUsers 返回所有用户数量,disabled 也算。即使所有账号被禁用,
|
|
// 首次初始化入口也不能重新开放。
|
|
func CountUsers(q Execer) (int, error) {
|
|
var count int
|
|
if err := q.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
|
return 0, fmt.Errorf("统计 Admin 用户失败: %w", err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// ChangePasswordAndRevokeSessions 只在旧哈希仍匹配时更新密码,并在同一事务中
|
|
// 撤销该用户的全部 Session。旧哈希条件用于阻止两个并发改密请求互相覆盖。
|
|
func ChangePasswordAndRevokeSessions(db *sql.DB, userID, expectedHash, passwordHash, updatedAt string) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("开始修改密码事务失败: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
result, err := tx.Exec(`
|
|
UPDATE users
|
|
SET password_hash = ?, password_changed_at = ?, updated_at = ?
|
|
WHERE user_id = ? AND password_hash = ? AND role = 'admin' AND status = 'active'`,
|
|
passwordHash, updatedAt, updatedAt, userID, expectedHash)
|
|
if err != nil {
|
|
return fmt.Errorf("修改管理员密码失败: %w", err)
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("确认修改密码结果失败: %w", err)
|
|
}
|
|
if affected == 0 {
|
|
return ErrUserPasswordChanged
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM web_sessions WHERE user_id = ?`, userID); err != nil {
|
|
return fmt.Errorf("撤销管理员 Session 失败: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("提交修改密码事务失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateInitialAdmin 锁住唯一的初始化哨兵行,再确认 users 为空并创建首位管理员。
|
|
// 并发请求会在 FOR UPDATE 处串行,最多一个能成功。
|
|
func CreateInitialAdmin(db *sql.DB, user model.User) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("开始初始化管理员事务失败: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
var lockID int
|
|
if err := tx.QueryRow(`SELECT id FROM admin_initialization_lock WHERE id = 1 FOR UPDATE`).Scan(&lockID); err != nil {
|
|
return fmt.Errorf("锁定管理员初始化状态失败: %w", err)
|
|
}
|
|
|
|
count, err := CountUsers(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count != 0 {
|
|
return ErrUsersAlreadyExist
|
|
}
|
|
|
|
_, err = tx.Exec(`
|
|
INSERT INTO users
|
|
(user_id, username, password_hash, role, status, last_login_at,
|
|
password_changed_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`,
|
|
user.UserID, user.Username, user.PasswordHash, user.Role, user.Status,
|
|
user.PasswordChangedAt, user.CreatedAt, user.UpdatedAt)
|
|
if err != nil {
|
|
return fmt.Errorf("创建首位管理员失败: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("提交初始化管理员事务失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindUserByUsername 使用 users.username 的 NOCASE 规则查账号。
|
|
func FindUserByUsername(q Execer, username string) (*model.User, error) {
|
|
var user model.User
|
|
var lastLogin sql.NullString
|
|
err := q.QueryRow(`
|
|
SELECT user_id, username, password_hash, role, status, last_login_at,
|
|
password_changed_at, created_at, updated_at
|
|
FROM users WHERE username = ?`, username,
|
|
).Scan(&user.UserID, &user.Username, &user.PasswordHash, &user.Role, &user.Status,
|
|
&lastLogin, &user.PasswordChangedAt, &user.CreatedAt, &user.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("查询 Admin 用户失败: %w", err)
|
|
}
|
|
user.LastLoginAt = lastLogin.String
|
|
return &user, nil
|
|
}
|
|
|
|
// CreateLoginSession 原子写入新 Session 并记录最后登录时间。
|
|
func CreateLoginSession(db *sql.DB, session model.WebSession, loginAt string) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("开始登录事务失败: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.Exec(`
|
|
INSERT INTO web_sessions
|
|
(session_hash, user_id, expires_at, created_at, last_seen_at)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
session.SessionHash, session.UserID, session.ExpiresAt,
|
|
session.CreatedAt, session.LastSeenAt); err != nil {
|
|
return fmt.Errorf("保存 Web Session 失败: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`
|
|
UPDATE users SET last_login_at = ?, updated_at = ? WHERE user_id = ?`,
|
|
loginAt, loginAt, session.UserID); err != nil {
|
|
return fmt.Errorf("更新用户最后登录时间失败: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("提交登录事务失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindUserBySessionHash 返回 Session 及其用户。账号状态由 service 判断,
|
|
// 这样“禁用账号立即失效”不依赖额外清理是否及时。
|
|
func FindUserBySessionHash(q Execer, sessionHash string) (*model.WebSession, *model.User, error) {
|
|
var session model.WebSession
|
|
var user model.User
|
|
var lastLogin sql.NullString
|
|
err := q.QueryRow(`
|
|
SELECT s.session_hash, s.user_id, s.expires_at, s.created_at, s.last_seen_at,
|
|
u.user_id, u.username, u.password_hash, u.role, u.status,
|
|
u.last_login_at, u.password_changed_at, u.created_at, u.updated_at
|
|
FROM web_sessions s
|
|
JOIN users u ON u.user_id = s.user_id
|
|
WHERE s.session_hash = ?`, sessionHash,
|
|
).Scan(&session.SessionHash, &session.UserID, &session.ExpiresAt,
|
|
&session.CreatedAt, &session.LastSeenAt,
|
|
&user.UserID, &user.Username, &user.PasswordHash, &user.Role, &user.Status,
|
|
&lastLogin, &user.PasswordChangedAt, &user.CreatedAt, &user.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, ErrSessionNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("查询 Web Session 失败: %w", err)
|
|
}
|
|
user.LastLoginAt = lastLogin.String
|
|
return &session, &user, nil
|
|
}
|
|
|
|
// DeleteSession 删除当前浏览器对应的 Session。不存在也视为成功,退出幂等。
|
|
func DeleteSession(q Execer, sessionHash string) error {
|
|
if _, err := q.Exec(`DELETE FROM web_sessions WHERE session_hash = ?`, sessionHash); err != nil {
|
|
return fmt.Errorf("删除 Web Session 失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteExpiredSessions 清理固定过期的 Session,不需要后台常驻线程。
|
|
func DeleteExpiredSessions(q Execer, nowISO string) error {
|
|
if _, err := q.Exec(`DELETE FROM web_sessions WHERE expires_at <= ?`, nowISO); err != nil {
|
|
return fmt.Errorf("清理过期 Web Session 失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListUsers 按用户名搜索并分页。列表和 COUNT 共用同一段筛选,避免页码漂移。
|
|
func ListUsers(q Execer, keyword string, status model.UserStatus, limit, offset int) ([]model.User, int, error) {
|
|
pattern := "%" + escapeLike(keyword) + "%"
|
|
where := `WHERE username LIKE ? ESCAPE '!'`
|
|
args := []any{pattern}
|
|
if status == model.UserActive || status == model.UserDisabled {
|
|
where += ` AND status = ?`
|
|
args = append(args, status)
|
|
}
|
|
var total int
|
|
if err := q.QueryRow(`SELECT COUNT(*) FROM users `+where, args...).Scan(&total); err != nil {
|
|
return nil, 0, fmt.Errorf("统计 Admin 用户失败: %w", err)
|
|
}
|
|
listArgs := append(append([]any{}, args...), limit, offset)
|
|
rows, err := q.Query(`
|
|
SELECT user_id, username, role, status, last_login_at,
|
|
password_changed_at, created_at, updated_at
|
|
FROM users `+where+`
|
|
ORDER BY created_at DESC, username ASC
|
|
LIMIT ? OFFSET ?`, listArgs...)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("查询 Admin 用户列表失败: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
users := make([]model.User, 0)
|
|
for rows.Next() {
|
|
var user model.User
|
|
var lastLogin sql.NullString
|
|
if err := rows.Scan(&user.UserID, &user.Username, &user.Role, &user.Status,
|
|
&lastLogin, &user.PasswordChangedAt, &user.CreatedAt, &user.UpdatedAt); err != nil {
|
|
return nil, 0, fmt.Errorf("读取 Admin 用户列表失败: %w", err)
|
|
}
|
|
user.LastLoginAt = lastLogin.String
|
|
users = append(users, user)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, 0, fmt.Errorf("遍历 Admin 用户列表失败: %w", err)
|
|
}
|
|
return users, total, nil
|
|
}
|
|
|
|
// CreateUser 创建一个已经完成密码哈希的账号。用户名由数据库 NOCASE 唯一约束
|
|
// 做最终并发保护。
|
|
func CreateUser(q Execer, user model.User) error {
|
|
_, err := q.Exec(`
|
|
INSERT INTO users
|
|
(user_id, username, password_hash, role, status, last_login_at,
|
|
password_changed_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`,
|
|
user.UserID, user.Username, user.PasswordHash, user.Role, user.Status,
|
|
user.PasswordChangedAt, user.CreatedAt, user.UpdatedAt)
|
|
if err != nil {
|
|
var mysqlErr *mysql.MySQLError
|
|
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
|
return ErrUsernameExists
|
|
}
|
|
return fmt.Errorf("创建 Admin 用户失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetUserStatusAndRevokeSessions 在同一写事务里修改状态并撤销全部 Session。
|
|
// 禁用管理员前会在事务内保护最后一个有效管理员。
|
|
func SetUserStatusAndRevokeSessions(db *sql.DB, userID string, status model.UserStatus, updatedAt string) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("开始修改用户状态事务失败: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var role model.UserRole
|
|
var current model.UserStatus
|
|
if err := tx.QueryRow(`SELECT role, status FROM users WHERE user_id = ? FOR UPDATE`, userID).Scan(&role, ¤t); errors.Is(err, sql.ErrNoRows) {
|
|
return ErrUserNotFound
|
|
} else if err != nil {
|
|
return fmt.Errorf("读取待修改用户失败: %w", err)
|
|
}
|
|
if status == model.UserDisabled && current == model.UserActive && role == model.RoleAdmin {
|
|
rows, err := tx.Query(`SELECT user_id FROM users WHERE role = 'admin' AND status = 'active' FOR UPDATE`)
|
|
if err != nil {
|
|
return fmt.Errorf("锁定有效管理员失败: %w", err)
|
|
}
|
|
activeAdmins := 0
|
|
for rows.Next() {
|
|
var activeID string
|
|
if err := rows.Scan(&activeID); err != nil {
|
|
rows.Close()
|
|
return fmt.Errorf("读取有效管理员失败: %w", err)
|
|
}
|
|
activeAdmins++
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return fmt.Errorf("读取有效管理员失败: %w", err)
|
|
}
|
|
if activeAdmins <= 1 {
|
|
return ErrLastActiveAdmin
|
|
}
|
|
}
|
|
if _, err := tx.Exec(`UPDATE users SET status = ?, updated_at = ? WHERE user_id = ?`, status, updatedAt, userID); err != nil {
|
|
return fmt.Errorf("修改用户状态失败: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM web_sessions WHERE user_id = ?`, userID); err != nil {
|
|
return fmt.Errorf("撤销用户 Session 失败: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("提交用户状态事务失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResetUserPasswordAndRevokeSessions 原子更新密码哈希并撤销该用户所有 Session。
|
|
func ResetUserPasswordAndRevokeSessions(db *sql.DB, userID, passwordHash, updatedAt string) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("开始重置密码事务失败: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
result, err := tx.Exec(`
|
|
UPDATE users
|
|
SET password_hash = ?, password_changed_at = ?, updated_at = ?
|
|
WHERE user_id = ?`, passwordHash, updatedAt, updatedAt, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("重置用户密码失败: %w", err)
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("确认重置密码结果失败: %w", err)
|
|
}
|
|
if affected == 0 {
|
|
return ErrUserNotFound
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM web_sessions WHERE user_id = ?`, userID); err != nil {
|
|
return fmt.Errorf("撤销用户 Session 失败: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("提交重置密码事务失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|