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

@ -25,7 +25,7 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("连接数据库失败: %v", err) log.Fatalf("连接数据库失败: %v", err)
} }
if err := db.AutoMigrate(&model.User{}); err != nil { if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}); err != nil {
log.Fatalf("数据库迁移失败: %v", err) log.Fatalf("数据库迁移失败: %v", err)
} }
@ -64,8 +64,14 @@ func main() {
r.Static("/shared/static", "./templates/shared/static") r.Static("/shared/static", "./templates/shared/static")
r.Static("/uploads/avatars", "./storage/uploads/avatars") r.Static("/uploads/avatars", "./storage/uploads/avatars")
// 站点设置管理器DB 持久化 + 内存缓存)
siteSettings, err := config.NewSiteSettings(db)
if err != nil {
log.Fatalf("初始化站点设置失败: %v", err)
}
// 路由注册 // 路由注册
router.Setup(r, db, cfg) router.Setup(r, db, cfg, siteSettings)
// 启动 // 启动
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port) log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)

View File

@ -22,6 +22,13 @@ jwt:
bcrypt: bcrypt:
cost: 12 cost: 12
# 审核系统配置(站点设置可运行时覆盖,无需重启)
audit:
enabled: true # 审核总开关
username_audit: true # 用户名修改审核
avatar_audit: true # 头像修改审核
bio_audit: true # 个性签名审核
# 角色配置(可扩展,无需修改源代码) # 角色配置(可扩展,无需修改源代码)
roles: roles:
# 角色层级(数字越大权限越高) # 角色层级(数字越大权限越高)

View File

@ -22,4 +22,16 @@ var (
ErrRateLimitIP = errors.New("请求过于频繁,请稍后重试") ErrRateLimitIP = errors.New("请求过于频繁,请稍后重试")
ErrRateLimitRegisterIP = errors.New("注册请求过于频繁,请稍后重试") ErrRateLimitRegisterIP = errors.New("注册请求过于频繁,请稍后重试")
ErrPermissionDenied = errors.New("权限不足") ErrPermissionDenied = errors.New("权限不足")
// 审核相关
ErrAuditNotFound = errors.New("审核记录不存在")
ErrAuditNotPending = errors.New("该审核记录已处理")
ErrAuditDisabled = errors.New("审核功能未开启")
ErrAuditTypeOff = errors.New("该类型审核未开启")
// 密码 & 注销相关
ErrIncorrectPassword = errors.New("当前密码错误")
ErrLoginBeforeDelete = errors.New("请先登录再注销")
ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号")
ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限")
) )

View File

@ -42,6 +42,7 @@ func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H {
data["RoleDisplayName"] = roleStr data["RoleDisplayName"] = roleStr
} }
data["CanManageUsers"] = model.HasMinRole(roleStr, model.RoleAdmin) data["CanManageUsers"] = model.HasMinRole(roleStr, model.RoleAdmin)
data["CanManageAudits"] = model.HasMinRole(roleStr, model.RoleAdmin)
data["CanManageSettings"] = model.HasMinRole(roleStr, model.RoleOwner) data["CanManageSettings"] = model.HasMinRole(roleStr, model.RoleOwner)
data["IsOwner"] = model.HasMinRole(roleStr, model.RoleOwner) data["IsOwner"] = model.HasMinRole(roleStr, model.RoleOwner)
} }

View File

@ -16,6 +16,15 @@ type Config struct {
JWT JWTConfig `mapstructure:"jwt"` JWT JWTConfig `mapstructure:"jwt"`
Bcrypt BcryptConfig `mapstructure:"bcrypt"` Bcrypt BcryptConfig `mapstructure:"bcrypt"`
Roles RolesConfig `mapstructure:"roles"` Roles RolesConfig `mapstructure:"roles"`
Audit AuditConfig `mapstructure:"audit"`
}
// AuditConfig 审核系统配置config.yaml 提供默认值,站点设置可运行时覆盖)
type AuditConfig struct {
Enabled bool `mapstructure:"enabled"`
UsernameAudit bool `mapstructure:"username_audit"`
AvatarAudit bool `mapstructure:"avatar_audit"`
BioAudit bool `mapstructure:"bio_audit"`
} }
type ServerConfig struct { type ServerConfig struct {

View File

@ -0,0 +1,109 @@
package config
import (
"strconv"
"sync"
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// SiteSettings 站点设置管理器DB 持久化 + 内存缓存,不重启生效)
// 启动时从 DB 加载全量到内存,运行时读内存不查 DB写入同步写 DB 和内存
type SiteSettings struct {
mu sync.RWMutex
data map[string]string
db *gorm.DB
}
// NewSiteSettings 创建站点设置管理器并加载 DB 中已有配置
func NewSiteSettings(db *gorm.DB) (*SiteSettings, error) {
ss := &SiteSettings{
data: make(map[string]string),
db: db,
}
if err := ss.reload(); err != nil {
return nil, err
}
return ss, nil
}
// reload 从 DB 加载全量设置到内存
func (s *SiteSettings) reload() error {
var rows []model.SiteSetting
if err := s.db.Find(&rows).Error; err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
for _, row := range rows {
s.data[row.Key] = row.Value
}
return nil
}
// Get 读取一个字符串值,不存在返回默认值
func (s *SiteSettings) Get(key, defaultVal string) string {
s.mu.RLock()
defer s.mu.RUnlock()
val, ok := s.data[key]
if !ok {
return defaultVal
}
return val
}
// GetBool 读取布尔值,不存在返回默认值(仅 "true" 字符串视为 true
func (s *SiteSettings) GetBool(key string, defaultVal bool) bool {
s.mu.RLock()
defer s.mu.RUnlock()
val, ok := s.data[key]
if !ok {
return defaultVal
}
b, err := strconv.ParseBool(val)
if err != nil {
return defaultVal
}
return b
}
// Set 写入设置(同步写 DB + 更新内存)
func (s *SiteSettings) Set(key, value string) error {
entry := model.SiteSetting{Key: key, Value: value}
if err := s.db.Save(&entry).Error; err != nil {
return err
}
s.mu.Lock()
s.data[key] = value
s.mu.Unlock()
return nil
}
// SetBool 写入布尔设置
func (s *SiteSettings) SetBool(key string, value bool) error {
return s.Set(key, strconv.FormatBool(value))
}
// All 返回所有设置的快照(用于前端展示)
func (s *SiteSettings) All() map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
cp := make(map[string]string, len(s.data))
for k, v := range s.data {
cp[k] = v
}
return cp
}
// IsAuditEnabled 审核总开关是否开启
// 优先查站点设置 DB若无则取 config.yaml 默认值
func (s *SiteSettings) IsAuditEnabled() bool {
return s.GetBool("audit.enabled", App.Audit.Enabled)
}
// IsAuditTypeEnabled 某类型审核是否开启
func (s *SiteSettings) IsAuditTypeEnabled(auditType string, defaultVal bool) bool {
return s.GetBool("audit."+auditType, defaultVal)
}

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 package admin
import "metazone.cc/metalab/internal/service" import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
)
// adminUseCase AdminController 对 AdminService 的最小依赖ISP4 个方法) // adminUseCase AdminController 对 AdminService 的最小依赖ISP4 个方法)
type adminUseCase interface { type adminUseCase interface {
@ -9,3 +12,24 @@ type adminUseCase interface {
ResetUserToken(operatorUID, targetUID uint) error ResetUserToken(operatorUID, targetUID uint) error
CountUsers() (int64, 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})
}
}

View File

@ -90,7 +90,7 @@ func (ac *AuthController) Login(c *gin.Context) {
return return
} }
accessToken, refreshToken, user, err := ac.authService.Login(req) accessToken, refreshToken, user, err := ac.authService.Login(req, ip)
if err != nil { if err != nil {
// 记录失败 → 两个维度各 +1 // 记录失败 → 两个维度各 +1
if recordAccount != nil { if recordAccount != nil {
@ -107,9 +107,13 @@ func (ac *AuthController) Login(c *gin.Context) {
common.Error(c, http.StatusForbidden, "账号已被封禁") common.Error(c, http.StatusForbidden, "账号已被封禁")
case common.ErrUserLocked: case common.ErrUserLocked:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrUserDeleted: case common.ErrNeedsConfirmRestore:
// deleted 状态本应在 Login 中自动恢复,此 case 作为兜底 // 注销账号登录 → 需要二次确认恢复
common.Error(c, http.StatusForbidden, "该账号已申请注销,登录即自动恢复") c.JSON(http.StatusOK, gin.H{
"success": true,
"action": "confirm_restore",
"message": "你的账号正在注销中,登录将中止注销流程",
})
default: default:
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试") common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
} }
@ -124,6 +128,29 @@ func (ac *AuthController) Login(c *gin.Context) {
common.OkWithMessage(c, user, "登录成功") common.OkWithMessage(c, user, "登录成功")
} }
// ConfirmRestore 二次确认恢复已注销账号
func (ac *AuthController) ConfirmRestore(c *gin.Context) {
var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请检查输入")
return
}
accessToken, refreshToken, user, err := ac.authService.ConfirmRestore(req, clientIP(c))
if err != nil {
switch err {
case common.ErrInvalidCred:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
default:
common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试")
}
return
}
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
common.OkWithMessage(c, user, "账号已恢复,欢迎回来")
}
// CheckEmail 检查邮箱是否已注册 // CheckEmail 检查邮箱是否已注册
func (ac *AuthController) CheckEmail(c *gin.Context) { func (ac *AuthController) CheckEmail(c *gin.Context) {
var req model.CheckEmailRequest var req model.CheckEmailRequest
@ -156,7 +183,7 @@ func (ac *AuthController) Register(c *gin.Context) {
return return
} }
accessToken, refreshToken, user, err := ac.authService.Register(req) accessToken, refreshToken, user, err := ac.authService.Register(req, clientIP(c))
if err != nil { if err != nil {
if recordReg != nil { if recordReg != nil {
recordReg() recordReg()

View File

@ -5,10 +5,11 @@ import (
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
) )
// authUseCase AuthController 对 AuthService 的最小依赖ISP3 个方法) // authUseCase AuthController 对 AuthService 的最小依赖ISP4 个方法)
type authUseCase interface { type authUseCase interface {
Register(req model.RegisterRequest) (string, string, *model.User, error) Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error)
Login(req model.LoginRequest) (string, string, *model.User, error) Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error)
ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error)
CheckEmail(email string) (bool, error) CheckEmail(email string) (bool, error)
} }

View File

@ -0,0 +1,129 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// notifProvider MessageController 所需的通知服务接口ISP5 个方法)
type notifProvider interface {
List(userID uint, page, pageSize int) (*model.NotificationListResult, error)
CountUnread(userID uint) (int64, error)
MarkRead(id, userID uint) error
MarkAllRead(userID uint) error
}
// MessageController 消息中心控制器
type MessageController struct {
notifService notifProvider
}
// NewMessageController 构造函数
func NewMessageController(notifService notifProvider) *MessageController {
return &MessageController{notifService: notifService}
}
// MessagesPage 消息中心页面需登录noindex
func (mc *MessageController) MessagesPage(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
c.Redirect(http.StatusFound, "/auth/login")
c.Abort()
return
}
uid := uidVal.(uint)
// 从 query 取分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize := 20
result, err := mc.notifService.List(uid, page, pageSize)
if err != nil {
result = &model.NotificationListResult{Items: []model.Notification{}, Total: 0, Page: 1}
}
totalPages := result.TotalPages
if totalPages == 0 && result.Total > 0 {
totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize))
}
c.HTML(http.StatusOK, "messages/index.html", common.BuildPageData(c, gin.H{
"Title": "消息中心",
"ExtraCSS": "/static/css/messages.css",
"Messages": result.Items,
"Total": result.Total,
"Page": result.Page,
"TotalPages": totalPages,
"PrevPage": result.Page - 1,
"NextPage": result.Page + 1,
"HasPrev": result.Page > 1,
"HasNext": result.Page < totalPages,
"UnreadCount": result.Unread,
"NotifyTypeNames": model.NotifyTypeNames,
"AuditTypeNames": model.AuditTypeNames,
}))
}
// UnreadCount 获取未读消息数API供导航栏轮询
func (mc *MessageController) UnreadCount(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
common.Ok(c, gin.H{"unread": 0})
return
}
count, err := mc.notifService.CountUnread(uidVal.(uint))
if err != nil {
common.Ok(c, gin.H{"unread": 0})
return
}
common.Ok(c, gin.H{"unread": count})
}
// MarkRead 标记单条消息已读API
func (mc *MessageController) MarkRead(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "消息 ID 无效")
return
}
if err := mc.notifService.MarkRead(uint(id), uidVal.(uint)); err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
common.OkMessage(c, "已标记为已读")
}
// MarkAllRead 一键全部已读API
func (mc *MessageController) MarkAllRead(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
if err := mc.notifService.MarkAllRead(uidVal.(uint)); err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
common.OkMessage(c, "已全部标为已读")
}

View File

