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 {
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)
}
@ -64,8 +64,14 @@ func main() {
r.Static("/shared/static", "./templates/shared/static")
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)

View File

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

View File

@ -22,4 +22,16 @@ var (
ErrRateLimitIP = errors.New("请求过于频繁,请稍后重试")
ErrRateLimitRegisterIP = 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["CanManageUsers"] = model.HasMinRole(roleStr, model.RoleAdmin)
data["CanManageAudits"] = model.HasMinRole(roleStr, model.RoleAdmin)
data["CanManageSettings"] = 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"`
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
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 {

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

View File

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

View File

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

View File

@ -5,10 +5,11 @@ import (
"metazone.cc/metalab/internal/model"
)
// authUseCase AuthController 对 AuthService 的最小依赖ISP3 个方法)
// authUseCase AuthController 对 AuthService 的最小依赖ISP4 个方法)
type authUseCase interface {
Register(req model.RegisterRequest) (string, string, *model.User, error)
Login(req model.LoginRequest) (string, string, *model.User, error)
Register(req model.RegisterRequest, regIP string) (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)
}

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"
)
// profileProvider SettingsController 对 Service 层的最小依赖ISP2 个方法)
// profileProvider SettingsController 对 Service 层的最小依赖ISP4 个方法)
type profileProvider interface {
GetProfile(userID uint) (*model.User, 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 {
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 个人设置控制器
type SettingsController struct {
authService profileProvider
avatarService avatarProvider
auditService auditSubmittable
}
// NewSettingsController 构造函数
func NewSettingsController(authService profileProvider, avatarService avatarProvider) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService}
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService}
}
// SettingsPage 个人设置页面(需登录)
@ -55,17 +67,23 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
return
}
// 查询待审核类型(用于前端显示审核提示)
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab,
"User": user,
"RoleName": model.RoleDisplayNames[user.Role],
"StatusName": model.StatusDisplayNames[user.Status],
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab,
"User": user,
"RoleName": model.RoleDisplayNames[user.Role],
"StatusName": model.StatusDisplayNames[user.Status],
"PendingTypes": pendingTypes,
"AuditTypes": model.AuditTypeNames,
}))
}
// UpdateProfile 修改个人资料(用户名 + 个性签名,需登录)
// 普通用户走审核流程,管理员及以上直接落库
func (sc *SettingsController) UpdateProfile(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
@ -82,7 +100,31 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
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)
return
}
@ -91,6 +133,7 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
}
// UploadAvatar 上传头像需登录multipart/form-data
// 普通用户走审核流程,管理员及以上直接更新
func (sc *SettingsController) UploadAvatar(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
@ -110,7 +153,32 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
cropY, _ := strconv.Atoi(c.PostForm("crop_y"))
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 {
common.Error(c, http.StatusBadRequest, err.Error())
return
@ -119,6 +187,56 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
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 层错误
func handleSettingsError(c *gin.Context, err error) {
switch err {
@ -128,7 +246,44 @@ func handleSettingsError(c *gin.Context, err error) {
common.Error(c, http.StatusBadRequest, err.Error())
case common.ErrBioTooLong:
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:
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
}
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)
c.Next()
}
@ -93,19 +93,27 @@ func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, erro
tokenVer := int(claims["ver"].(float64))
currentVer, err := am.userRepo.FindTokenVersion(uid)
if err != nil || tokenVer != currentVer {
if tokenVer != currentVer {
log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
}
if err != nil {
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
}
// injectUserContext 将 JWT claims 中的用户信息注入 gin context
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("username", claims["username"])
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 {
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
import "time"
// User 用户模型
type User struct {
BaseModel
@ -12,7 +14,13 @@ type User struct {
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"`
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
}
// FindByEmail 按邮箱查找用户
// FindByEmail 按邮箱查找用户(含软删除,用于登录等需要找到已注销用户的场景)
func (r *UserRepo) FindByEmail(email string) (*model.User, error) {
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 {
return nil, err
}

View File

@ -17,7 +17,7 @@ import (
)
// 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())
@ -28,14 +28,35 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
rateLimiter := middleware.NewRateLimiter()
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
avatarSvc := service.NewAvatarService(userRepo)
settingsCtrl := controller.NewSettingsController(authService, avatarSvc)
authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
// 管理后台
adminService := service.NewAdminService(userRepo)
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 被豁免) ---
pages := r.Group("/")
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")
})
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.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 跳首页) ---
@ -95,6 +133,8 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
adminPages.GET("/", adminController.Dashboard)
adminPages.GET("/users", adminController.UsersPage,
middleware.RequirePageRole(model.RoleAdmin))
adminPages.GET("/audits", auditController.AuditPage,
middleware.RequirePageRole(model.RoleAdmin))
}
// --- 管理后台 APIJSON 响应) ---
@ -106,6 +146,20 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
adminAPI.GET("/users", adminController.ListUsers)
adminAPI.PUT("/users/:uid/status", adminController.UpdateUserStatus)
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 严格验证) ---
@ -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/register", authCtrl.Register)
api.POST("/auth/login", authCtrl.Login)
api.POST("/auth/confirm-restore", authCtrl.ConfirmRestore)
api.POST("/auth/logout", authCtrl.Logout)
api.POST("/auth/refresh", authCtrl.RefreshToken)
}

