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
This commit is contained in:
2026-05-27 13:30:34 +08:00
parent 053ca32a14
commit 0800ee2f3e
42 changed files with 3237 additions and 169 deletions

View File

@ -0,0 +1,140 @@
package admin
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// AuditController 审核管理控制器
type AuditController struct {
auditService auditUseCase
userRepo auditStatusProvider
}
// NewAuditController 构造函数
func NewAuditController(auditService auditUseCase, userRepo auditStatusProvider) *AuditController {
return &AuditController{auditService: auditService, userRepo: userRepo}
}
// AuditPage 审核管理页面
func (ac *AuditController) AuditPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/audit/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "审核管理",
"ExtraCSS": "/admin/static/css/audit.css",
"ExtraJS": "/admin/static/js/audit.js",
"AuditTypes": model.AuditTypeNames,
"AuditStatus": model.AuditStatusNames,
}))
}
// ListAudits 审核列表 API分页 + 类型筛选)
func (ac *AuditController) ListAudits(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
auditType := c.Query("type")
status := c.Query("status")
// 默认查 pending
if status == "" {
status = model.AuditStatusPending
}
result, err := ac.auditService.List(auditType, status, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
// 为每个审核记录附加提交者的用户名
type auditItem struct {
model.AuditSubmission
SubmitterUsername string `json:"submitter_username"`
}
items := make([]auditItem, 0, len(result.Items))
for _, a := range result.Items {
item := auditItem{AuditSubmission: a}
if user, err := ac.userRepo.FindByID(a.UserID); err == nil {
item.SubmitterUsername = user.Username
}
items = append(items, item)
}
common.Ok(c, gin.H{
"items": items,
"total": result.Total,
"page": result.Page,
})
}
// Approve 通过审核
func (ac *AuditController) Approve(c *gin.Context) {
submissionID, err := parseIDParam(c.Param("id"))
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的审核 ID")
return
}
reviewerID := c.GetUint("uid")
if err := ac.auditService.Approve(reviewerID, submissionID); err != nil {
handleAuditError(c, err)
return
}
common.OkMessage(c, "审核通过")
}
// Reject 拒绝审核
func (ac *AuditController) Reject(c *gin.Context) {
submissionID, err := parseIDParam(c.Param("id"))
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的审核 ID")
return
}
var req struct {
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
reviewerID := c.GetUint("uid")
if err := ac.auditService.Reject(reviewerID, submissionID, req.Reason); err != nil {
handleAuditError(c, err)
return
}
common.OkMessage(c, "已拒绝")
}
// parseIDParam 从 URL 路径参数解析 uint ID
func parseIDParam(s string) (uint, error) {
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}
// handleAuditError 统一处理审核接口的 service 层错误
func handleAuditError(c *gin.Context, err error) {
switch err {
case common.ErrAuditNotFound:
common.Error(c, http.StatusNotFound, "审核记录不存在")
case common.ErrAuditNotPending:
common.Error(c, http.StatusBadRequest, "该审核记录已处理")
case common.ErrUserNotFound:
common.Error(c, http.StatusNotFound, "用户不存在")
case common.ErrUsernameTaken:
common.Error(c, http.StatusConflict, "该用户名已被其他用户占用,审批失败")
default:
common.Error(c, http.StatusInternalServerError, "操作失败")
}
}

View File

@ -1,6 +1,9 @@
package admin
import "metazone.cc/metalab/internal/service"
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
)
// adminUseCase AdminController 对 AdminService 的最小依赖ISP4 个方法)
type adminUseCase interface {
@ -9,3 +12,24 @@ type adminUseCase interface {
ResetUserToken(operatorUID, targetUID uint) error
CountUsers() (int64, error)
}
// auditUseCase AuditController 对 AuditService 的最小依赖ISP4 个方法)
type auditUseCase interface {
List(auditType, status string, page, pageSize int) (*service.AuditListResult, error)
Approve(reviewerID uint, submissionID uint) error
Reject(reviewerID uint, submissionID uint, reason string) error
}
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖ISP3 个方法)
type siteSettingUseCase interface {
GetSettings() map[string]string
UpdateSetting(key, value string) error
UpdateBoolSetting(key string, value bool) error
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
}
// auditStatusProvider 审核控制器所需的用户信息查询ISP
type auditStatusProvider interface {
FindByID(id uint) (*model.User, error)
FindByUsername(username string) (*model.User, error)
}

View File

@ -0,0 +1,103 @@
package admin
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/service"
"github.com/gin-gonic/gin"
)
// SiteSettingController 站点设置控制器(仅 owner 可访问)
type SiteSettingController struct {
settingService siteSettingUseCase
defaults *service.AuditDefaults
}
// NewSiteSettingController 构造函数
func NewSiteSettingController(settingService siteSettingUseCase, defaults *service.AuditDefaults) *SiteSettingController {
return &SiteSettingController{settingService: settingService, defaults: defaults}
}
// GetSettings 获取所有站点设置
func (sc *SiteSettingController) GetSettings(c *gin.Context) {
all := sc.settingService.GetSettings()
audit := sc.settingService.GetAuditSettings(sc.defaults)
common.Ok(c, gin.H{
"all": all,
"audit": audit,
})
}
// UpdateSetting 更新单项设置
func (sc *SiteSettingController) UpdateSetting(c *gin.Context) {
var req struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
if err := sc.settingService.UpdateSetting(req.Key, req.Value); err != nil {
common.Error(c, http.StatusInternalServerError, "保存失败")
return
}
common.OkMessage(c, "设置已更新")
}
// UpdateBoolSetting 更新布尔设置
func (sc *SiteSettingController) UpdateBoolSetting(c *gin.Context) {
var req struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
v, err := service.ParseBool(req.Value)
if err != nil {
common.Error(c, http.StatusBadRequest, "值必须为 true 或 false")
return
}
if err := sc.settingService.UpdateBoolSetting(req.Key, v); err != nil {
common.Error(c, http.StatusInternalServerError, "保存失败")
return
}
common.OkMessage(c, "设置已更新")
}
// GetBoolSetting 获取单个布尔设置的当前值
func (sc *SiteSettingController) GetBoolSetting(c *gin.Context) {
key := c.Query("key")
if key == "" {
common.Error(c, http.StatusBadRequest, "缺少 key 参数")
return
}
defaultVal := c.DefaultQuery("default", "false")
d, _ := strconv.ParseBool(defaultVal)
val := sc.settingService.GetAuditSettings(sc.defaults)
// 简单键值查找
switch key {
case "audit.enabled":
common.Ok(c, gin.H{"value": val.Enabled})
case "audit.username_audit":
common.Ok(c, gin.H{"value": val.UsernameAudit})
case "audit.avatar_audit":
common.Ok(c, gin.H{"value": val.AvatarAudit})
case "audit.bio_audit":
common.Ok(c, gin.H{"value": val.BioAudit})
default:
common.Ok(c, gin.H{"value": d})
}
}