@ -11,26 +11,38 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// profileProvider SettingsController 对 Service 层的最小依赖ISP2 个方法) // profileProvider SettingsController 对 Service 层的最小依赖ISP4 个方法)
type profileProvider interface { type profileProvider interface {
GetProfile(userID uint) (*model.User, error) GetProfile(userID uint) (*model.User, error)
UpdateProfile(userID uint, username, bio string) error UpdateProfile(userID uint, username, bio string) error
ChangePassword(userID uint, currentPassword, newPassword string) error
DeleteAccount(userID uint, password, reason string) error
} }
// avatarProvider 头像上传对 Service 层的最小依赖ISP1 个方法) // avatarProvider 头像上传对 Service 层的最小依赖ISP2 个方法)
type avatarProvider interface { type avatarProvider interface {
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
}
// auditSubmittable 个人设置对审核服务的依赖ISP3 个方法)
type auditSubmittable interface {
ShouldAudit(userID uint) (bool, error)
SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error
Submit(userID uint, auditType, newValue string) error
GetPendingTypes(userID uint) ([]string, error)
} }
// SettingsController 个人设置控制器 // SettingsController 个人设置控制器
type SettingsController struct { type SettingsController struct {
authService profileProvider authService profileProvider
avatarService avatarProvider avatarService avatarProvider
auditService auditSubmittable
} }
// NewSettingsController 构造函数 // NewSettingsController 构造函数
func NewSettingsController(authService profileProvider, avatarService avatarProvider) *SettingsController { func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService} return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService}
} }
// SettingsPage 个人设置页面(需登录) // SettingsPage 个人设置页面(需登录)
@ -55,17 +67,23 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
return return
} }
// 查询待审核类型(用于前端显示审核提示)
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{
"Title": "个人设置", "Title": "个人设置",
"ExtraCSS": "/static/css/settings.css", "ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab, "ActiveTab": tab,
"User": user, "User": user,
"RoleName": model.RoleDisplayNames[user.Role], "RoleName": model.RoleDisplayNames[user.Role],
"StatusName": model.StatusDisplayNames[user.Status], "StatusName": model.StatusDisplayNames[user.Status],
"PendingTypes": pendingTypes,
"AuditTypes": model.AuditTypeNames,
})) }))
} }
// UpdateProfile 修改个人资料(用户名 + 个性签名,需登录) // UpdateProfile 修改个人资料(用户名 + 个性签名,需登录)
// 普通用户走审核流程,管理员及以上直接落库
func (sc *SettingsController) UpdateProfile(c *gin.Context) { func (sc *SettingsController) UpdateProfile(c *gin.Context) {
uid, exists := c.Get("uid") uid, exists := c.Get("uid")
if !exists { if !exists {
@ -82,7 +100,31 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
return return
} }
if err := sc.authService.UpdateProfile(uid.(uint), req.Username, req.Bio); err != nil { userID := uid.(uint)
// 判断是否需要审核
shouldAudit, err := sc.auditService.ShouldAudit(userID)
if err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
if shouldAudit {
user, err := sc.authService.GetProfile(userID)
if err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil {
handleAuditSubmitError(c, err)
return
}
common.OkMessage(c, "修改已提交审核,通过前当前信息保持不变")
return
}
// 管理员及以上直接更新
if err := sc.authService.UpdateProfile(userID, req.Username, req.Bio); err != nil {
handleSettingsError(c, err) handleSettingsError(c, err)
return return
} }
@ -91,6 +133,7 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
} }
// UploadAvatar 上传头像需登录multipart/form-data // UploadAvatar 上传头像需登录multipart/form-data
// 普通用户走审核流程,管理员及以上直接更新
func (sc *SettingsController) UploadAvatar(c *gin.Context) { func (sc *SettingsController) UploadAvatar(c *gin.Context) {
uid, exists := c.Get("uid") uid, exists := c.Get("uid")
if !exists { if !exists {
@ -110,7 +153,32 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
cropY, _ := strconv.Atoi(c.PostForm("crop_y")) cropY, _ := strconv.Atoi(c.PostForm("crop_y"))
cropSize, _ := strconv.Atoi(c.PostForm("crop_size")) cropSize, _ := strconv.Atoi(c.PostForm("crop_size"))
url, err := sc.avatarService.ProcessAvatar(uid.(uint), file, header.Header.Get("Content-Type"), cropX, cropY, cropSize) userID := uid.(uint)
// 判断是否需要审核
shouldAudit, err := sc.auditService.ShouldAudit(userID)
if err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
if shouldAudit {
// 仅处理图片,不更新用户
avatarURL, err := sc.avatarService.ProcessImage(userID, file, header.Header.Get("Content-Type"), cropX, cropY, cropSize)
if err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
if err := sc.auditService.Submit(userID, model.AuditTypeAvatar, avatarURL); err != nil {
handleAuditSubmitError(c, err)
return
}
common.OkMessage(c, "头像已提交审核,通过前当前头像保持不变")
return
}
// 管理员及以上直接更新
url, err := sc.avatarService.ProcessAvatar(userID, file, header.Header.Get("Content-Type"), cropX, cropY, cropSize)
if err != nil { if err != nil {
common.Error(c, http.StatusBadRequest, err.Error()) common.Error(c, http.StatusBadRequest, err.Error())
return return
@ -119,6 +187,56 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
common.Ok(c, gin.H{"url": url}) common.Ok(c, gin.H{"url": url})
} }
// ChangePassword 修改密码(需登录)
func (sc *SettingsController) ChangePassword(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
var req struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请检查输入")
return
}
if err := sc.authService.ChangePassword(uid.(uint), req.CurrentPassword, req.NewPassword); err != nil {
handleSettingsError(c, err)
return
}
common.OkMessage(c, "密码已修改,请重新登录")
}
// DeleteAccount 自助注销账号(需登录,验证密码 + 注销原因)
func (sc *SettingsController) DeleteAccount(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
var req struct {
Password string `json:"password" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请检查输入")
return
}
if err := sc.authService.DeleteAccount(uid.(uint), req.Password, req.Reason); err != nil {
handleSettingsError(c, err)
return
}
common.OkMessage(c, "账号已注销7 天内重新登录即可恢复")
}
// handleSettingsError 统一处理 settings 接口的 service 层错误 // handleSettingsError 统一处理 settings 接口的 service 层错误
func handleSettingsError(c *gin.Context, err error) { func handleSettingsError(c *gin.Context, err error) {
switch err { switch err {
@ -128,7 +246,44 @@ func handleSettingsError(c *gin.Context, err error) {
common.Error(c, http.StatusBadRequest, err.Error()) common.Error(c, http.StatusBadRequest, err.Error())
case common.ErrBioTooLong: case common.ErrBioTooLong:
common.Error(c, http.StatusBadRequest, err.Error()) common.Error(c, http.StatusBadRequest, err.Error())
case common.ErrIncorrectPassword:
common.Error(c, http.StatusForbidden, err.Error())
case common.ErrOwnerCannotDelete:
common.Error(c, http.StatusForbidden, err.Error())
default: default:
common.Error(c, http.StatusInternalServerError, "操作失败") common.Error(c, http.StatusInternalServerError, "操作失败")
} }
} }
// handleAuditSubmitError 统一处理审核提交的错误
func handleAuditSubmitError(c *gin.Context, err error) {
switch err {
case common.ErrAuditDisabled:
common.Error(c, http.StatusForbidden, "审核功能未开启")
case common.ErrAuditTypeOff:
common.Error(c, http.StatusForbidden, "该类型审核未开启,请联系管理员")
case common.ErrUsernameTaken:
common.Error(c, http.StatusConflict, err.Error())
default:
common.Error(c, http.StatusInternalServerError, "提交审核失败")
}
}
// AuditStatus 查询当前用户的待审核类型(需登录)
func (sc *SettingsController) AuditStatus(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
types, err := sc.auditService.GetPendingTypes(uid.(uint))
if err != nil {
common.Ok(c, gin.H{"pending_types": []string{}})
return
}
if types == nil {
types = []string{}
}
common.Ok(c, gin.H{"pending_types": types})
}

View File

@ -70,7 +70,7 @@ func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc {
return return
} }
log.Printf("[AdminAuth] OK: uid=%d role=%v path=%s", uint(claims["uid"].(float64)), claims["role"], c.Request.URL.Path) log.Printf("[AdminAuth] OK: uid=%v role=%v path=%s", claims["uid"], claims["role"], c.Request.URL.Path)
injectUserContext(c, claims) injectUserContext(c, claims)
c.Next() c.Next()
} }
@ -93,19 +93,27 @@ func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, erro
tokenVer := int(claims["ver"].(float64)) tokenVer := int(claims["ver"].(float64))
currentVer, err := am.userRepo.FindTokenVersion(uid) currentVer, err := am.userRepo.FindTokenVersion(uid)
if err != nil || tokenVer != currentVer { if err != nil {
if tokenVer != currentVer {
log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
}
return nil, err return nil, err
} }
if tokenVer != currentVer {
log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
return nil, common.ErrTokenRevoked
}
return claims, nil return claims, nil
} }
// injectUserContext 将 JWT claims 中的用户信息注入 gin context // injectUserContext 将 JWT claims 中的用户信息注入 gin context
func injectUserContext(c *gin.Context, claims jwt.MapClaims) { func injectUserContext(c *gin.Context, claims jwt.MapClaims) {
c.Set("uid", uint(claims["uid"].(float64))) if claims == nil {
return
}
uid, ok := claims["uid"].(float64)
if !ok {
return
}
c.Set("uid", uint(uid))
c.Set("email", claims["email"]) c.Set("email", claims["email"])
c.Set("username", claims["username"]) c.Set("username", claims["username"])
c.Set("role", claims["role"]) c.Set("role", claims["role"])

61
internal/model/audit.go Normal file
View File

@ -0,0 +1,61 @@
package model
// 审核类型常量
const (
AuditTypeUsername = "username"
AuditTypeAvatar = "avatar"
AuditTypeBio = "bio"
)
// 审核状态常量
const (
AuditStatusPending = "pending"
AuditStatusApproved = "approved"
AuditStatusRejected = "rejected"
)
// AuditSubmission 审核提交记录
// 密钥设计:
// - 用户提交修改 → 创建 pending 记录User 表暂不更新
// - 管理员审批通过 → 更新 User 表对应字段,标记 approved
// - 管理员拒绝 → 标记 rejected记录理由
// - 用户连续提交同类型 → 旧 pending 标记 rejected理由"用户重新提交"),创建新 pending
type AuditSubmission struct {
BaseModel
UserID uint `gorm:"index:idx_type_user_status,priority:2;not null" json:"user_id"`
AuditType string `gorm:"type:varchar(20);index:idx_type_user_status,priority:1;not null" json:"audit_type"`
NewValue string `gorm:"type:text;not null" json:"new_value"`
Status string `gorm:"type:varchar(20);index:idx_type_user_status,priority:3;not null;default:pending" json:"status"`
// 审核人信息(审核后才填充)
ReviewedBy *uint `gorm:"default:null" json:"reviewed_by,omitempty"`
ReviewerName *string `gorm:"type:varchar(50);default:null" json:"reviewer_name,omitempty"`
// 拒绝理由
RejectReason *string `gorm:"type:text;default:null" json:"reject_reason,omitempty"`
}
// AuditTypeNames 审核类型 → 中文名
var AuditTypeNames = map[string]string{
AuditTypeUsername: "用户名",
AuditTypeAvatar: "头像",
AuditTypeBio: "个性签名",
}
// AuditStatusNames 审核状态 → 中文名
var AuditStatusNames = map[string]string{
AuditStatusPending: "待审核",
AuditStatusApproved: "已通过",
AuditStatusRejected: "已拒绝",
}
// IsAuditType 校验审核类型合法性
func IsAuditType(t string) bool {
switch t {
case AuditTypeUsername, AuditTypeAvatar, AuditTypeBio:
return true
}
return false
}

View File

@ -19,3 +19,15 @@ type LoginRequest struct {
type CheckEmailRequest struct { type CheckEmailRequest struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
} }
// ChangePasswordRequest 修改密码请求
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
// DeleteAccountRequest 注销账号请求
type DeleteAccountRequest struct {
Password string `json:"password" binding:"required"`
Reason string `json:"reason"`
}

View File

@ -0,0 +1,34 @@
package model
// 消息/通知类型常量
const (
NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected"
)
// Notification 消息/通知模型
type Notification struct {
BaseModel
UserID uint `gorm:"index:idx_user_read,priority:1;not null" json:"user_id"`
NotifyType string `gorm:"type:varchar(30);index;not null" json:"notify_type"`
Title string `gorm:"type:varchar(200);not null" json:"title"`
Content string `gorm:"type:text" json:"content"`
IsRead bool `gorm:"index:idx_user_read,priority:2;default:false;not null" json:"is_read"`
RelatedID *uint `gorm:"default:null" json:"related_id,omitempty"` // 关联业务 ID
}
// NotifyTypeNames 通知类型 → 中文名
var NotifyTypeNames = map[string]string{
NotifyAuditApproved: "系统通知",
NotifyAuditRejected: "系统通知",
}
// NotificationListResult 消息列表查询结果
type NotificationListResult struct {
Items []Notification `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
Unread int64 `json:"unread"`
}

View File

@ -0,0 +1,7 @@
package model
// SiteSetting 站点设置(键值对模型,存数据库,运行时加载到内存)
type SiteSetting struct {
Key string `gorm:"primarykey;type:varchar(100)" json:"key"`
Value string `gorm:"type:text;not null" json:"value"`
}

View File

@ -1,5 +1,7 @@
package model package model
import "time"
// User 用户模型 // User 用户模型
type User struct { type User struct {
BaseModel BaseModel
@ -12,7 +14,13 @@ type User struct {
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"` Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"` Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
// 安全与审计
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
LastLoginAt *time.Time `gorm:"default:null" json:"last_login_at,omitempty"` // 最后登录时间
DeleteReason string `gorm:"type:text;default:''" json:"-"` // 注销原因
} }
// 角色常量(仅用作字符串标识符,层级和权限由配置驱动) // 角色常量(仅用作字符串标识符,层级和权限由配置驱动)

View File

@ -0,0 +1,103 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// AuditRepo 审核记录数据访问
type AuditRepo struct {
db *gorm.DB
}
// NewAuditRepo 构造函数
func NewAuditRepo(db *gorm.DB) *AuditRepo {
return &AuditRepo{db: db}
}
// AutoMigrate 自动迁移审核表
func (r *AuditRepo) AutoMigrate() error {
return r.db.AutoMigrate(&model.AuditSubmission{})
}
// Create 创建审核提交
func (r *AuditRepo) Create(audit *model.AuditSubmission) error {
return r.db.Create(audit).Error
}
// FindByID 按 ID 查找审核记录
func (r *AuditRepo) FindByID(id uint) (*model.AuditSubmission, error) {
var audit model.AuditSubmission
err := r.db.First(&audit, id).Error
if err != nil {
return nil, err
}
return &audit, nil
}
// FindPendingByUserAndType 查找用户某类型的待审记录(用于连续提交覆盖)
func (r *AuditRepo) FindPendingByUserAndType(userID uint, auditType string) (*model.AuditSubmission, error) {
var audit model.AuditSubmission
err := r.db.
Where("user_id = ? AND audit_type = ? AND status = ?", userID, auditType, model.AuditStatusPending).
First(&audit).Error
if err != nil {
return nil, err
}
return &audit, nil
}
// Update 全量更新审核记录
func (r *AuditRepo) Update(audit *model.AuditSubmission) error {
return r.db.Save(audit).Error
}
// findAudits 构建审核列表查询
func (r *AuditRepo) findAudits(auditType, status string) *gorm.DB {
q := r.db.Model(&model.AuditSubmission{})
if auditType != "" {
q = q.Where("audit_type = ?", auditType)
}
if status != "" {
q = q.Where("status = ?", status)
}
return q
}
// ListAudits 分页查询审核列表
func (r *AuditRepo) ListAudits(auditType, status string, offset, limit int) ([]model.AuditSubmission, error) {
var audits []model.AuditSubmission
err := r.findAudits(auditType, status).
Order("created_at DESC").
Offset(offset).Limit(limit).
Find(&audits).Error
return audits, err
}
// CountAudits 统计审核记录总数
func (r *AuditRepo) CountAudits(auditType, status string) (int64, error) {
var count int64
err := r.findAudits(auditType, status).Count(&count).Error
return count, err
}
// FindPendingByUserID 查找用户所有待审记录(用于个人中心提示)
func (r *AuditRepo) FindPendingByUserID(userID uint) ([]model.AuditSubmission, error) {
var audits []model.AuditSubmission
err := r.db.
Where("user_id = ? AND status = ?", userID, model.AuditStatusPending).
Find(&audits).Error
return audits, err
}
// ExistsPendingByTypeAndValue 检查是否有同类型同值的待审记录(用于防重名提交)
// 排除指定 userID 的记录(允许同一用户覆盖自己的提交)
func (r *AuditRepo) ExistsPendingByTypeAndValue(auditType, newValue string, excludeUserID uint) (bool, error) {
var count int64
err := r.db.Model(&model.AuditSubmission{}).
Where("audit_type = ? AND new_value = ? AND status = ? AND user_id != ?",
auditType, newValue, model.AuditStatusPending, excludeUserID).
Count(&count).Error
return count > 0, err
}

View File

@ -0,0 +1,67 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// NotificationRepo 消息/通知数据访问
type NotificationRepo struct {
db *gorm.DB
}
// NewNotificationRepo 构造函数
func NewNotificationRepo(db *gorm.DB) *NotificationRepo {
return &NotificationRepo{db: db}
}
// AutoMigrate 自动迁移消息表
func (r *NotificationRepo) AutoMigrate() error {
return r.db.AutoMigrate(&model.Notification{})
}
// Create 创建消息
func (r *NotificationRepo) Create(n *model.Notification) error {
return r.db.Create(n).Error
}
// ListByUser 分页查询用户消息
func (r *NotificationRepo) ListByUser(userID uint, offset, limit int) ([]model.Notification, error) {
var list []model.Notification
err := r.db.Where("user_id = ?", userID).
Order("created_at DESC").
Offset(offset).Limit(limit).
Find(&list).Error
return list, err
}
// CountByUser 统计用户消息总数
func (r *NotificationRepo) CountByUser(userID uint) (int64, error) {
var count int64
err := r.db.Model(&model.Notification{}).Where("user_id = ?", userID).Count(&count).Error
return count, err
}
// CountUnread 统计用户未读消息数
func (r *NotificationRepo) CountUnread(userID uint) (int64, error) {
var count int64
err := r.db.Model(&model.Notification{}).
Where("user_id = ? AND is_read = false", userID).
Count(&count).Error
return count, err
}
// MarkRead 标记单条消息为已读
func (r *NotificationRepo) MarkRead(id, userID uint) error {
return r.db.Model(&model.Notification{}).
Where("id = ? AND user_id = ?", id, userID).
Update("is_read", true).Error
}
// MarkAllRead 将用户所有未读消息标记为已读
func (r *NotificationRepo) MarkAllRead(userID uint) error {
return r.db.Model(&model.Notification{}).
Where("user_id = ? AND is_read = false", userID).
Update("is_read", true).Error
}

View File

@ -0,0 +1,34 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// SiteSettingRepo 站点设置数据访问
type SiteSettingRepo struct {
db *gorm.DB
}
// NewSiteSettingRepo 构造函数
func NewSiteSettingRepo(db *gorm.DB) *SiteSettingRepo {
return &SiteSettingRepo{db: db}
}
// AutoMigrate 自动迁移站点设置表
func (r *SiteSettingRepo) AutoMigrate() error {
return r.db.AutoMigrate(&model.SiteSetting{})
}
// Upsert 创建或更新站点设置
func (r *SiteSettingRepo) Upsert(key, value string) error {
return r.db.Save(&model.SiteSetting{Key: key, Value: value}).Error
}
// GetAll 获取所有站点设置
func (r *SiteSettingRepo) GetAll() ([]model.SiteSetting, error) {
var settings []model.SiteSetting
err := r.db.Find(&settings).Error
return settings, err
}

View File

@ -21,10 +21,10 @@ func (r *UserRepo) Create(user *model.User) error {
return r.db.Create(user).Error return r.db.Create(user).Error
} }
// FindByEmail 按邮箱查找用户 // FindByEmail 按邮箱查找用户(含软删除,用于登录等需要找到已注销用户的场景)
func (r *UserRepo) FindByEmail(email string) (*model.User, error) { func (r *UserRepo) FindByEmail(email string) (*model.User, error) {
var user model.User var user model.User
err := r.db.Where("email = ?", email).First(&user).Error err := r.db.Unscoped().Where("email = ?", email).First(&user).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -17,7 +17,7 @@ import (
) )
// Setup 注册所有路由 // Setup 注册所有路由
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) { func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) {
// --- 全局中间件 --- // --- 全局中间件 ---
r.Use(middleware.SecurityHeaders()) r.Use(middleware.SecurityHeaders())
@ -28,14 +28,35 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
rateLimiter := middleware.NewRateLimiter() rateLimiter := middleware.NewRateLimiter()
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg) authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
avatarSvc := service.NewAvatarService(userRepo) avatarSvc := service.NewAvatarService(userRepo)
settingsCtrl := controller.NewSettingsController(authService, avatarSvc)
authMdw := middleware.NewAuthMiddleware(cfg, userRepo) authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
// 管理后台 // 管理后台
adminService := service.NewAdminService(userRepo) adminService := service.NewAdminService(userRepo)
adminController := adminCtrl.NewAdminController(adminService) adminController := adminCtrl.NewAdminController(adminService)
// 审核系统
auditRepo := repository.NewAuditRepo(db)
notificationRepo := repository.NewNotificationRepo(db)
notificationService := service.NewNotificationService(notificationRepo)
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService)
auditController := adminCtrl.NewAuditController(auditService, userRepo)
// 消息中心
messageController := controller.NewMessageController(notificationService)
// 站点设置
siteSettingService := service.NewSiteSettingService(siteSettings)
auditDefaults := &service.AuditDefaults{
Enabled: cfg.Audit.Enabled,
UsernameAudit: cfg.Audit.UsernameAudit,
AvatarAudit: cfg.Audit.AvatarAudit,
BioAudit: cfg.Audit.BioAudit,
}
siteSettingController := adminCtrl.NewSiteSettingController(siteSettingService, auditDefaults)
// 个人设置(需要审核服务)
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService)
// --- 页面路由CSRF 仅下发 token不验证——页面 GET 被豁免) --- // --- 页面路由CSRF 仅下发 token不验证——页面 GET 被豁免) ---
pages := r.Group("/") pages := r.Group("/")
pages.Use(authMdw.Optional()) pages.Use(authMdw.Optional())
@ -58,6 +79,9 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
c.Redirect(http.StatusFound, "/settings/profile") c.Redirect(http.StatusFound, "/settings/profile")
}) })
pages.GET("/settings/:tab", settingsCtrl.SettingsPage) pages.GET("/settings/:tab", settingsCtrl.SettingsPage)
// 消息中心需登录noindex
pages.GET("/messages", messageController.MessagesPage)
} }
// 认证页面(已登录自动跳走) // 认证页面(已登录自动跳走)
@ -80,6 +104,20 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
{ {
settingsAPI.PUT("/profile", settingsCtrl.UpdateProfile) settingsAPI.PUT("/profile", settingsCtrl.UpdateProfile)
settingsAPI.POST("/avatar", settingsCtrl.UploadAvatar) settingsAPI.POST("/avatar", settingsCtrl.UploadAvatar)
settingsAPI.PUT("/password", settingsCtrl.ChangePassword)
settingsAPI.POST("/delete-account", settingsCtrl.DeleteAccount)
// 查询当前用户的待审核状态
settingsAPI.GET("/audit-status", settingsCtrl.AuditStatus)
}
// --- 消息中心 API需登录 ---
messagesAPI := r.Group("/api/messages")
messagesAPI.Use(authMdw.Required())
messagesAPI.Use(middleware.CSRF(cfg))
{
messagesAPI.GET("/unread", messageController.UnreadCount)
messagesAPI.POST("/:id/read", messageController.MarkRead)
messagesAPI.POST("/read-all", messageController.MarkAllRead)
} }
// --- 管理后台 SSR 页面(认证失败 302 跳首页) --- // --- 管理后台 SSR 页面(认证失败 302 跳首页) ---
@ -95,6 +133,8 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
adminPages.GET("/", adminController.Dashboard) adminPages.GET("/", adminController.Dashboard)
adminPages.GET("/users", adminController.UsersPage, adminPages.GET("/users", adminController.UsersPage,
middleware.RequirePageRole(model.RoleAdmin)) middleware.RequirePageRole(model.RoleAdmin))
adminPages.GET("/audits", auditController.AuditPage,
middleware.RequirePageRole(model.RoleAdmin))
} }
// --- 管理后台 APIJSON 响应) --- // --- 管理后台 APIJSON 响应) ---
@ -106,6 +146,20 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
adminAPI.GET("/users", adminController.ListUsers) adminAPI.GET("/users", adminController.ListUsers)
adminAPI.PUT("/users/:uid/status", adminController.UpdateUserStatus) adminAPI.PUT("/users/:uid/status", adminController.UpdateUserStatus)
adminAPI.POST("/users/:uid/reset-token", adminController.ResetToken) adminAPI.POST("/users/:uid/reset-token", adminController.ResetToken)
// 审核 API
adminAPI.GET("/audits", auditController.ListAudits)
adminAPI.POST("/audits/:id/approve", auditController.Approve)
adminAPI.POST("/audits/:id/reject", auditController.Reject)
}
// --- 站点设置 API仅 owner 可访问) ---
siteSettingsAPI := r.Group("/api/admin/site-settings")
siteSettingsAPI.Use(authMdw.Required())
siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner))
siteSettingsAPI.Use(middleware.CSRF(cfg))
{
siteSettingsAPI.GET("", siteSettingController.GetSettings)
siteSettingsAPI.PUT("", siteSettingController.UpdateSetting)
} }
// --- API 路由CSRF 严格验证) --- // --- API 路由CSRF 严格验证) ---
@ -115,6 +169,7 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
api.POST("/auth/check-email", authCtrl.CheckEmail) api.POST("/auth/check-email", authCtrl.CheckEmail)
api.POST("/auth/register", authCtrl.Register) api.POST("/auth/register", authCtrl.Register)
api.POST("/auth/login", authCtrl.Login) api.POST("/auth/login", authCtrl.Login)
api.POST("/auth/confirm-restore", authCtrl.ConfirmRestore)
api.POST("/auth/logout", authCtrl.Logout) api.POST("/auth/logout", authCtrl.Logout)
api.POST("/auth/refresh", authCtrl.RefreshToken) api.POST("/auth/refresh", authCtrl.RefreshToken)
} }

View File

@ -4,9 +4,6 @@ import (
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
) )
// AdminService 管理后台业务逻辑
// 集中处理所有权限边界检查,避免胖控制器
type AdminService struct { type AdminService struct {
userRepo userAdminStore userRepo userAdminStore
} }
@ -34,20 +31,15 @@ type ListUsersResult struct {
// ListUsers 分页搜索用户列表 // ListUsers 分页搜索用户列表
func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, error) { func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, error) {
if params.Page < 1 { p := common.Pagination{Page: params.Page, PageSize: params.PageSize}
params.Page = 1 p.DefaultPagination()
}
if params.PageSize < 1 || params.PageSize > 100 {
params.PageSize = 20
}
offset := (params.Page - 1) * params.PageSize
total, err := s.userRepo.CountSearchUsers(params.Keyword, params.Role, params.Status) total, err := s.userRepo.CountSearchUsers(params.Keyword, params.Role, params.Status)
if err != nil { if err != nil {
return nil, err return nil, err
} }
users, err := s.userRepo.SearchUsers(params.Keyword, params.Role, params.Status, offset, params.PageSize) users, err := s.userRepo.SearchUsers(params.Keyword, params.Role, params.Status, p.Offset(), p.PageSize)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -55,7 +47,7 @@ func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, erro
return &ListUsersResult{ return &ListUsersResult{
Users: users, Users: users,
Total: total, Total: total,
Page: params.Page, Page: p.Page,
}, nil }, nil
} }

View File

@ -0,0 +1,295 @@
package service
import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// auditRepo AuditService 所需的最小仓储接口ISP
type auditRepo interface {
Create(audit *model.AuditSubmission) error
FindByID(id uint) (*model.AuditSubmission, error)
FindPendingByUserAndType(userID uint, auditType string) (*model.AuditSubmission, error)
Update(audit *model.AuditSubmission) error
ListAudits(auditType, status string, offset, limit int) ([]model.AuditSubmission, error)
CountAudits(auditType, status string) (int64, error)
FindPendingByUserID(userID uint) ([]model.AuditSubmission, error)
ExistsPendingByTypeAndValue(auditType, newValue string, excludeUserID uint) (bool, error)
}
// userProfileStore 审核服务所需的最小用户仓储接口ISP
type userProfileStore interface {
FindByID(id uint) (*model.User, error)
Update(user *model.User) error
ExistsByUsername(username string) (bool, error)
}
// siteSettingsChecker 审核服务所需的站点设置接口ISP仅读取审核开关
type siteSettingsChecker interface {
IsAuditEnabled() bool
IsAuditTypeEnabled(auditType string, defaultVal bool) bool
}
// auditNotifier 审核服务所需的通知接口ISP审核通过/拒绝时发送通知)
type auditNotifier interface {
NotifyAuditApproved(userID uint, auditType string, relatedID uint)
NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint)
}
// AuditService 审核业务逻辑
type AuditService struct {
auditRepo auditRepo
userRepo userProfileStore
settings siteSettingsChecker
defaultCfg config.AuditConfig
notifier auditNotifier
}
// NewAuditService 构造函数
func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings siteSettingsChecker, cfg config.AuditConfig, notifier auditNotifier) *AuditService {
return &AuditService{
auditRepo: auditRepo,
userRepo: userRepo,
settings: settings,
defaultCfg: cfg,
notifier: notifier,
}
}
// ErrXxx 重导出
var (
ErrAuditNotFound = common.ErrAuditNotFound
ErrAuditNotPending = common.ErrAuditNotPending
ErrAuditDisabled = common.ErrAuditDisabled
ErrAuditTypeOff = common.ErrAuditTypeOff
)
// ShouldAudit 判断指定用户是否需要走审核流程
// 规则admin 及以上角色免审
func (s *AuditService) ShouldAudit(userID uint) (bool, error) {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return false, err
}
return !model.HasMinRole(user.Role, model.RoleAdmin), nil
}
// Submit 提交审核
// - 总开关未开 → 返回 ErrAuditDisabled
// - 类型开关未开 → 返回 ErrAuditTypeOff
// - 用户名类型 → 先检查是否已被占用
// - 若存在同类型 pending → 标记旧记录为 rejected理由"用户重新提交"),创建新 pending
func (s *AuditService) Submit(userID uint, auditType, newValue string) error {
// 检查总开关
if !s.settings.IsAuditEnabled() {
return ErrAuditDisabled
}
// 检查类型开关
if !s.settings.IsAuditTypeEnabled(auditType, s.typeDefault(auditType)) {
return ErrAuditTypeOff
}
// 用户名冲突检查(提交时即拦截,避免提交到后台后审核失败)
if auditType == model.AuditTypeUsername {
// 1) users 表中已被占用
exists, err := s.userRepo.ExistsByUsername(newValue)
if err != nil {
return err
}
if exists {
return ErrUsernameTaken
}
// 2) 审核表中已有其他用户提交了同名 pending
pendingExists, err := s.auditRepo.ExistsPendingByTypeAndValue(auditType, newValue, userID)
if err != nil {
return err
}
if pendingExists {
return ErrUsernameTaken
}
}
// 覆盖旧的同类型 pending
existing, err := s.auditRepo.FindPendingByUserAndType(userID, auditType)
if err == nil {
reason := "用户重新提交"
existing.Status = model.AuditStatusRejected
existing.RejectReason = &reason
_ = s.auditRepo.Update(existing)
}
submission := &model.AuditSubmission{
UserID: userID,
AuditType: auditType,
NewValue: newValue,
Status: model.AuditStatusPending,
}
return s.auditRepo.Create(submission)
}
// SubmitProfileChanges 批量提交资料变更(用户名/签名),每个变更字段独立提交
// 仅提交有变更的字段,无变更的跳过
func (s *AuditService) SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error {
if newUsername != "" && newUsername != currentUser.Username {
if err := s.Submit(userID, model.AuditTypeUsername, newUsername); err != nil {
return err
}
}
if newBio != "" && newBio != currentUser.Bio {
if err := s.Submit(userID, model.AuditTypeBio, newBio); err != nil {
return err
}
}
return nil
}
// Approve 通过审核 → 更新 User 表对应字段
// 仅审核人可操作,不可审核自己的提交
func (s *AuditService) Approve(reviewerID uint, submissionID uint) error {
return s.review(reviewerID, submissionID, true, "")
}
// Reject 拒绝审核 → 记录拒绝理由
func (s *AuditService) Reject(reviewerID uint, submissionID uint, reason string) error {
return s.review(reviewerID, submissionID, false, reason)
}
// review 通用审核操作
func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool, reason string) error {
// 1. 查审核记录
submission, err := s.auditRepo.FindByID(submissionID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return ErrAuditNotFound
}
return err
}
// 2. 必须 pending
if submission.Status != model.AuditStatusPending {
return ErrAuditNotPending
}
// 3. 查审核人信息
reviewer, err := s.userRepo.FindByID(reviewerID)
if err != nil {
return common.ErrUserNotFound
}
// 5. 标记审核结果
submission.ReviewedBy = &reviewerID
submission.ReviewerName = &reviewer.Username
if approved {
submission.Status = model.AuditStatusApproved
// 更新 User 表对应字段
if err := s.applyApproval(submission); err != nil {
return err
}
// 通知用户审核通过
if s.notifier != nil {
s.notifier.NotifyAuditApproved(submission.UserID, submission.AuditType, submission.ID)
}
} else {
submission.Status = model.AuditStatusRejected
submission.RejectReason = &reason
// 通知用户审核被拒
if s.notifier != nil {
reasonMsg := reason
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
}
}
return s.auditRepo.Update(submission)
}
// applyApproval 将审核通过的内容写入 User 表
// 用户名审批前再次检查冲突(防止提交到审批期间被其他用户抢占)
func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
user, err := s.userRepo.FindByID(submission.UserID)
if err != nil {
return common.ErrUserNotFound
}
switch submission.AuditType {
case model.AuditTypeUsername:
exists, err := s.userRepo.ExistsByUsername(submission.NewValue)
if err != nil {
return err
}
if exists {
return ErrUsernameTaken
}
user.Username = submission.NewValue
case model.AuditTypeAvatar:
user.Avatar = submission.NewValue
case model.AuditTypeBio:
user.Bio = submission.NewValue
}
return s.userRepo.Update(user)
}
// List 分页查询审核列表
func (s *AuditService) List(auditType, status string, page, pageSize int) (*AuditListResult, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
total, err := s.auditRepo.CountAudits(auditType, status)
if err != nil {
return nil, err
}
items, err := s.auditRepo.ListAudits(auditType, status, offset, pageSize)
if err != nil {
return nil, err
}
if items == nil {
items = []model.AuditSubmission{}
}
return &AuditListResult{
Items: items,
Total: total,
Page: page,
}, nil
}
// GetPendingTypes 获取用户所有待审核类型(用于个人中心提示)
func (s *AuditService) GetPendingTypes(userID uint) ([]string, error) {
submissions, err := s.auditRepo.FindPendingByUserID(userID)
if err != nil {
return nil, err
}
types := make([]string, 0, len(submissions))
for _, sub := range submissions {
types = append(types, sub.AuditType)
}
return types, nil
}
// typeDefault 获取某审核类型在 config.yaml 中的默认开关值
func (s *AuditService) typeDefault(auditType string) bool {
switch auditType {
case model.AuditTypeUsername:
return s.defaultCfg.UsernameAudit
case model.AuditTypeAvatar:
return s.defaultCfg.AvatarAudit
case model.AuditTypeBio:
return s.defaultCfg.BioAudit
}
return true
}
// AuditListResult 审核列表查询结果
type AuditListResult struct {
Items []model.AuditSubmission `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
}

View File

@ -2,6 +2,7 @@ package service
import ( import (
"regexp" "regexp"
"time"
"unicode/utf8" "unicode/utf8"
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
@ -25,18 +26,21 @@ func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.
} }
var ( var (
ErrEmailExists = common.ErrEmailExists ErrEmailExists = common.ErrEmailExists
ErrUsernameTaken = common.ErrUsernameTaken ErrUsernameTaken = common.ErrUsernameTaken
ErrInvalidCred = common.ErrInvalidCred ErrInvalidCred = common.ErrInvalidCred
ErrUserNotFound = common.ErrUserNotFound ErrUserNotFound = common.ErrUserNotFound
ErrUserBanned = common.ErrUserBanned ErrUserBanned = common.ErrUserBanned
ErrUserLocked = common.ErrUserLocked ErrUserLocked = common.ErrUserLocked
ErrUserDeleted = common.ErrUserDeleted ErrUserDeleted = common.ErrUserDeleted
ErrWeakPassword = common.ErrWeakPassword ErrWeakPassword = common.ErrWeakPassword
ErrTokenExpired = common.ErrTokenExpired ErrTokenExpired = common.ErrTokenExpired
ErrTokenInvalid = common.ErrTokenInvalid ErrTokenInvalid = common.ErrTokenInvalid
ErrTokenRevoked = common.ErrTokenRevoked ErrTokenRevoked = common.ErrTokenRevoked
ErrPermissionDenied = common.ErrPermissionDenied ErrPermissionDenied = common.ErrPermissionDenied
ErrIncorrectPassword = common.ErrIncorrectPassword
ErrNeedsConfirmRestore = common.ErrNeedsConfirmRestore
ErrOwnerCannotDelete = common.ErrOwnerCannotDelete
) )
var pwLetter = regexp.MustCompile(`[a-zA-Z]`) var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
@ -68,7 +72,8 @@ func validatePassword(pw string) error {
// Register 注册 // Register 注册
// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT // 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT
// rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除 // rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除
func (s *AuthService) Register(req model.RegisterRequest) (string, string, *model.User, error) { // regIP: 注册 IP 地址
func (s *AuthService) Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) {
// 1. 密码强度 // 1. 密码强度
if err := validatePassword(req.Password); err != nil { if err := validatePassword(req.Password); err != nil {
return "", "", nil, err return "", "", nil, err
@ -102,6 +107,7 @@ func (s *AuthService) Register(req model.RegisterRequest) (string, string, *mode
Username: username, Username: username,
Role: model.RoleUser, Role: model.RoleUser,
Status: model.StatusActive, Status: model.StatusActive,
RegIP: regIP,
} }
if err := s.userRepo.Create(user); err != nil { if err := s.userRepo.Create(user); err != nil {
return "", "", nil, err return "", "", nil, err
@ -123,24 +129,25 @@ func (s *AuthService) Register(req model.RegisterRequest) (string, string, *mode
} }
// Login 登录 // Login 登录
// 流程:按邮箱查找 → 检查状态 → 验证密码 → access JWT + refresh JWT // 流程:按邮箱查找 → 已注销则验证密码后要求二次确认 → 检查状态 → 验证密码 → 记录登录 IP/时间 → access JWT + refresh JWT
// 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对 // 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对
// rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除 // rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除
func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User, error) { // loginIP: 登录 IP 地址
// 若用户处于 deleted 状态且密码正确 → 返回 ErrNeedsConfirmRestore不自动恢复不签发 token
func (s *AuthService) Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email) user, err := s.userRepo.FindByEmail(req.Email)
if err != nil { if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, ErrInvalidCred return "", "", nil, ErrInvalidCred
} }
// 已注销deleted→ 自动恢复 // 已注销deleted验证密码后要求二次确认,不自动恢复
if user.Status == model.StatusDeleted { if user.Status == model.StatusDeleted {
user.Status = model.StatusActive if !common.CheckPassword(req.Password, user.PasswordHash) {
user.DeletedAt = gorm.DeletedAt{} _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
if err := s.userRepo.Update(user); err != nil { return "", "", nil, ErrInvalidCred
return "", "", nil, err
} }
// 恢复后继续正常登录流程 return "", "", nil, ErrNeedsConfirmRestore
} }
// 永久锁定 // 永久锁定
@ -158,6 +165,14 @@ func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User
return "", "", nil, ErrInvalidCred return "", "", nil, ErrInvalidCred
} }
// 记录登录 IP 和时间
now := time.Now()
user.LastLoginIP = loginIP
user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil {
return "", "", nil, err
}
// 生成 access JWT // 生成 access JWT
accessToken, err := s.tokenService.BuildAccessToken(user) accessToken, err := s.tokenService.BuildAccessToken(user)
if err != nil { if err != nil {
@ -173,6 +188,47 @@ func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User
return accessToken, refreshToken, user, nil return accessToken, refreshToken, user, nil
} }
// ConfirmRestore 二次确认恢复已注销账号
// 流程:按邮箱查找 → 验证密码 → 恢复为 active → 记录登录 IP/时间 → 签发 token
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, ErrInvalidCred
}
// 仅 deleted 状态允许恢复
if user.Status != model.StatusDeleted {
return "", "", nil, ErrUserNotFound
}
if !common.CheckPassword(req.Password, user.PasswordHash) {
return "", "", nil, ErrInvalidCred
}
// 恢复账号
user.Status = model.StatusActive
user.DeletedAt = gorm.DeletedAt{}
now := time.Now()
user.LastLoginIP = loginIP
user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil {
return "", "", nil, err
}
accessToken, err := s.tokenService.BuildAccessToken(user)
if err != nil {
return "", "", nil, err
}
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
}
// CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查) // CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查)
func (s *AuthService) CheckEmail(email string) (bool, error) { func (s *AuthService) CheckEmail(email string) (bool, error) {
return s.userRepo.ExistsByEmail(email) return s.userRepo.ExistsByEmail(email)
@ -183,6 +239,68 @@ func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
return s.userRepo.FindByID(userID) return s.userRepo.FindByID(userID)
} }
// ChangePassword 修改密码
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 吊销所有 JWT强制重新登录
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
// 验证当前密码
if !common.CheckPassword(currentPassword, user.PasswordHash) {
return ErrIncorrectPassword
}
// 新密码强度校验
if err := validatePassword(newPassword); err != nil {
return err
}
// 哈希新密码
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
if err != nil {
return err
}
user.PasswordHash = hash
if err := s.userRepo.Update(user); err != nil {
return err
}
// 强制所有设备重新登录(安全性)
return s.InvalidateSessions(userID)
}
// DeleteAccount 用户自主注销
// 流程:检查角色 → 验证密码 → 记录原因 → 设置 deleted 状态 → 吊销所有 JWT → 7 天冷却期内登录需二次确认恢复
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
// 站长不允许自主注销(避免权限体系死锁)
if user.Role == model.RoleOwner {
return ErrOwnerCannotDelete
}
// 验证当前密码(防止 CSRF 或未授权操作)
if !common.CheckPassword(password, user.PasswordHash) {
return ErrIncorrectPassword
}
user.Status = model.StatusDeleted
user.DeleteReason = reason
if err := s.userRepo.Update(user); err != nil {
return err
}
// 递增 token_version即时吊销所有 JWT 强制退登
return s.InvalidateSessions(userID)
}
// InvalidateSessions 吊销某用户所有 JWT递增 token_version强制所有设备重新登录 // InvalidateSessions 吊销某用户所有 JWT递增 token_version强制所有设备重新登录
// 适用场景:修改密码、账号被盗、管理员强制下线 // 适用场景:修改密码、账号被盗、管理员强制下线
func (s *AuthService) InvalidateSessions(userID uint) error { func (s *AuthService) InvalidateSessions(userID uint) error {

View File

@ -36,6 +36,7 @@ const (
type userAvatarStore interface { type userAvatarStore interface {
FindByID(id uint) (*model.User, error) FindByID(id uint) (*model.User, error)
Update(user *model.User) error Update(user *model.User) error
FindByIDUnscoped(userID uint) (*model.User, error)
} }
// AvatarService 头像上传处理服务 // AvatarService 头像上传处理服务
@ -49,10 +50,27 @@ func NewAvatarService(userRepo userAvatarStore) *AvatarService {
return &AvatarService{userRepo: userRepo, storageDir: avatarStorageDir} return &AvatarService{userRepo: userRepo, storageDir: avatarStorageDir}
} }
// ProcessAvatar 处理头像上传流程 // ProcessAvatar 处理头像上传流程(完整流程:处理图片 + 更新 User
// cropX, cropY, cropSize: 自定义裁切参数(原图像素坐标),全 0 则默认中心裁切
// 返回头像 URL失败返回错误
func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) { func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) {
avatarURL, err := s.ProcessImage(userID, file, contentType, cropX, cropY, cropSize)
if err != nil {
return "", err
}
user, err := s.userRepo.FindByID(userID)
if err != nil {
return "", fmt.Errorf("用户不存在")
}
user.Avatar = avatarURL
if err := s.userRepo.Update(user); err != nil {
return "", fmt.Errorf("更新头像失败")
}
return avatarURL, nil
}
// ProcessImage 处理头像上传流程(仅处理图片 + 保存文件,不更新 User审核场景用
func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) {
// 1. 检查文件类型(不支持 GIF裁切后无法保持动画 // 1. 检查文件类型(不支持 GIF裁切后无法保持动画
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" { if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
return "", fmt.Errorf("仅支持 JPEG、PNG、WebP 格式") return "", fmt.Errorf("仅支持 JPEG、PNG、WebP 格式")
@ -127,18 +145,7 @@ func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType s
return "", fmt.Errorf("保存文件失败") return "", fmt.Errorf("保存文件失败")
} }
// 10. 更新用户头像字段 return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
user, err := s.userRepo.FindByID(userID)
if err != nil {
return "", fmt.Errorf("用户不存在")
}
avatarURL := fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts)
user.Avatar = avatarURL
if err := s.userRepo.Update(user); err != nil {
return "", fmt.Errorf("更新头像失败")
}
return avatarURL, nil
} }
// cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传) // cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传)