View File

@ -4,9 +4,6 @@ import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
)
// AdminService 管理后台业务逻辑
// 集中处理所有权限边界检查,避免胖控制器
type AdminService struct {
userRepo userAdminStore
}
@ -34,20 +31,15 @@ type ListUsersResult struct {
// ListUsers 分页搜索用户列表
func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, error) {
if params.Page < 1 {
params.Page = 1
}
if params.PageSize < 1 || params.PageSize > 100 {
params.PageSize = 20
}
offset := (params.Page - 1) * params.PageSize
p := common.Pagination{Page: params.Page, PageSize: params.PageSize}
p.DefaultPagination()
total, err := s.userRepo.CountSearchUsers(params.Keyword, params.Role, params.Status)
if err != nil {
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 {
return nil, err
}
@ -55,7 +47,7 @@ func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, erro
return &ListUsersResult{
Users: users,
Total: total,
Page: params.Page,
Page: p.Page,
}, 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 (
"regexp"
"time"
"unicode/utf8"
"metazone.cc/metalab/internal/common"
@ -25,18 +26,21 @@ func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.
}
var (
ErrEmailExists = common.ErrEmailExists
ErrUsernameTaken = common.ErrUsernameTaken
ErrInvalidCred = common.ErrInvalidCred
ErrUserNotFound = common.ErrUserNotFound
ErrUserBanned = common.ErrUserBanned
ErrUserLocked = common.ErrUserLocked
ErrUserDeleted = common.ErrUserDeleted
ErrWeakPassword = common.ErrWeakPassword
ErrTokenExpired = common.ErrTokenExpired
ErrTokenInvalid = common.ErrTokenInvalid
ErrTokenRevoked = common.ErrTokenRevoked
ErrPermissionDenied = common.ErrPermissionDenied
ErrEmailExists = common.ErrEmailExists
ErrUsernameTaken = common.ErrUsernameTaken
ErrInvalidCred = common.ErrInvalidCred
ErrUserNotFound = common.ErrUserNotFound
ErrUserBanned = common.ErrUserBanned
ErrUserLocked = common.ErrUserLocked
ErrUserDeleted = common.ErrUserDeleted
ErrWeakPassword = common.ErrWeakPassword
ErrTokenExpired = common.ErrTokenExpired
ErrTokenInvalid = common.ErrTokenInvalid
ErrTokenRevoked = common.ErrTokenRevoked
ErrPermissionDenied = common.ErrPermissionDenied
ErrIncorrectPassword = common.ErrIncorrectPassword
ErrNeedsConfirmRestore = common.ErrNeedsConfirmRestore
ErrOwnerCannotDelete = common.ErrOwnerCannotDelete
)
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
@ -68,7 +72,8 @@ func validatePassword(pw string) error {
// Register 注册
// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT
// 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. 密码强度
if err := validatePassword(req.Password); err != nil {
return "", "", nil, err
@ -102,6 +107,7 @@ func (s *AuthService) Register(req model.RegisterRequest) (string, string, *mode
Username: username,
Role: model.RoleUser,
Status: model.StatusActive,
RegIP: regIP,
}
if err := s.userRepo.Create(user); err != nil {
return "", "", nil, err
@ -123,24 +129,25 @@ func (s *AuthService) Register(req model.RegisterRequest) (string, string, *mode
}
// Login 登录
// 流程:按邮箱查找 → 检查状态 → 验证密码 → access JWT + refresh JWT
// 流程:按邮箱查找 → 已注销则验证密码后要求二次确认 → 检查状态 → 验证密码 → 记录登录 IP/时间 → access JWT + refresh JWT
// 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对
// 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)
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, ErrInvalidCred
}
// 已注销deleted→ 自动恢复
// 已注销deleted验证密码后要求二次确认,不自动恢复
if user.Status == model.StatusDeleted {
user.Status = model.StatusActive
user.DeletedAt = gorm.DeletedAt{}
if err := s.userRepo.Update(user); err != nil {
return "", "", nil, err
if !common.CheckPassword(req.Password, user.PasswordHash) {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, ErrInvalidCred
}
// 恢复后继续正常登录流程
return "", "", nil, ErrNeedsConfirmRestore
}
// 永久锁定
@ -158,6 +165,14 @@ func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User
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
accessToken, err := s.tokenService.BuildAccessToken(user)
if err != nil {
@ -173,6 +188,47 @@ func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User
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 检查邮箱是否已被注册(无需认证的轻量检查)
func (s *AuthService) CheckEmail(email string) (bool, error) {
return s.userRepo.ExistsByEmail(email)
@ -183,6 +239,68 @@ func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
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强制所有设备重新登录
// 适用场景:修改密码、账号被盗、管理员强制下线
func (s *AuthService) InvalidateSessions(userID uint) error {

View File

@ -36,6 +36,7 @@ const (
type userAvatarStore interface {
FindByID(id uint) (*model.User, error)
Update(user *model.User) error
FindByIDUnscoped(userID uint) (*model.User, error)
}
// AvatarService 头像上传处理服务
@ -49,10 +50,27 @@ func NewAvatarService(userRepo userAvatarStore) *AvatarService {
return &AvatarService{userRepo: userRepo, storageDir: avatarStorageDir}
}
// ProcessAvatar 处理头像上传流程
// cropX, cropY, cropSize: 自定义裁切参数(原图像素坐标),全 0 则默认中心裁切
// 返回头像 URL失败返回错误
// ProcessAvatar 处理头像上传流程(完整流程:处理图片 + 更新 User
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裁切后无法保持动画
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/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("保存文件失败")
}
// 10. 更新用户头像字段
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
return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
}
// 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">
<li><a href="/">首页</a></li>
{{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="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
{{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>
<p class="settings-card-desc">你的公开信息和基本资料</p>
</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="info-row">
<span class="info-label">用户名</span>
@ -103,12 +113,62 @@
<span class="info-label">注册时间</span>
<span class="info-value">{{.User.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
</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">
<span class="info-label">最后更新</span>
<span class="info-value">{{.User.UpdatedAt.Format "2006-01-02 15:04:05"}}</span>
</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}}
</main>
</div>
@ -138,11 +198,6 @@
(function () {
'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 toastTimer = null;
@ -158,86 +213,93 @@
}, 5000);
}
// ---- 字符计数器 ----
var usernameCounter = document.getElementById('usernameCounter');
var bioCounterEl = document.getElementById('bioCounter');
// ===== 个人资料 Tab =====
(function () {
var usernameInput = document.getElementById('usernameInput');
var bioInput = document.getElementById('bioInput');
var btn = document.getElementById('saveProfileBtn');
if (!usernameInput || !bioInput || !btn) return;
function updateCounters() {
if (usernameCounter) {
usernameCounter.textContent = usernameInput.value.length + '/16';
}
if (bioCounterEl) {
bioCounterEl.textContent = bioInput.value.length + '/128';
}
}
// ---- 字符计数器 ----
var usernameCounter = document.getElementById('usernameCounter');
var bioCounterEl = document.getElementById('bioCounter');
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);
function updateCounters() {
if (usernameCounter) {
usernameCounter.textContent = usernameInput.value.length + '/16';
}
};
xhr.send(JSON.stringify({ username: username, bio: bio }));
});
if (bioCounterEl) {
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');
@ -435,8 +497,8 @@
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast('头像已更新');
if (avatarPreview.tagName === 'IMG') {
showSettingsToast(res.message || '头像已更新');
if (res.data && res.data.url && avatarPreview.tagName === 'IMG') {
avatarPreview.src = res.data.url + '?t=' + Date.now();
}
} else {
@ -460,6 +522,99 @@
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>
</body>

View File

@ -130,6 +130,45 @@ header {
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 {
display: 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;
}
/* 审核状态提示 */
.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 {
padding: 1.25rem 2rem 1.5rem;
}
@ -495,3 +507,64 @@ body {
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) ----
// 场景 A页面开着时每 10 分钟静默续期(保持 access token 不过期)
// 场景 B关浏览器 15+ min 后回来access token 过期但 refresh 仍有效

View File

@ -64,7 +64,12 @@
var resp;
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);
setTimeout(function () {
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>
{{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>
</aside>
<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 });
}