325 lines
9.6 KiB
Go
325 lines
9.6 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/auth"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
UserRoleSuperAdmin = "super_admin"
|
|
UserRoleAdmin = "admin"
|
|
UserRoleUser = "user"
|
|
UserStatusActive = "active"
|
|
UserStatusDisabled = "disabled"
|
|
)
|
|
|
|
var ErrSuperAdminProtected = errors.New("超级管理员不能删除")
|
|
|
|
// User 表示一个可登录的管理后台用户。密码摘要不会被 JSON 输出。
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
PasswordHash string `json:"-"`
|
|
Role string `json:"role"`
|
|
Groups []string `json:"groups,omitempty"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type UserRepository interface {
|
|
EnsureSuperAdmin(context.Context, User) error
|
|
FindByIdentifier(context.Context, string) (User, error)
|
|
ListUsers(context.Context) ([]User, error)
|
|
GetUserByID(context.Context, string) (User, error)
|
|
CreateUser(context.Context, User) error
|
|
UpdateUser(context.Context, User) error
|
|
DeleteUser(context.Context, string) error
|
|
}
|
|
|
|
type SQLUserRepository struct {
|
|
db *sql.DB
|
|
driver string
|
|
}
|
|
|
|
func NewSQLUserRepository(db *sql.DB, driver ...string) *SQLUserRepository {
|
|
selectedDriver := "sqlite"
|
|
if len(driver) > 0 && strings.TrimSpace(driver[0]) != "" {
|
|
selectedDriver = strings.ToLower(strings.TrimSpace(driver[0]))
|
|
}
|
|
return &SQLUserRepository{db: db, driver: selectedDriver}
|
|
}
|
|
|
|
func (r *SQLUserRepository) EnsureSuperAdmin(ctx context.Context, item User) error {
|
|
if err := r.ensureDB(); err != nil {
|
|
return err
|
|
}
|
|
item.Role = UserRoleSuperAdmin
|
|
item.Status = UserStatusActive
|
|
item = normalizeUser(item)
|
|
if item.ID == "" {
|
|
item.ID = "primary-super-admin"
|
|
}
|
|
if err := validateUser(item, false); err != nil {
|
|
return err
|
|
}
|
|
if err := r.upsert(ctx, item); err != nil {
|
|
return fmt.Errorf("同步超级管理员失败:%w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *SQLUserRepository) FindByIdentifier(ctx context.Context, identifier string) (User, error) {
|
|
if err := r.ensureDB(); err != nil {
|
|
return User{}, err
|
|
}
|
|
normalized, err := auth.NormalizeIdentifier(identifier)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
row := r.db.QueryRowContext(ctx, r.query(userSelect+` WHERE LOWER(username) = ? OR (email IS NOT NULL AND LOWER(email) = ?) LIMIT 1`), normalized, normalized)
|
|
return scanUser(row)
|
|
}
|
|
|
|
func (r *SQLUserRepository) ListUsers(ctx context.Context) ([]User, error) {
|
|
if err := r.ensureDB(); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := r.db.QueryContext(ctx, r.query(userSelect+` ORDER BY role, username, id`))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("查询用户失败:%w", err)
|
|
}
|
|
defer rows.Close()
|
|
items := make([]User, 0)
|
|
for rows.Next() {
|
|
item, err := scanUser(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("读取用户失败:%w", err)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (r *SQLUserRepository) GetUserByID(ctx context.Context, id string) (User, error) {
|
|
if err := r.ensureDB(); err != nil {
|
|
return User{}, err
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return User{}, errors.New("用户 ID 不能为空")
|
|
}
|
|
return scanUser(r.db.QueryRowContext(ctx, r.query(userSelect+` WHERE id = ?`), id))
|
|
}
|
|
|
|
func (r *SQLUserRepository) CreateUser(ctx context.Context, item User) error {
|
|
if err := r.ensureDB(); err != nil {
|
|
return err
|
|
}
|
|
item = normalizeUser(item)
|
|
if err := validateUser(item, false); err != nil {
|
|
return err
|
|
}
|
|
groups, err := encodeUserGroups(item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item.CreatedAt.IsZero() {
|
|
item.CreatedAt = time.Now().UTC()
|
|
}
|
|
if item.UpdatedAt.IsZero() {
|
|
item.UpdatedAt = item.CreatedAt
|
|
}
|
|
_, err = r.db.ExecContext(ctx, r.query(`INSERT INTO users
|
|
(id, username, email, password_hash, role, status, created_at, updated_at, groups_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Username, r.emailValue(item.Email), item.PasswordHash,
|
|
item.Role, item.Status, item.CreatedAt, item.UpdatedAt, groups)
|
|
if err != nil {
|
|
return fmt.Errorf("保存用户失败:%w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *SQLUserRepository) UpdateUser(ctx context.Context, item User) error {
|
|
if err := r.ensureDB(); err != nil {
|
|
return err
|
|
}
|
|
item = normalizeUser(item)
|
|
if err := validateUser(item, false); err != nil {
|
|
return err
|
|
}
|
|
groups, err := encodeUserGroups(item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result, err := r.db.ExecContext(ctx, r.query(`UPDATE users SET username = ?, email = ?, password_hash = ?, role = ?, status = ?, updated_at = ?, groups_json = ? WHERE id = ?`),
|
|
item.Username, r.emailValue(item.Email), item.PasswordHash, item.Role, item.Status, time.Now().UTC(), groups, item.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("更新用户失败:%w", err)
|
|
}
|
|
count, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("读取用户更新结果失败:%w", err)
|
|
}
|
|
if count == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *SQLUserRepository) DeleteUser(ctx context.Context, id string) error {
|
|
if err := r.ensureDB(); err != nil {
|
|
return err
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return errors.New("用户 ID 不能为空")
|
|
}
|
|
if id == "primary-super-admin" {
|
|
return ErrSuperAdminProtected
|
|
}
|
|
result, err := r.db.ExecContext(ctx, r.query(`DELETE FROM users WHERE id = ?`), id)
|
|
if err != nil {
|
|
return fmt.Errorf("删除用户失败:%w", err)
|
|
}
|
|
count, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("读取用户删除结果失败:%w", err)
|
|
}
|
|
if count == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *SQLUserRepository) ensureDB() error {
|
|
if r == nil || r.db == nil {
|
|
return errors.New("数据库连接不能为空")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *SQLUserRepository) upsert(ctx context.Context, item User) error {
|
|
now := time.Now().UTC()
|
|
if item.CreatedAt.IsZero() {
|
|
item.CreatedAt = now
|
|
}
|
|
item.UpdatedAt = now
|
|
result, err := r.db.ExecContext(ctx, r.query(`UPDATE users SET username = ?, email = ?, password_hash = ?, role = ?, status = ?, updated_at = ? WHERE id = ?`),
|
|
item.Username, r.emailValue(item.Email), item.PasswordHash, item.Role, item.Status, item.UpdatedAt, item.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
count, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
_, err = r.db.ExecContext(ctx, r.query(`INSERT INTO users
|
|
(id, username, email, password_hash, role, status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Username, r.emailValue(item.Email), item.PasswordHash,
|
|
item.Role, item.Status, item.CreatedAt, item.UpdatedAt)
|
|
return err
|
|
}
|
|
|
|
func (r *SQLUserRepository) emailValue(email string) any {
|
|
if email == "" && (r.driver == "mysql" || r.driver == "postgres") {
|
|
return nil
|
|
}
|
|
return email
|
|
}
|
|
|
|
func (r *SQLUserRepository) query(value string) string {
|
|
return Rebind(r.driver, value)
|
|
}
|
|
|
|
func normalizeUser(item User) User {
|
|
item.ID = strings.TrimSpace(item.ID)
|
|
item.Username = strings.TrimSpace(strings.ToLower(item.Username))
|
|
item.Email = strings.TrimSpace(strings.ToLower(item.Email))
|
|
item.Role = strings.TrimSpace(strings.ToLower(item.Role))
|
|
item.Status = strings.TrimSpace(strings.ToLower(item.Status))
|
|
return item
|
|
}
|
|
|
|
func validateUser(item User, allowEmptyID bool) error {
|
|
item = normalizeUser(item)
|
|
if item.ID == "" && !allowEmptyID {
|
|
return errors.New("用户 ID 不能为空")
|
|
}
|
|
if len([]byte(item.Username)) < 3 || len([]byte(item.Username)) > 64 {
|
|
return errors.New("用户名长度必须为 3 到 64 个字符")
|
|
}
|
|
if strings.Contains(item.Username, "@") {
|
|
return errors.New("用户名格式无效")
|
|
}
|
|
for _, char := range item.Username {
|
|
if unicode.IsSpace(char) || unicode.IsControl(char) {
|
|
return errors.New("用户名不能包含空格或控制字符")
|
|
}
|
|
}
|
|
switch item.Username {
|
|
case "admin", "administrator", "root", "system", "scert":
|
|
if item.Role != UserRoleSuperAdmin || item.Username != "admin" {
|
|
return errors.New("用户名为系统保留名称")
|
|
}
|
|
}
|
|
if item.Email != "" {
|
|
if _, err := auth.NormalizeIdentifier(item.Email); err != nil || !strings.Contains(item.Email, "@") {
|
|
return errors.New("邮箱格式无效")
|
|
}
|
|
}
|
|
if _, err := bcrypt.Cost([]byte(item.PasswordHash)); err != nil {
|
|
return errors.New("密码摘要无效")
|
|
}
|
|
if item.Role != UserRoleSuperAdmin && item.Role != UserRoleAdmin && item.Role != UserRoleUser {
|
|
return fmt.Errorf("用户角色无效:%s", item.Role)
|
|
}
|
|
if item.Status != UserStatusActive && item.Status != UserStatusDisabled {
|
|
return fmt.Errorf("用户状态无效:%s", item.Status)
|
|
}
|
|
_, err := NormalizeUserGroups(item.Role, item.Groups)
|
|
return err
|
|
}
|
|
|
|
const userSelect = `SELECT id, username, email, password_hash, role, status, created_at, updated_at, COALESCE(groups_json, '[]') FROM users`
|
|
|
|
func scanUser(row interface{ Scan(...any) error }) (User, error) {
|
|
var item User
|
|
var email sql.NullString
|
|
var groups string
|
|
if err := row.Scan(&item.ID, &item.Username, &email, &item.PasswordHash, &item.Role, &item.Status, &item.CreatedAt, &item.UpdatedAt, &groups); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return User{}, sql.ErrNoRows
|
|
}
|
|
return User{}, fmt.Errorf("读取用户失败:%w", err)
|
|
}
|
|
if email.Valid {
|
|
item.Email = email.String
|
|
}
|
|
if err := json.Unmarshal([]byte(groups), &item.Groups); err != nil {
|
|
return User{}, fmt.Errorf("读取用户分组失败:%w", err)
|
|
}
|
|
var err error
|
|
item.Groups, err = NormalizeUserGroups(item.Role, item.Groups)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
return normalizeUser(item), nil
|
|
}
|