View File

@ -0,0 +1,112 @@
package service
import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
)
// notifRepo 通知服务所需的最小仓储接口ISP
type notifRepo interface {
Create(n *model.Notification) error
ListByUser(userID uint, offset, limit int) ([]model.Notification, error)
CountByUser(userID uint) (int64, error)
CountUnread(userID uint) (int64, error)
MarkRead(id, userID uint) error
MarkAllRead(userID uint) error
}
// NotificationService 消息/通知业务逻辑
type NotificationService struct {
repo notifRepo
}
// NewNotificationService 构造函数
func NewNotificationService(repo notifRepo) *NotificationService {
return &NotificationService{repo: repo}
}
// Create 创建消息
func (s *NotificationService) Create(userID uint, notifyType, title, content string, relatedID *uint) error {
n := &model.Notification{
UserID: userID,
NotifyType: notifyType,
Title: title,
Content: content,
IsRead: false,
RelatedID: relatedID,
}
return s.repo.Create(n)
}
// List 分页查询用户消息列表
func (s *NotificationService) List(userID uint, page, pageSize int) (*model.NotificationListResult, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
total, err := s.repo.CountByUser(userID)
if err != nil {
return nil, err
}
items, err := s.repo.ListByUser(userID, p.Offset(), p.PageSize)
if err != nil {
return nil, err
}
if items == nil {
items = []model.Notification{}
}
totalPages := int((total + int64(p.PageSize) - 1) / int64(p.PageSize))
unread, _ := s.CountUnread(userID)
return &model.NotificationListResult{
Items: items,
Total: total,
Page: p.Page,
TotalPages: totalPages,
Unread: unread,
}, nil
}
// CountUnread 统计未读消息数
func (s *NotificationService) CountUnread(userID uint) (int64, error) {
return s.repo.CountUnread(userID)
}
// MarkRead 标记单条消息为已读
func (s *NotificationService) MarkRead(id, userID uint) error {
return s.repo.MarkRead(id, userID)
}
// MarkAllRead 标记所有未读为已读
func (s *NotificationService) MarkAllRead(userID uint) error {
return s.repo.MarkAllRead(userID)
}
// NotifyAuditApproved 审核通过通知
func (s *NotificationService) NotifyAuditApproved(userID uint, auditType string, relatedID uint) {
typeName := model.AuditTypeNames[auditType]
if typeName == "" {
typeName = auditType
}
_ = s.Create(userID, model.NotifyAuditApproved,
"审核已通过",
"你的"+typeName+"修改已通过审核",
&relatedID)
}
// NotifyAuditRejected 审核拒绝通知
func (s *NotificationService) NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint) {
typeName := model.AuditTypeNames[auditType]
if typeName == "" {
typeName = auditType
}
content := "你的" + typeName + "修改被拒绝"
if reason != "" {
content += ",理由:" + reason
}
_ = s.Create(userID, model.NotifyAuditRejected,
"审核未通过",
content,
&relatedID)
}

