Files
mce/internal/repository/user_repo.go
Victor_Jay 0800ee2f3e feat: 消息通知中心 + 账号安全体系 + Bug修复
## 新增功能

### 消息通知中心 (全新模块)
- 新增 MessageController / NotificationService / NotificationRepo
- SSR 消息页面 (/messages):左侧边栏 + 右侧卡片列表,noindex 元标签
- 通知能力:列表分页、单条标为已读、一键全部标为已读
- 未读数角标 (1/66/99+):侧边栏 + 导航栏铃铛图标
- 导航栏轮询 /api/messages/unread 每 60 秒刷新未读数
- 审核通过/驳回时自动 fire-and-forget 推送通知

### 密码修改
- ChangePassword:验证当前密码 → 新密码强度校验(8位+字母+数字) → 哈希更新
- 修改后递增 token_version 强制所有设备退登

### 账号自助注销
- DeleteAccount:验证密码 → 设置 deleted 状态 → 记录原因 → 吊销 JWT
- 注销后登录二次确认:Login 检测 deleted → 返回 confirm_restore
- ConfirmRestore 恢复账号,重新签发 token
- 注销页文案:"账号将在 7 天后正式注销,期间可随时重新登录恢复"

### IP 审计记录
- 注册时记录 RegIP,登录时记录 LastLoginIP + LastLoginAt
- clientIP() 支持 X-Forwarded-For / X-Real-IP 反向代理

### 安全加固
- Login 防时序攻击:用户不存在时仍执行完整 bcrypt 比对
- FindByEmail 改用 Unscoped() 覆盖软删除用户
- 站长 (owner) 不允许自主注销,避免权限体系死锁

## Bug 修复

1. 注销按钮不触发:JS IIFE 中 profile 代码 return 阻塞了 account 标签页处理器注册
   → 拆分为两层 IIFE,profile 放在内层
2. label 缺少 for 属性导致控制台警告 → 全部补充 for 属性
3. 注销后未自动退登:DeleteAccount 只设状态未吊销 JWT → 末尾加 InvalidateSessions
4. 退登后重定向 500 panic:authenticateToken 中 err||versionMismatch 合并判断
   在 err=nil 但版本不匹配时返回 (nil,nil) → 拆为两个独立判断
   injectUserContext 增加 claims==nil / 类型断言空安全守卫
5. 注销后登录直接提示"登录成功":FindByEmail 默认 scope 排除软删除记录
   → 改用 Unscoped()

## 文件变更
- 新建 17 个文件 (消息/审核/站点设置完整模块)
- 修改 25 个文件 (认证/设置/中间件/前端)
- 统计:+3229 / -161,42 files changed
2026-05-27 13:32:45 +08:00