View File

@ -0,0 +1,82 @@
package service
import (
"strconv"
)
// siteSettingsManager 站点设置服务所需的最小接口ISP
type siteSettingsManager interface {
Get(key, defaultVal string) string
GetBool(key string, defaultVal bool) bool
Set(key, value string) error
SetBool(key string, value bool) error
All() map[string]string
}
// SiteSettingService 站点设置业务逻辑
type SiteSettingService struct {
settings siteSettingsManager
}
// NewSiteSettingService 构造函数
func NewSiteSettingService(settings siteSettingsManager) *SiteSettingService {
return &SiteSettingService{settings: settings}
}
// GetSettings 获取所有站点设置(仅 owner 可见)
func (s *SiteSettingService) GetSettings() map[string]string {
return s.settings.All()
}
// UpdateSetting 更新单项设置
func (s *SiteSettingService) UpdateSetting(key, value string) error {
return s.settings.Set(key, value)
}
// UpdateBoolSetting 更新布尔设置
func (s *SiteSettingService) UpdateBoolSetting(key string, value bool) error {
return s.settings.SetBool(key, value)
}
// GetBool gets a boolean setting with a default
func (s *SiteSettingService) GetBool(key string, defaultVal bool) bool {
return s.settings.GetBool(key, defaultVal)
}
// --- 审核相关设置快捷方法 ---
// AuditSettings 审核相关站点设置集合(用于前端展示)
type AuditSettings struct {
Enabled bool `json:"enabled"`
UsernameAudit bool `json:"username_audit"`
AvatarAudit bool `json:"avatar_audit"`
BioAudit bool `json:"bio_audit"`
}
// GetAuditSettings 获取审核相关设置
func (s *SiteSettingService) GetAuditSettings(defaultCfg *AuditDefaults) *AuditSettings {
return &AuditSettings{
Enabled: s.settings.GetBool("audit.enabled", defaultCfg.Enabled),
UsernameAudit: s.settings.GetBool("audit.username_audit", defaultCfg.UsernameAudit),
AvatarAudit: s.settings.GetBool("audit.avatar_audit", defaultCfg.AvatarAudit),
BioAudit: s.settings.GetBool("audit.bio_audit", defaultCfg.BioAudit),
}
}
// AuditDefaults config.yaml 中的审核默认值
type AuditDefaults struct {
Enabled bool
UsernameAudit bool
AvatarAudit bool
BioAudit bool
}
// GetBoolSetting is a generic getter used by controllers
func (s *SiteSettingService) GetBoolSetting(key string, defaultVal bool) bool {
return s.settings.GetBool(key, defaultVal)
}
// ParseBool 解析字符串为布尔值
func ParseBool(v string) (bool, error) {
return strconv.ParseBool(v)
}

View File

@ -7,6 +7,12 @@
<ul class="nav-links" id="navLinks"> <ul class="nav-links" id="navLinks">
<li><a href="/">首页</a></li> <li><a href="/">首页</a></li>
{{if .IsLoggedIn}} {{if .IsLoggedIn}}
<li>
<a href="/messages" class="nav-messages" id="messagesBell" title="消息中心">
<svg class="nav-bell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="nav-bell-badge" id="msgBadge" style="display:none">0</span>
</a>
</li>
<li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li> <li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li>
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li> <li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
{{else}} {{else}}

View File

@ -0,0 +1,172 @@
{{template "layout/header.html" .}}
<meta name="robots" content="noindex,nofollow">
<body>
{{template "layout/nav.html" .}}
<main class="msg-layout">
<aside class="msg-sidebar">
<div class="msg-sidebar-header">
<h2>消息中心</h2>
</div>
<nav class="msg-sidebar-nav">
<span class="msg-sidebar-item active">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
全部消息
{{if .UnreadCount}}
<span class="msg-sidebar-count">{{.UnreadCount}}</span>
{{end}}
</span>
</nav>
</aside>
<div class="msg-main">
{{if .Messages}}
<div class="msg-toolbar">
{{if .UnreadCount}}
<button id="markAllReadBtn" class="msg-mark-all-btn">全部标为已读</button>
{{end}}
</div>
<div class="msg-list">
{{range $i, $m := .Messages}}
<div class="msg-card{{if not $m.IsRead}} unread{{end}}" data-id="{{$m.ID}}">
<div class="msg-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</div>
<div class="msg-body">
<div class="msg-title">{{$m.Title}}</div>
<div class="msg-content">{{$m.Content}}</div>
<div class="msg-meta">
<span class="msg-type">{{index $.NotifyTypeNames $m.NotifyType}}</span>
<span class="msg-time">{{$m.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
</div>
{{if not $m.IsRead}}
<span class="msg-badge"></span>
{{end}}
</div>
{{end}}
</div>
{{if .TotalPages}}
<div class="msg-pagination">
{{if .HasPrev}}
<a href="/messages?page={{.PrevPage}}" class="msg-page-btn">上一页</a>
{{else}}
<span class="msg-page-btn disabled">上一页</span>
{{end}}
<span class="msg-page-info">{{.Page}} / {{.TotalPages}}</span>
{{if .HasNext}}
<a href="/messages?page={{.NextPage}}" class="msg-page-btn">下一页</a>
{{else}}
<span class="msg-page-btn disabled">下一页</span>
{{end}}
</div>
{{end}}
{{else}}
<div class="msg-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48" class="msg-empty-icon"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><line x1="4" y1="17" x2="20" y2="17"/><line x1="9" y1="11" x2="10" y2="11"/><line x1="14" y1="11" x2="15" y2="11"/></svg>
<p>暂无消息</p>
</div>
{{end}}
</div>
</main>
{{template "layout/footer.html" .}}
<script>
(function () {
'use strict';
// ---- 单条标记已读 ----
var cards = document.querySelectorAll('.msg-card.unread');
for (var i = 0; i < cards.length; i++) {
(function (card) {
card.addEventListener('click', function () {
var id = card.getAttribute('data-id');
if (!id) return;
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/messages/' + id + '/read', true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
card.classList.remove('unread');
var badge = card.querySelector('.msg-badge');
if (badge) badge.remove();
updateUnreadCounts();
}
};
xhr.send();
});
})(cards[i]);
}
// ---- 全部标为已读 ----
var markAllBtn = document.getElementById('markAllReadBtn');
if (markAllBtn) {
markAllBtn.addEventListener('click', function () {
markAllBtn.disabled = true;
markAllBtn.textContent = '处理中...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/messages/read-all', true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
markAllBtn.disabled = false;
markAllBtn.textContent = '全部标为已读';
if (xhr.status === 200) {
// 本地清除所有 unread 样式
var allCards = document.querySelectorAll('.msg-card.unread');
for (var j = 0; j < allCards.length; j++) {
allCards[j].classList.remove('unread');
var b = allCards[j].querySelector('.msg-badge');
if (b) b.remove();
}
markAllBtn.remove();
updateUnreadCounts();
}
}
};
xhr.send();
});
}
// ---- 更新侧边栏和导航栏未读计数 ----
function updateUnreadCounts() {
// 更新侧边栏计数
var visibleUnread = document.querySelectorAll('.msg-card.unread').length;
var sidebarCount = document.querySelector('.msg-sidebar-count');
if (sidebarCount) {
if (visibleUnread > 0) {
sidebarCount.textContent = visibleUnread;
} else {
sidebarCount.remove();
}
} else if (visibleUnread > 0) {
var sidebarItem = document.querySelector('.msg-sidebar-item.active');
if (sidebarItem) {
var el = document.createElement('span');
el.className = 'msg-sidebar-count';
el.textContent = visibleUnread;
sidebarItem.appendChild(el);
}
}
// 更新导航栏铃铛计数
var navBadge = document.getElementById('msgBadge');
if (navBadge) {
fetch('/api/messages/unread')
.then(function (res) { return res.json(); })
.then(function (data) {
if (data && data.success && data.data) {
var count = data.data.unread || 0;
navBadge.textContent = count > 99 ? '99+' : count;
navBadge.style.display = count > 0 ? 'flex' : 'none';
}
})
.catch(function () {});
}
}
})();
</script>
<script src="/static/js/common.js"></script>
</body>
</html>