179 lines
5.7 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 repository
import (
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// UserRepo 用户数据访问
type UserRepo struct {
db *gorm.DB
}
// NewUserRepo 构造函数
func NewUserRepo(db *gorm.DB) *UserRepo {
return &UserRepo{db: db}
}
// Create 创建用户
func (r *UserRepo) Create(user *model.User) error {
return r.db.Create(user).Error
}
// FindByEmail 按邮箱查找用户(含软删除,用于登录等需要找到已注销用户的场景)
func (r *UserRepo) FindByEmail(email string) (*model.User, error) {
var user model.User
err := r.db.Unscoped().Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
// FindByID 按 UID 查找用户
func (r *UserRepo) FindByID(id uint) (*model.User, error) {
var user model.User
err := r.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
// FindByUsername 按用户名查找
func (r *UserRepo) FindByUsername(username string) (*model.User, error) {
var user model.User
err := r.db.Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
// ExistsByEmail 检查邮箱是否已注册
func (r *UserRepo) ExistsByEmail(email string) (bool, error) {
var count int64
err := r.db.Model(&model.User{}).Where("email = ?", email).Count(&count).Error
return count > 0, err
}
// ExistsByUsername 检查用户名是否已存在
func (r *UserRepo) ExistsByUsername(username string) (bool, error) {
var count int64
err := r.db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error
return count > 0, err
}
// ExistsByEmailExclude 检查邮箱是否被其他 active 用户占用(解锁前冲突检查)
func (r *UserRepo) ExistsByEmailExclude(email string, excludeUID uint) (bool, error) {
var count int64
err := r.db.Model(&model.User{}).
Where("email = ? AND uid != ?", email, excludeUID).
Count(&count).Error
return count > 0, err
}
// IncrementTokenVersion 递增用户令牌版本,使所有已签发的 JWT 即时失效
func (r *UserRepo) IncrementTokenVersion(userID uint) error {
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", userID).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
}
// FindTokenVersion 按 UID 查询当前令牌版本(轻量查询,仅 SELECT token_version
func (r *UserRepo) FindTokenVersion(userID uint) (int, error) {
var version int
err := r.db.Model(&model.User{}).
Select("token_version").
Where("uid = ?", userID).
Scan(&version).Error
return version, err
}
// FindByIDForAuth 认证专用查询:返回 uid/email/username/role/status/token_version
// 比 FindByID 轻量(不查 avatar、bio 等无用字段),避免全量 SELECT *
// 使用默认 scope排除软删除locked 用户不可持有有效 JWT
func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
var user model.User
err := r.db.Select("uid", "email", "username", "role", "status", "token_version").
First(&user, userID).Error
if err != nil {
return nil, err
}
return &user, nil
}
// FindByIDUnscoped 管理后台专用:含软删除用户,用于解锁/封禁等操作
func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) {
var user model.User
err := r.db.Unscoped().Select("uid", "email", "username", "role", "status", "token_version", "deleted_at").
First(&user, userID).Error
if err != nil {
return nil, err
}
return &user, nil
}
// UpdateStatus 更新用户状态含软删除用户locked 状态变更需要)
func (r *UserRepo) UpdateStatus(uid uint, status string) error {
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("status", status).Error
}
// UpdateRole 更新用户角色(仅 owner 调用)
func (r *UserRepo) UpdateRole(uid uint, role string) error {
return r.db.Model(&model.User{}).Where("uid = ?", uid).Update("role", role).Error
}
// Update 全量更新用户信息
func (r *UserRepo) Update(user *model.User) error {
return r.db.Save(user).Error
}
// SoftDelete GORM 软删除(设 DeletedAt用于 locked 状态释放邮箱
func (r *UserRepo) SoftDelete(uid uint) error {
return r.db.Where("uid = ?", uid).Delete(&model.User{}).Error
}
// Restore 恢复软删除(清 DeletedAt
func (r *UserRepo) Restore(uid uint) error {
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("deleted_at", nil).Error
}
// buildSearchQuery 构建搜索/筛选的公共查询条件
func (r *UserRepo) buildSearchQuery(keyword, role, status string) *gorm.DB {
query := r.db.Unscoped().Model(&model.User{})
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("email LIKE ? OR username LIKE ?", like, like)
}
if role != "" {
query = query.Where("role = ?", role)
}
if status != "" {
query = query.Where("status = ?", status)
}
return query
}
// SearchUsers 综合搜索(含软删除用户)
func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) {
var users []model.User
err := r.buildSearchQuery(keyword, role, status).
Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
return users, err
}
// CountSearchUsers 搜索结果总数(含软删除用户)
func (r *UserRepo) CountSearchUsers(keyword, role, status string) (int64, error) {
var count int64
err := r.buildSearchQuery(keyword, role, status).Count(&count).Error
return count, err
}
// FindByStatus 按状态分页查询(回收站用,暂未暴露前端)
func (r *UserRepo) FindByStatus(status string, offset, limit int) ([]model.User, error) {
var users []model.User
err := r.db.Where("status = ?", status).Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
return users, err
}