View File

@ -35,6 +35,16 @@
<h2>个人资料</h2> <h2>个人资料</h2>
<p class="settings-card-desc">你的公开信息和基本资料</p> <p class="settings-card-desc">你的公开信息和基本资料</p>
</div> </div>
{{if .PendingTypes}}
<div class="settings-audit-notice">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>
{{range $i, $t := .PendingTypes}}{{if $i}}、{{end}}{{index $.AuditTypes $t}}{{end}}审核中
</span>
</div>
{{end}}
<div class="settings-card-body"> <div class="settings-card-body">
<div class="info-row"> <div class="info-row">
<span class="info-label">用户名</span> <span class="info-label">用户名</span>
@ -103,12 +113,62 @@
<span class="info-label">注册时间</span> <span class="info-label">注册时间</span>
<span class="info-value">{{.User.CreatedAt.Format "2006-01-02 15:04:05"}}</span> <span class="info-value">{{.User.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
</div> </div>
{{if .User.LastLoginAt}}
<div class="info-row">
<span class="info-label">上次登录</span>
<span class="info-value">{{.User.LastLoginAt.Format "2006-01-02 15:04:05"}}</span>
</div>
{{end}}
<div class="info-row"> <div class="info-row">
<span class="info-label">最后更新</span> <span class="info-label">最后更新</span>
<span class="info-value">{{.User.UpdatedAt.Format "2006-01-02 15:04:05"}}</span> <span class="info-value">{{.User.UpdatedAt.Format "2006-01-02 15:04:05"}}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 修改密码 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>修改密码</h2>
<p class="settings-card-desc">修改密码后需重新登录</p>
</div>
<div class="settings-card-body">
<div class="form-group">
<label class="form-label" for="currentPassword">当前密码</label>
<input type="password" id="currentPassword" class="settings-text-input" placeholder="输入当前密码" autocomplete="current-password">
</div>
<div class="form-group">
<label class="form-label" for="newPassword">新密码</label>
<input type="password" id="newPassword" class="settings-text-input" placeholder="至少 8 位,包含字母和数字" autocomplete="new-password">
</div>
<div class="settings-card-footer">
<button id="changePwdBtn" class="settings-save-btn">修改密码</button>
<span class="form-hint">修改成功后需重新登录</span>
</div>
</div>
</div>
<!-- 注销账号 -->
<div class="settings-card settings-card-danger">
<div class="settings-card-header">
<h2>注销账号</h2>
<p class="settings-card-desc">账号将在 7 天后正式注销,期间可随时重新登录恢复</p>
</div>
<div class="settings-card-body">
<div class="form-group">
<label class="form-label" for="deletePassword">确认密码</label>
<input type="password" id="deletePassword" class="settings-text-input" placeholder="输入密码确认" autocomplete="current-password">
</div>
<div class="form-group">
<label class="form-label" for="deleteReason">注销原因(选填)</label>
<textarea id="deleteReason" class="settings-textarea" maxlength="500" placeholder="可以告诉我们为什么要离开吗?"></textarea>
</div>
<div class="settings-card-footer">
<button id="deleteAccountBtn" class="settings-danger-btn">确认注销</button>
<span class="form-hint">此操作不可逆,请谨慎处理</span>
</div>
</div>
</div>
{{end}} {{end}}
</main> </main>
</div> </div>
@ -138,11 +198,6 @@
(function () { (function () {
'use strict'; 'use strict';
var usernameInput = document.getElementById('usernameInput');
var bioInput = document.getElementById('bioInput');
var btn = document.getElementById('saveProfileBtn');
if (!usernameInput || !bioInput || !btn) return;
var toastEl = document.getElementById('settingsToast'); var toastEl = document.getElementById('settingsToast');
var toastTimer = null; var toastTimer = null;
@ -158,86 +213,93 @@
}, 5000); }, 5000);
} }
// ---- 字符计数器 ---- // ===== 个人资料 Tab =====
var usernameCounter = document.getElementById('usernameCounter'); (function () {
var bioCounterEl = document.getElementById('bioCounter'); var usernameInput = document.getElementById('usernameInput');
var bioInput = document.getElementById('bioInput');
var btn = document.getElementById('saveProfileBtn');
if (!usernameInput || !bioInput || !btn) return;
function updateCounters() { // ---- 字符计数器 ----
if (usernameCounter) { var usernameCounter = document.getElementById('usernameCounter');
usernameCounter.textContent = usernameInput.value.length + '/16'; var bioCounterEl = document.getElementById('bioCounter');
}
if (bioCounterEl) {
bioCounterEl.textContent = bioInput.value.length + '/128';
}
}
usernameInput.addEventListener('input', updateCounters); function updateCounters() {
bioInput.addEventListener('input', updateCounters); if (usernameCounter) {
updateCounters(); usernameCounter.textContent = usernameInput.value.length + '/16';
// ---- 原始值(用于判断是否变更) ----
var origUsername = usernameInput.value.trim();
var origBio = bioInput.value;
// 与后端一致的合法字符集:中文、英文大小写、数字、下划线、连字符
var usernameRe = /^[\u4e00-\u9fffa-zA-Z0-9_-]+$/;
btn.addEventListener('click', function () {
var username = usernameInput.value.trim();
var bio = bioInput.value;
// 用户名校验
if (!username) {
showSettingsToast('用户名不能为空', true);
return;
}
if (username.length > 16) {
showSettingsToast('用户名不能超过 16 个字符', true);
return;
}
if (!usernameRe.test(username)) {
showSettingsToast('用户名仅支持中英文、数字、下划线、连字符', true);
return;
}
// 个性签名校验
if (bio.length > 128) {
showSettingsToast('个性签名不能超过 128 个字符', true);
return;
}
// 无变更判断
if (username === origUsername && bio === origBio) {
showSettingsToast('内容未变更,无需保存');
return;
}
btn.disabled = true;
btn.textContent = '保存中...';
var xhr = new XMLHttpRequest();
xhr.open('PUT', '/api/settings/profile', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
btn.disabled = false;
btn.textContent = '保存';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast(res.message || '个人资料已更新');
origUsername = username;
origBio = bio;
} else {
showSettingsToast(res.message || '保存失败', true);
}
} catch (e) {
showSettingsToast('保存失败', true);
} }
}; if (bioCounterEl) {
xhr.send(JSON.stringify({ username: username, bio: bio })); bioCounterEl.textContent = bioInput.value.length + '/128';
}); }
}
usernameInput.addEventListener('input', updateCounters);
bioInput.addEventListener('input', updateCounters);
updateCounters();
// ---- 原始值(用于判断是否变更) ----
var origUsername = usernameInput.value.trim();
var origBio = bioInput.value;
// 与后端一致的合法字符集:中文、英文大小写、数字、下划线、连字符
var usernameRe = /^[\u4e00-\u9fffa-zA-Z0-9_-]+$/;
btn.addEventListener('click', function () {
var username = usernameInput.value.trim();
var bio = bioInput.value;
// 用户名校验
if (!username) {
showSettingsToast('用户名不能为空', true);
return;
}
if (username.length > 16) {
showSettingsToast('用户名不能超过 16 个字符', true);
return;
}
if (!usernameRe.test(username)) {
showSettingsToast('用户名仅支持中英文、数字、下划线、连字符', true);
return;
}
// 个性签名校验
if (bio.length > 128) {
showSettingsToast('个性签名不能超过 128 个字符', true);
return;
}
// 无变更判断
if (username === origUsername && bio === origBio) {
showSettingsToast('内容未变更,无需保存');
return;
}
btn.disabled = true;
btn.textContent = '保存中...';
var xhr = new XMLHttpRequest();
xhr.open('PUT', '/api/settings/profile', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
btn.disabled = false;
btn.textContent = '保存';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast(res.message || '个人资料已更新');
origUsername = username;
origBio = bio;
} else {
showSettingsToast(res.message || '保存失败', true);
}
} catch (e) {
showSettingsToast('保存失败', true);
}
};
xhr.send(JSON.stringify({ username: username, bio: bio }));
});
// ---- 头像裁切上传 ---- // ---- 头像裁切上传 ----
var avatarWrap = document.getElementById('avatarWrap'); var avatarWrap = document.getElementById('avatarWrap');
@ -435,8 +497,8 @@
try { try {
var res = JSON.parse(xhr.responseText); var res = JSON.parse(xhr.responseText);
if (res.success) { if (res.success) {
showSettingsToast('头像已更新'); showSettingsToast(res.message || '头像已更新');
if (avatarPreview.tagName === 'IMG') { if (res.data && res.data.url && avatarPreview.tagName === 'IMG') {
avatarPreview.src = res.data.url + '?t=' + Date.now(); avatarPreview.src = res.data.url + '?t=' + Date.now();
} }
} else { } else {
@ -460,6 +522,99 @@
openCrop(file); openCrop(file);
}); });
} }
})(); // 结束个人资料 Tab 子 IIFE
// ---- 修改密码 ----
var currentPwd = document.getElementById('currentPassword');
var newPwd = document.getElementById('newPassword');
var changePwdBtn = document.getElementById('changePwdBtn');
if (currentPwd && newPwd && changePwdBtn) {
changePwdBtn.addEventListener('click', function () {
if (!currentPwd.value) {
showSettingsToast('请输入当前密码', true);
return;
}
if (!newPwd.value || !/[a-zA-Z]/.test(newPwd.value) || !/\d/.test(newPwd.value) || newPwd.value.length < 8) {
showSettingsToast('新密码至少 8 位,包含字母和数字', true);
return;
}
changePwdBtn.disabled = true;
changePwdBtn.textContent = '修改中...';
var xhr = new XMLHttpRequest();
xhr.open('PUT', '/api/settings/password', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
changePwdBtn.disabled = false;
changePwdBtn.textContent = '修改密码';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast('密码已修改,请重新登录');
currentPwd.value = '';
newPwd.value = '';
// 延时后执行登出并跳转到首页
setTimeout(function () {
window.location.href = '/';
}, 2000);
} else {
showSettingsToast(res.message || '修改失败', true);
}
} catch (e) {
showSettingsToast('修改失败', true);
}
};
xhr.send(JSON.stringify({
current_password: currentPwd.value,
new_password: newPwd.value
}));
});
}
// ---- 注销账号 ----
var deletePwd = document.getElementById('deletePassword');
var deleteReason = document.getElementById('deleteReason');
var deleteAccountBtn = document.getElementById('deleteAccountBtn');
if (deletePwd && deleteAccountBtn) {
deleteAccountBtn.addEventListener('click', function () {
if (!deletePwd.value) {
showSettingsToast('请输入密码确认', true);
return;
}
if (!confirm('确定要注销账号吗?\n\n注销后 7 天内重新登录可自动恢复,期满后将永久删除。')) {
return;
}
deleteAccountBtn.disabled = true;
deleteAccountBtn.textContent = '注销中...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/settings/delete-account', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
deleteAccountBtn.disabled = false;
deleteAccountBtn.textContent = '确认注销';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast(res.message || '账号已注销');
setTimeout(function () {
window.location.href = '/';
}, 2000);
} else {
showSettingsToast(res.message || '操作失败', true);
}
} catch (e) {
showSettingsToast('操作失败', true);
}
};
xhr.send(JSON.stringify({
password: deletePwd.value,
reason: deleteReason ? deleteReason.value : ''
}));
});
}
})(); })();
</script> </script>
</body> </body>

View File

@ -130,6 +130,45 @@ header {
color: var(--color-secondary) !important; color: var(--color-secondary) !important;
} }
/* ---------- Nav Messages Bell ---------- */
.nav-messages {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 50%;
transition: background .15s ease;
}
.nav-messages:hover {
background: rgba(52, 152, 219, .08);
}
.nav-bell-icon {
width: 20px;
height: 20px;
display: block;
}
.nav-bell-badge {
position: absolute;
top: 0;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: .65rem;
font-weight: 700;
color: #fff;
background: var(--color-danger);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.mobile-menu-btn { .mobile-menu-btn {
display: none; display: none;
background: none; background: none;

View File

@ -0,0 +1,294 @@
/* ============================================================
MetaLab Messages Styles — 消息中心页样式
左侧导航 + 右侧消息卡片列表
============================================================ */
/* ---------- Page Body ---------- */
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ---------- Layout ---------- */
.msg-layout {
display: flex;
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 2rem 20px;
gap: 2rem;
}
/* ---------- Left Sidebar ---------- */
.msg-sidebar {
width: 220px;
flex-shrink: 0;
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
padding: 1.25rem 1rem;
align-self: flex-start;
}
.msg-sidebar-header {
margin-bottom: .75rem;
}
.msg-sidebar-header h2 {
font-size: 1rem;
font-weight: 600;
color: var(--color-primary);
}
.msg-sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.msg-sidebar-item {
display: flex;
align-items: center;
gap: .6rem;
padding: .65rem 1rem;
border-radius: var(--radius);
font-size: .92rem;
color: var(--color-secondary);
font-weight: 500;
transition: all .15s ease;
}
.msg-sidebar-item:hover {
background: rgba(52, 152, 219, .06);
color: var(--color-accent);
}
.msg-sidebar-item.active {
background: rgba(52, 152, 219, .1);
color: var(--color-accent);
font-weight: 600;
}
.msg-sidebar-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.msg-sidebar-count {
margin-left: auto;
min-width: 20px;
height: 20px;
padding: 0 5px;
font-size: .7rem;
font-weight: 700;
color: #fff;
background: var(--color-accent);
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* ---------- Right Content Area ---------- */
.msg-main {
flex: 1;
min-width: 0;
}
/* ---------- Toolbar ---------- */
.msg-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: .75rem;
}
.msg-mark-all-btn {
display: inline-flex;
align-items: center;
padding: .35rem .9rem;
font-size: .82rem;
font-weight: 500;
font-family: inherit;
color: var(--color-accent);
background: transparent;
border: 1px solid var(--color-accent);
border-radius: 6px;
cursor: pointer;
transition: all .15s ease;
}
.msg-mark-all-btn:hover {
background: var(--color-accent);
color: #fff;
}
.msg-mark-all-btn:disabled {
opacity: .5;
cursor: not-allowed;
}
/* ---------- Right Content Area ---------- */
.msg-main {
flex: 1;
min-width: 0;
}
/* ---------- Message Card ---------- */
.msg-card {
display: flex;
align-items: flex-start;
gap: .9rem;
padding: 1rem 1.25rem;
background: var(--color-surface);
border-radius: var(--radius);
border: 1px solid var(--color-border);
margin-bottom: .6rem;
transition: background .15s ease, border-color .15s ease;
position: relative;
cursor: pointer;
}
.msg-card:hover {
border-color: var(--color-accent);
background: #f8fafe;
}
.msg-card.unread {
background: #f0f6fe;
border-left: 3px solid var(--color-accent);
cursor: pointer;
}
.msg-icon {
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(52, 152, 219, .1);
border-radius: 8px;
color: var(--color-accent);
margin-top: 2px;
}
.msg-body {
flex: 1;
min-width: 0;
}
.msg-title {
font-size: .92rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: .25rem;
}
.msg-content {
font-size: .85rem;
color: var(--color-secondary);
line-height: 1.55;
margin-bottom: .4rem;
}
.msg-meta {
display: flex;
gap: 1rem;
font-size: .78rem;
color: #aaa;
}
.msg-type {
color: rgba(52, 152, 219, .7);
}
.msg-badge {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-accent);
flex-shrink: 0;
margin-top: 8px;
}
/* ---------- Pagination ---------- */
.msg-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: .75rem;
padding: 1.5rem 0;
}
.msg-page-btn {
display: inline-flex;
align-items: center;
padding: .4rem 1rem;
font-size: .85rem;
color: var(--color-accent);
border: 1px solid var(--color-accent);
border-radius: 6px;
font-weight: 500;
transition: all .15s ease;
}
.msg-page-btn:hover {
background: var(--color-accent);
color: #fff;
}
.msg-page-btn.disabled {
color: #ccc;
border-color: #ddd;
cursor: not-allowed;
pointer-events: none;
}
.msg-page-info {
font-size: .85rem;
color: var(--color-text-light);
}
/* ---------- Empty State ---------- */
.msg-empty {
text-align: center;
padding: 4rem 2rem;
color: #bbb;
}
.msg-empty-icon {
margin-bottom: 1rem;
color: #ddd;
}
.msg-empty p {
font-size: .92rem;
color: #aaa;
}
/* ---------- Responsive ---------- */
@media (max-width: 768px) {
.msg-layout {
flex-direction: column;
padding: 1rem 16px;
gap: 1rem;
}
.msg-sidebar {
width: 100%;
align-self: auto;
}
.msg-sidebar-nav {
flex-direction: row;
}
.msg-card {
padding: .85rem 1rem;
}
}

View File

@ -131,6 +131,18 @@ body {
margin-bottom: 0; margin-bottom: 0;
} }
/* 审核状态提示 */
.settings-audit-notice {
display: flex;
align-items: center;
gap: 8px;
padding: 0.75rem 2rem;
background: #fff8e1;
border-bottom: 1px solid #ffe082;
color: #e65100;
font-size: .85rem;
}
.settings-card-body { .settings-card-body {
padding: 1.25rem 2rem 1.5rem; padding: 1.25rem 2rem 1.5rem;
} }
@ -495,3 +507,64 @@ body {
padding: 1rem 1.25rem 1.25rem; padding: 1rem 1.25rem 1.25rem;
} }
} }
/* ---------- Form Group (Password / Delete) ---------- */
.settings-card + .settings-card {
margin-top: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group:last-of-type {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: .85rem;
color: var(--color-text-light);
font-weight: 500;
margin-bottom: .35rem;
}
.form-hint {
font-size: .8rem;
color: #bbb;
margin-left: .75rem;
}
/* ---------- Danger Card (Delete Account) ---------- */
.settings-card-danger {
border: 1px solid #ffcdd2;
}
.settings-card-danger .settings-card-header h2 {
color: var(--color-danger);
}
.settings-danger-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: .55rem 1.5rem;
font-size: .88rem;
font-weight: 600;
font-family: inherit;
color: #fff;
background: var(--color-danger);
border: none;
border-radius: 6px;
cursor: pointer;
transition: background .15s ease, opacity .15s ease;
}
.settings-danger-btn:hover {
background: #c0392b;
}
.settings-danger-btn:disabled {
opacity: .6;
cursor: not-allowed;
}

View File

@ -137,6 +137,28 @@
}); });
} }
// ---- Unread message badge polling ----
var msgBadge = document.getElementById('msgBadge');
if (msgBadge) {
var POLL_INTERVAL = 60 * 1000; // 1 minute
function pollUnread() {
fetch('/api/messages/unread')
.then(function (res) {
if (!res.ok) return;
return res.json();
})
.then(function (data) {
if (!data || !data.success) return;
var count = data.data && data.data.unread ? data.data.unread : 0;
msgBadge.textContent = count > 99 ? '99+' : count;
msgBadge.style.display = count > 0 ? 'flex' : 'none';
})
.catch(function () {});
}
pollUnread();
setInterval(pollUnread, POLL_INTERVAL);
}
// ---- Auto-refresh token (silent) ---- // ---- Auto-refresh token (silent) ----
// 场景 A页面开着时每 10 分钟静默续期(保持 access token 不过期) // 场景 A页面开着时每 10 分钟静默续期(保持 access token 不过期)
// 场景 B关浏览器 15+ min 后回来access token 过期但 refresh 仍有效 // 场景 B关浏览器 15+ min 后回来access token 过期但 refresh 仍有效

View File

@ -64,7 +64,12 @@
var resp; var resp;
try { resp = JSON.parse(xhr.responseText); } catch (err) { resp = null; } try { resp = JSON.parse(xhr.responseText); } catch (err) { resp = null; }
if (xhr.status === 200 && resp && resp.success) { if (xhr.status === 200 && resp && resp.success && resp.action === 'confirm_restore') {
// 注销账号登录 → 二次确认恢复
if (confirm(resp.message + '\n\n确认继续登录将中止注销流程。')) {
doConfirmRestore(email, password);
}
} else if (xhr.status === 200 && resp && resp.success) {
window.MetaLab.toast(toast, '登录成功', false); window.MetaLab.toast(toast, '登录成功', false);
setTimeout(function () { setTimeout(function () {
window.location.href = '/'; window.location.href = '/';
@ -75,6 +80,29 @@
} }
} }
}; };
xhr.send(JSON.stringify({ email: email, password: password, remember_me: rememberMeCheckbox.checked })); xhr.send(JSON.stringify({ email: email, password: password, remember_me: rememberMeCheckbox.checked }));
}
function doConfirmRestore(email, password) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/confirm-restore', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
var resp;
try { resp = JSON.parse(xhr.responseText); } catch (err) { resp = null; }
if (xhr.status === 200 && resp && resp.success) {
window.MetaLab.toast(toast, resp.message || '账号已恢复,欢迎回来', false);
setTimeout(function () {
window.location.href = '/';
}, 800);
} else {
var msg = (resp && resp.message) ? resp.message : '操作失败,请稍后重试';
window.MetaLab.toast(toast, msg, true);
}
}
};
xhr.send(JSON.stringify({ email: email, password: password, remember_me: rememberMeCheckbox.checked }));
} }
})(); })();

View File

@ -0,0 +1,110 @@
{{template "admin/layout/base.html" .}}
<div class="page-header">
<h1>审核管理</h1>
<p class="page-desc">审核用户提交的用户名、头像、个性签名修改</p>
</div>
<!-- 审核状态筛选 + TAB -->
<div class="filter-bar">
<div class="audit-tabs">
<button class="audit-tab active" data-tab="username">用户名</button>
<button class="audit-tab" data-tab="avatar">头像</button>
<button class="audit-tab" data-tab="bio">个性签名</button>
<button class="audit-tab" data-tab="history">审核记录</button>
</div>
</div>
<!-- 用户名审核(表格) -->
<div class="audit-panel active" id="panel-username">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>提交者</th>
<th>当前用户名</th>
<th>新用户名</th>
<th>提交时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="usernameTableBody">
<tr class="table-loading"><td colspan="5">加载中...</td></tr>
</tbody>
</table>
</div>
<div class="pagination-bar" id="usernamePagination"></div>
</div>
<!-- 头像审核(卡片网格) -->
<div class="audit-panel" id="panel-avatar">
<div class="avatar-audit-grid" id="avatarGrid">
<div class="table-loading-full">加载中...</div>
</div>
<div class="pagination-bar" id="avatarPagination"></div>
</div>
<!-- 签名审核(表格) -->
<div class="audit-panel" id="panel-bio">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>提交者</th>
<th>新签名</th>
<th>提交时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="bioTableBody">
<tr class="table-loading"><td colspan="4">加载中...</td></tr>
</tbody>
</table>
</div>
<div class="pagination-bar" id="bioPagination"></div>
</div>
<!-- 审核记录(全部状态) -->
<div class="audit-panel" id="panel-history">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>提交者</th>
<th>内容</th>
<th>状态</th>
<th>审核人</th>
<th>提交时间</th>
</tr>
</thead>
<tbody id="historyTableBody">
<tr class="table-loading"><td colspan="6">加载中...</td></tr>
</tbody>
</table>
</div>
<div class="pagination-bar" id="historyPagination"></div>
</div>
<!-- 拒绝理由弹窗 -->
<div class="modal-overlay" id="rejectModal" style="display:none;">
<div class="modal-box">
<h3>拒绝审核</h3>
<p>请填写拒绝理由:</p>
<textarea id="rejectReason" rows="3" maxlength="500" placeholder="请输入拒绝理由..."></textarea>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeRejectModal()">取消</button>
<button class="btn btn-danger" id="confirmRejectBtn">确认拒绝</button>
</div>
</div>
</div>
<script>
window.__AUDIT_TYPES = {
{{range $val, $label := .AuditTypes}}"{{$val}}": "{{$label}}",
{{end}}
};
window.__AUDIT_STATUS = {
{{range $val, $label := .AuditStatus}}"{{$val}}": "{{$label}}",
{{end}}
};
</script>
{{template "admin/layout/footer.html" .}}

View File

@ -33,6 +33,12 @@
用户管理 用户管理
</a> </a>
{{end}} {{end}}
{{if .CanManageAudits}}
<a href="/admin/audits" class="sidebar-item {{if eq .CurrentPath "/admin/audits"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="m9 14 2 2 4-4"/></svg></span>
审核管理
</a>
{{end}}
</nav> </nav>
</aside> </aside>
<main class="admin-main"> <main class="admin-main">

View File

@ -0,0 +1,185 @@
/* =====================================================
MetaLab 管理面板 - 审核管理 CSS
===================================================== */
/* TAB 切换 */
.audit-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid #e9ecef;
}
.audit-tab {
padding: 10px 24px;
border: none;
background: none;
color: #636e72;
font-size: 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color .2s, border-color .2s;
}
.audit-tab:hover {
color: #2d3436;
}
.audit-tab.active {
color: #0984e3;
border-bottom-color: #0984e3;
font-weight: 600;
}
/* 审核面板显隐 */
.audit-panel {
display: none;
}
.audit-panel.active {
display: block;
}
/* 头像审核网格 */
.avatar-audit-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.avatar-audit-card {
background: #fff;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.avatar-audit-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: #636e72;
}
.avatar-audit-card .card-header strong {
color: #2d3436;
}
.avatar-audit-card .avatar-compare {
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
}
.avatar-audit-card .avatar-compare img {
width: 80px;
height: 80px;
border-radius: 8px;
object-fit: cover;
border: 2px solid #e9ecef;
}
.avatar-audit-card .avatar-compare .arrow {
font-size: 24px;
color: #0984e3;
flex-shrink: 0;
}
.avatar-audit-card .card-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* 表格内长文本截断 */
.col-long-text {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 审核记录状态标签 */
.audit-status-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.audit-status-tag.pending {
background: #fff3cd;
color: #856404;
}
.audit-status-tag.approved {
background: #d4edda;
color: #155724;
}
.audit-status-tag.rejected {
background: #f8d7da;
color: #721c24;
}
/* 全屏加载 */
.table-loading-full {
text-align: center;
padding: 40px;
color: #636e72;
grid-column: 1 / -1;
}
/* 拒绝弹窗 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal-box {
background: #fff;
border-radius: 12px;
padding: 28px;
width: 420px;
max-width: 90vw;
box-shadow: 0 8px 30px rgba(0,0,0,.15);
}
.modal-box h3 {
margin: 0 0 8px;
font-size: 18px;
}
.modal-box p {
color: #636e72;
margin: 0 0 12px;
font-size: 14px;
}
.modal-box textarea {
width: 100%;
padding: 10px;
border: 1px solid #dcdde1;
border-radius: 6px;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}

View File

@ -0,0 +1,260 @@
/* =====================================================
MetaLab 管理面板 - 审核管理 JS
单一职责:审核列表 TAB 切换、分页、审批操作
===================================================== */
let currentTab = 'username';
const pageSize = 20;
const pages = {
username: 1,
avatar: 1,
bio: 1,
history: 1
};
let pendingRejectId = null;
document.addEventListener('DOMContentLoaded', () => {
// TAB 切换
document.querySelectorAll('.audit-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.audit-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentTab = tab.dataset.tab;
document.querySelectorAll('.audit-panel').forEach(p => p.classList.remove('active'));
document.getElementById('panel-' + currentTab).classList.add('active');
loadCurrentTab();
});
});
// 初始加载
loadCurrentTab();
});
/* =========================================
数据加载
========================================= */
function loadCurrentTab() {
if (currentTab === 'history') {
loadHistory();
} else if (currentTab === 'avatar') {
loadAvatarAudits();
} else {
loadTableAudits();
}
}
function loadTableAudits() {
const page = pages[currentTab];
api.get(`/api/admin/audits?type=${currentTab}&page=${page}&page_size=${pageSize}`)
.then(res => {
if (res.success) {
renderTable(res.data.items, currentTab + 'TableBody');
renderPagination(res.data.total, res.data.page, currentTab + 'Pagination');
} else {
showToast(res.message || '加载失败', 'error');
}
})
.catch(() => showToast('网络错误', 'error'));
}
function loadAvatarAudits() {
const page = pages['avatar'];
api.get(`/api/admin/audits?type=avatar&page=${page}&page_size=${pageSize}`)
.then(res => {
if (res.success) {
renderAvatarCards(res.data.items);
renderPagination(res.data.total, res.data.page, 'avatarPagination');
} else {
showToast(res.message || '加载失败', 'error');
}
})
.catch(() => showToast('网络错误', 'error'));
}
function loadHistory() {
const page = pages['history'];
api.get(`/api/admin/audits?page=${page}&page_size=${pageSize}&status=`)
.then(res => {
if (res.success) {
renderHistoryTable(res.data.items);
renderPagination(res.data.total, res.data.page, 'historyPagination');
} else {
showToast(res.message || '加载失败', 'error');
}
})
.catch(() => showToast('网络错误', 'error'));
}
/* =========================================
表格渲染(用户名/签名)
========================================= */
function renderTable(items, bodyId) {
const tbody = document.getElementById(bodyId);
if (!items || items.length === 0) {
const cols = currentTab === 'bio' ? 4 : 5;
tbody.innerHTML = `<tr><td colspan="${cols}" style="text-align:center;padding:40px;color:#636e72;">没有待审核记录</td></tr>`;
return;
}
tbody.innerHTML = items.map(item => {
if (currentTab === 'bio') {
return renderBioRow(item);
}
return renderUsernameRow(item);
}).join('');
}
function renderUsernameRow(item) {
return `
<tr>
<td>${escapeHtml(item.submitter_username || '—')}</td>
<td>—</td>
<td><strong>${escapeHtml(item.new_value)}</strong></td>
<td>${formatTime(item.created_at)}</td>
<td class="col-actions">
<button class="btn btn-primary btn-sm" onclick="approveAudit(${item.uid})">通过</button>
<button class="btn btn-outline btn-sm" onclick="openRejectModal(${item.uid})">拒绝</button>
</td>
</tr>`;
}
function renderBioRow(item) {
const text = item.new_value || '';
const display = text.length > 40 ? text.substring(0, 40) + '...' : text;
return `
<tr>
<td>${escapeHtml(item.submitter_username || '—')}</td>
<td title="${escapeHtml(text)}">${escapeHtml(display)}</td>
<td>${formatTime(item.created_at)}</td>
<td class="col-actions">
<button class="btn btn-primary btn-sm" onclick="approveAudit(${item.uid})">通过</button>
<button class="btn btn-outline btn-sm" onclick="openRejectModal(${item.uid})">拒绝</button>
</td>
</tr>`;
}
/* =========================================
头像卡片渲染
========================================= */
function renderAvatarCards(items) {
const grid = document.getElementById('avatarGrid');
if (!items || items.length === 0) {
grid.innerHTML = '<div class="table-loading-full">没有待审核头像</div>';
return;
}
grid.innerHTML = items.map(item => `
<div class="avatar-audit-card">
<div class="card-header">
<span>提交者: <strong>${escapeHtml(item.submitter_username || '—')}</strong></span>
<span>${formatTime(item.created_at)}</span>
</div>
<div class="avatar-compare">
<span>新头像 →</span>
<img src="${escapeHtml(item.new_value)}" alt="新头像" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 80 80%22><rect fill=%22%23eee%22 width=%2280%22 height=%2280%22/><text x=%2240%22 y=%2245%22 text-anchor=%22middle%22 fill=%22%23999%22 font-size=%2212%22>加载失败</text></svg>'">
</div>
<div class="card-actions">
<button class="btn btn-primary btn-sm" onclick="approveAudit(${item.uid})">通过</button>
<button class="btn btn-outline btn-sm" onclick="openRejectModal(${item.uid})">拒绝</button>
</div>
</div>
`).join('');
}
/* =========================================
审核记录渲染
========================================= */
function renderHistoryTable(items) {
const tbody = document.getElementById('historyTableBody');
if (!items || items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:#636e72;">没有审核记录</td></tr>';
return;
}
tbody.innerHTML = items.map(item => {
const typeLabel = window.__AUDIT_TYPES[item.audit_type] || item.audit_type;
const statusLabel = window.__AUDIT_STATUS[item.status] || item.status;
const statusClass = item.status;
const content = item.new_value.length > 40 ? item.new_value.substring(0, 40) + '...' : item.new_value;
const reviewer = item.reviewer_name || '—';
return `
<tr>
<td>${typeLabel}</td>
<td>${escapeHtml(item.submitter_username || '—')}</td>
<td>${escapeHtml(content)}</td>
<td><span class="audit-status-tag ${statusClass}">${statusLabel}</span></td>
<td>${escapeHtml(reviewer)}</td>
<td>${formatTime(item.created_at)}</td>
</tr>`;
}).join('');
}
/* =========================================
审核操作
========================================= */
async function approveAudit(id) {
const ok = await showConfirm('通过审核', '确定要通过此审核吗?通过后将立即生效。');
if (!ok) return;
const res = await api.post('/api/admin/audits/' + id + '/approve');
if (res.success) {
showToast(res.message, 'success');
loadCurrentTab();
} else {
showToast(res.message || '操作失败', 'error');
}
}
function openRejectModal(id) {
pendingRejectId = id;
document.getElementById('rejectReason').value = '';
document.getElementById('rejectModal').style.display = 'flex';
}
function closeRejectModal() {
pendingRejectId = null;
document.getElementById('rejectModal').style.display = 'none';
}
document.getElementById('confirmRejectBtn').addEventListener('click', async () => {
const reason = document.getElementById('rejectReason').value.trim();
if (!reason) {
showToast('请填写拒绝理由', 'error');
return;
}
const res = await api.post('/api/admin/audits/' + pendingRejectId + '/reject', { reason });
if (res.success) {
showToast(res.message, 'success');
closeRejectModal();
loadCurrentTab();
} else {
showToast(res.message || '操作失败', 'error');
}
});
// 点击遮罩关闭弹窗
document.getElementById('rejectModal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeRejectModal();
});
/* =========================================
分页
========================================= */
function renderPagination(total, page, barId) {
const totalPages = Math.ceil(total / pageSize);
const bar = document.getElementById(barId);
if (totalPages <= 1) { bar.innerHTML = ''; return; }
let html = `<button onclick="goPage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>上一页</button>`;
html += `<span class="page-current">第 ${page} / ${totalPages} 页(共 ${total} 条)</span>`;
html += `<button onclick="goPage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>下一页</button>`;
bar.innerHTML = html;
}
function goPage(p) {
pages[currentTab] = p;
loadCurrentTab();
window.scrollTo({ top: 0 });
}