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:
@ -25,7 +25,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,8 +64,14 @@ func main() {
|
|||||||
r.Static("/shared/static", "./templates/shared/static")
|
r.Static("/shared/static", "./templates/shared/static")
|
||||||
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
||||||
|
|
||||||
|
// 站点设置管理器(DB 持久化 + 内存缓存)
|
||||||
|
siteSettings, err := config.NewSiteSettings(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("初始化站点设置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 路由注册
|
// 路由注册
|
||||||
router.Setup(r, db, cfg)
|
router.Setup(r, db, cfg, siteSettings)
|
||||||
|
|
||||||
// 启动
|
// 启动
|
||||||
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
|
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
|
||||||
|
|||||||
@ -22,6 +22,13 @@ jwt:
|
|||||||
bcrypt:
|
bcrypt:
|
||||||
cost: 12
|
cost: 12
|
||||||
|
|
||||||
|
# 审核系统配置(站点设置可运行时覆盖,无需重启)
|
||||||
|
audit:
|
||||||
|
enabled: true # 审核总开关
|
||||||
|
username_audit: true # 用户名修改审核
|
||||||
|
avatar_audit: true # 头像修改审核
|
||||||
|
bio_audit: true # 个性签名审核
|
||||||
|
|
||||||
# 角色配置(可扩展,无需修改源代码)
|
# 角色配置(可扩展,无需修改源代码)
|
||||||
roles:
|
roles:
|
||||||
# 角色层级(数字越大权限越高)
|
# 角色层级(数字越大权限越高)
|
||||||
|
|||||||
@ -22,4 +22,16 @@ var (
|
|||||||
ErrRateLimitIP = errors.New("请求过于频繁,请稍后重试")
|
ErrRateLimitIP = errors.New("请求过于频繁,请稍后重试")
|
||||||
ErrRateLimitRegisterIP = errors.New("注册请求过于频繁,请稍后重试")
|
ErrRateLimitRegisterIP = errors.New("注册请求过于频繁,请稍后重试")
|
||||||
ErrPermissionDenied = errors.New("权限不足")
|
ErrPermissionDenied = errors.New("权限不足")
|
||||||
|
|
||||||
|
// 审核相关
|
||||||
|
ErrAuditNotFound = errors.New("审核记录不存在")
|
||||||
|
ErrAuditNotPending = errors.New("该审核记录已处理")
|
||||||
|
ErrAuditDisabled = errors.New("审核功能未开启")
|
||||||
|
ErrAuditTypeOff = errors.New("该类型审核未开启")
|
||||||
|
|
||||||
|
// 密码 & 注销相关
|
||||||
|
ErrIncorrectPassword = errors.New("当前密码错误")
|
||||||
|
ErrLoginBeforeDelete = errors.New("请先登录再注销")
|
||||||
|
ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号")
|
||||||
|
ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -42,6 +42,7 @@ func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H {
|
|||||||
data["RoleDisplayName"] = roleStr
|
data["RoleDisplayName"] = roleStr
|
||||||
}
|
}
|
||||||
data["CanManageUsers"] = model.HasMinRole(roleStr, model.RoleAdmin)
|
data["CanManageUsers"] = model.HasMinRole(roleStr, model.RoleAdmin)
|
||||||
|
data["CanManageAudits"] = model.HasMinRole(roleStr, model.RoleAdmin)
|
||||||
data["CanManageSettings"] = model.HasMinRole(roleStr, model.RoleOwner)
|
data["CanManageSettings"] = model.HasMinRole(roleStr, model.RoleOwner)
|
||||||
data["IsOwner"] = model.HasMinRole(roleStr, model.RoleOwner)
|
data["IsOwner"] = model.HasMinRole(roleStr, model.RoleOwner)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,15 @@ type Config struct {
|
|||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
|
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
|
||||||
Roles RolesConfig `mapstructure:"roles"`
|
Roles RolesConfig `mapstructure:"roles"`
|
||||||
|
Audit AuditConfig `mapstructure:"audit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditConfig 审核系统配置(config.yaml 提供默认值,站点设置可运行时覆盖)
|
||||||
|
type AuditConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
UsernameAudit bool `mapstructure:"username_audit"`
|
||||||
|
AvatarAudit bool `mapstructure:"avatar_audit"`
|
||||||
|
BioAudit bool `mapstructure:"bio_audit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
|
|||||||
109
internal/config/site_settings.go
Normal file
109
internal/config/site_settings.go
Normal 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)
|
||||||
|
}
|
||||||
140
internal/controller/admin/audit_controller.go
Normal file
140
internal/controller/admin/audit_controller.go
Normal 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, "操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,9 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import "metazone.cc/metalab/internal/service"
|
import (
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:4 个方法)
|
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:4 个方法)
|
||||||
type adminUseCase interface {
|
type adminUseCase interface {
|
||||||
@ -9,3 +12,24 @@ type adminUseCase interface {
|
|||||||
ResetUserToken(operatorUID, targetUID uint) error
|
ResetUserToken(operatorUID, targetUID uint) error
|
||||||
CountUsers() (int64, error)
|
CountUsers() (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auditUseCase AuditController 对 AuditService 的最小依赖(ISP:4 个方法)
|
||||||
|
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 的最小依赖(ISP:3 个方法)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
103
internal/controller/admin/site_setting_controller.go
Normal file
103
internal/controller/admin/site_setting_controller.go
Normal 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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -90,7 +90,7 @@ func (ac *AuthController) Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, refreshToken, user, err := ac.authService.Login(req)
|
accessToken, refreshToken, user, err := ac.authService.Login(req, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 记录失败 → 两个维度各 +1
|
// 记录失败 → 两个维度各 +1
|
||||||
if recordAccount != nil {
|
if recordAccount != nil {
|
||||||
@ -107,9 +107,13 @@ func (ac *AuthController) Login(c *gin.Context) {
|
|||||||
common.Error(c, http.StatusForbidden, "账号已被封禁")
|
common.Error(c, http.StatusForbidden, "账号已被封禁")
|
||||||
case common.ErrUserLocked:
|
case common.ErrUserLocked:
|
||||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||||
case common.ErrUserDeleted:
|
case common.ErrNeedsConfirmRestore:
|
||||||
// deleted 状态本应在 Login 中自动恢复,此 case 作为兜底
|
// 注销账号登录 → 需要二次确认恢复
|
||||||
common.Error(c, http.StatusForbidden, "该账号已申请注销,登录即自动恢复")
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"action": "confirm_restore",
|
||||||
|
"message": "你的账号正在注销中,登录将中止注销流程",
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
|
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
|
||||||
}
|
}
|
||||||
@ -124,6 +128,29 @@ func (ac *AuthController) Login(c *gin.Context) {
|
|||||||
common.OkWithMessage(c, user, "登录成功")
|
common.OkWithMessage(c, user, "登录成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfirmRestore 二次确认恢复已注销账号
|
||||||
|
func (ac *AuthController) ConfirmRestore(c *gin.Context) {
|
||||||
|
var req model.LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请检查输入")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, refreshToken, user, err := ac.authService.ConfirmRestore(req, clientIP(c))
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case common.ErrInvalidCred:
|
||||||
|
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||||
|
default:
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
|
||||||
|
common.OkWithMessage(c, user, "账号已恢复,欢迎回来")
|
||||||
|
}
|
||||||
|
|
||||||
// CheckEmail 检查邮箱是否已注册
|
// CheckEmail 检查邮箱是否已注册
|
||||||
func (ac *AuthController) CheckEmail(c *gin.Context) {
|
func (ac *AuthController) CheckEmail(c *gin.Context) {
|
||||||
var req model.CheckEmailRequest
|
var req model.CheckEmailRequest
|
||||||
@ -156,7 +183,7 @@ func (ac *AuthController) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, refreshToken, user, err := ac.authService.Register(req)
|
accessToken, refreshToken, user, err := ac.authService.Register(req, clientIP(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if recordReg != nil {
|
if recordReg != nil {
|
||||||
recordReg()
|
recordReg()
|
||||||
|
|||||||
@ -5,10 +5,11 @@ import (
|
|||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// authUseCase AuthController 对 AuthService 的最小依赖(ISP:3 个方法)
|
// authUseCase AuthController 对 AuthService 的最小依赖(ISP:4 个方法)
|
||||||
type authUseCase interface {
|
type authUseCase interface {
|
||||||
Register(req model.RegisterRequest) (string, string, *model.User, error)
|
Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error)
|
||||||
Login(req model.LoginRequest) (string, string, *model.User, error)
|
Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error)
|
||||||
|
ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error)
|
||||||
CheckEmail(email string) (bool, error)
|
CheckEmail(email string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
internal/controller/message_controller.go
Normal file
129
internal/controller/message_controller.go
Normal 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 所需的通知服务接口(ISP:5 个方法)
|
||||||
|
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, "已全部标为已读")
|
||||||
|
}
|
||||||
@ -11,26 +11,38 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// profileProvider SettingsController 对 Service 层的最小依赖(ISP:2 个方法)
|
// profileProvider SettingsController 对 Service 层的最小依赖(ISP:4 个方法)
|
||||||
type profileProvider interface {
|
type profileProvider interface {
|
||||||
GetProfile(userID uint) (*model.User, error)
|
GetProfile(userID uint) (*model.User, error)
|
||||||
UpdateProfile(userID uint, username, bio string) error
|
UpdateProfile(userID uint, username, bio string) error
|
||||||
|
ChangePassword(userID uint, currentPassword, newPassword string) error
|
||||||
|
DeleteAccount(userID uint, password, reason string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// avatarProvider 头像上传对 Service 层的最小依赖(ISP:1 个方法)
|
// avatarProvider 头像上传对 Service 层的最小依赖(ISP:2 个方法)
|
||||||
type avatarProvider interface {
|
type avatarProvider interface {
|
||||||
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
||||||
|
ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditSubmittable 个人设置对审核服务的依赖(ISP:3 个方法)
|
||||||
|
type auditSubmittable interface {
|
||||||
|
ShouldAudit(userID uint) (bool, error)
|
||||||
|
SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error
|
||||||
|
Submit(userID uint, auditType, newValue string) error
|
||||||
|
GetPendingTypes(userID uint) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsController 个人设置控制器
|
// SettingsController 个人设置控制器
|
||||||
type SettingsController struct {
|
type SettingsController struct {
|
||||||
authService profileProvider
|
authService profileProvider
|
||||||
avatarService avatarProvider
|
avatarService avatarProvider
|
||||||
|
auditService auditSubmittable
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingsController 构造函数
|
// NewSettingsController 构造函数
|
||||||
func NewSettingsController(authService profileProvider, avatarService avatarProvider) *SettingsController {
|
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable) *SettingsController {
|
||||||
return &SettingsController{authService: authService, avatarService: avatarService}
|
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsPage 个人设置页面(需登录)
|
// SettingsPage 个人设置页面(需登录)
|
||||||
@ -55,6 +67,9 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询待审核类型(用于前端显示审核提示)
|
||||||
|
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "个人设置",
|
"Title": "个人设置",
|
||||||
"ExtraCSS": "/static/css/settings.css",
|
"ExtraCSS": "/static/css/settings.css",
|
||||||
@ -62,10 +77,13 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
"User": user,
|
"User": user,
|
||||||
"RoleName": model.RoleDisplayNames[user.Role],
|
"RoleName": model.RoleDisplayNames[user.Role],
|
||||||
"StatusName": model.StatusDisplayNames[user.Status],
|
"StatusName": model.StatusDisplayNames[user.Status],
|
||||||
|
"PendingTypes": pendingTypes,
|
||||||
|
"AuditTypes": model.AuditTypeNames,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile 修改个人资料(用户名 + 个性签名,需登录)
|
// UpdateProfile 修改个人资料(用户名 + 个性签名,需登录)
|
||||||
|
// 普通用户走审核流程,管理员及以上直接落库
|
||||||
func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
||||||
uid, exists := c.Get("uid")
|
uid, exists := c.Get("uid")
|
||||||
if !exists {
|
if !exists {
|
||||||
@ -82,7 +100,31 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := sc.authService.UpdateProfile(uid.(uint), req.Username, req.Bio); err != nil {
|
userID := uid.(uint)
|
||||||
|
|
||||||
|
// 判断是否需要审核
|
||||||
|
shouldAudit, err := sc.auditService.ShouldAudit(userID)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldAudit {
|
||||||
|
user, err := sc.authService.GetProfile(userID)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil {
|
||||||
|
handleAuditSubmitError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "修改已提交审核,通过前当前信息保持不变")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员及以上直接更新
|
||||||
|
if err := sc.authService.UpdateProfile(userID, req.Username, req.Bio); err != nil {
|
||||||
handleSettingsError(c, err)
|
handleSettingsError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -91,6 +133,7 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UploadAvatar 上传头像(需登录,multipart/form-data)
|
// UploadAvatar 上传头像(需登录,multipart/form-data)
|
||||||
|
// 普通用户走审核流程,管理员及以上直接更新
|
||||||
func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
||||||
uid, exists := c.Get("uid")
|
uid, exists := c.Get("uid")
|
||||||
if !exists {
|
if !exists {
|
||||||
@ -110,7 +153,32 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
|||||||
cropY, _ := strconv.Atoi(c.PostForm("crop_y"))
|
cropY, _ := strconv.Atoi(c.PostForm("crop_y"))
|
||||||
cropSize, _ := strconv.Atoi(c.PostForm("crop_size"))
|
cropSize, _ := strconv.Atoi(c.PostForm("crop_size"))
|
||||||
|
|
||||||
url, err := sc.avatarService.ProcessAvatar(uid.(uint), file, header.Header.Get("Content-Type"), cropX, cropY, cropSize)
|
userID := uid.(uint)
|
||||||
|
|
||||||
|
// 判断是否需要审核
|
||||||
|
shouldAudit, err := sc.auditService.ShouldAudit(userID)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldAudit {
|
||||||
|
// 仅处理图片,不更新用户
|
||||||
|
avatarURL, err := sc.avatarService.ProcessImage(userID, file, header.Header.Get("Content-Type"), cropX, cropY, cropSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sc.auditService.Submit(userID, model.AuditTypeAvatar, avatarURL); err != nil {
|
||||||
|
handleAuditSubmitError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "头像已提交审核,通过前当前头像保持不变")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员及以上直接更新
|
||||||
|
url, err := sc.avatarService.ProcessAvatar(userID, file, header.Header.Get("Content-Type"), cropX, cropY, cropSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
@ -119,6 +187,56 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
|||||||
common.Ok(c, gin.H{"url": url})
|
common.Ok(c, gin.H{"url": url})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改密码(需登录)
|
||||||
|
func (sc *SettingsController) ChangePassword(c *gin.Context) {
|
||||||
|
uid, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
CurrentPassword string `json:"current_password" binding:"required"`
|
||||||
|
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请检查输入")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sc.authService.ChangePassword(uid.(uint), req.CurrentPassword, req.NewPassword); err != nil {
|
||||||
|
handleSettingsError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "密码已修改,请重新登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAccount 自助注销账号(需登录,验证密码 + 注销原因)
|
||||||
|
func (sc *SettingsController) DeleteAccount(c *gin.Context) {
|
||||||
|
uid, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请检查输入")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sc.authService.DeleteAccount(uid.(uint), req.Password, req.Reason); err != nil {
|
||||||
|
handleSettingsError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "账号已注销,7 天内重新登录即可恢复")
|
||||||
|
}
|
||||||
|
|
||||||
// handleSettingsError 统一处理 settings 接口的 service 层错误
|
// handleSettingsError 统一处理 settings 接口的 service 层错误
|
||||||
func handleSettingsError(c *gin.Context, err error) {
|
func handleSettingsError(c *gin.Context, err error) {
|
||||||
switch err {
|
switch err {
|
||||||
@ -128,7 +246,44 @@ func handleSettingsError(c *gin.Context, err error) {
|
|||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
case common.ErrBioTooLong:
|
case common.ErrBioTooLong:
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
case common.ErrIncorrectPassword:
|
||||||
|
common.Error(c, http.StatusForbidden, err.Error())
|
||||||
|
case common.ErrOwnerCannotDelete:
|
||||||
|
common.Error(c, http.StatusForbidden, err.Error())
|
||||||
default:
|
default:
|
||||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAuditSubmitError 统一处理审核提交的错误
|
||||||
|
func handleAuditSubmitError(c *gin.Context, err error) {
|
||||||
|
switch err {
|
||||||
|
case common.ErrAuditDisabled:
|
||||||
|
common.Error(c, http.StatusForbidden, "审核功能未开启")
|
||||||
|
case common.ErrAuditTypeOff:
|
||||||
|
common.Error(c, http.StatusForbidden, "该类型审核未开启,请联系管理员")
|
||||||
|
case common.ErrUsernameTaken:
|
||||||
|
common.Error(c, http.StatusConflict, err.Error())
|
||||||
|
default:
|
||||||
|
common.Error(c, http.StatusInternalServerError, "提交审核失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditStatus 查询当前用户的待审核类型(需登录)
|
||||||
|
func (sc *SettingsController) AuditStatus(c *gin.Context) {
|
||||||
|
uid, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
types, err := sc.auditService.GetPendingTypes(uid.(uint))
|
||||||
|
if err != nil {
|
||||||
|
common.Ok(c, gin.H{"pending_types": []string{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if types == nil {
|
||||||
|
types = []string{}
|
||||||
|
}
|
||||||
|
common.Ok(c, gin.H{"pending_types": types})
|
||||||
|
}
|
||||||
|
|||||||
@ -70,7 +70,7 @@ func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[AdminAuth] OK: uid=%d role=%v path=%s", uint(claims["uid"].(float64)), claims["role"], c.Request.URL.Path)
|
log.Printf("[AdminAuth] OK: uid=%v role=%v path=%s", claims["uid"], claims["role"], c.Request.URL.Path)
|
||||||
injectUserContext(c, claims)
|
injectUserContext(c, claims)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
@ -93,11 +93,12 @@ func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, erro
|
|||||||
tokenVer := int(claims["ver"].(float64))
|
tokenVer := int(claims["ver"].(float64))
|
||||||
|
|
||||||
currentVer, err := am.userRepo.FindTokenVersion(uid)
|
currentVer, err := am.userRepo.FindTokenVersion(uid)
|
||||||
if err != nil || tokenVer != currentVer {
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if tokenVer != currentVer {
|
if tokenVer != currentVer {
|
||||||
log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
|
log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
|
||||||
}
|
return nil, common.ErrTokenRevoked
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return claims, nil
|
return claims, nil
|
||||||
@ -105,7 +106,14 @@ func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, erro
|
|||||||
|
|
||||||
// injectUserContext 将 JWT claims 中的用户信息注入 gin context
|
// injectUserContext 将 JWT claims 中的用户信息注入 gin context
|
||||||
func injectUserContext(c *gin.Context, claims jwt.MapClaims) {
|
func injectUserContext(c *gin.Context, claims jwt.MapClaims) {
|
||||||
c.Set("uid", uint(claims["uid"].(float64)))
|
if claims == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, ok := claims["uid"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("uid", uint(uid))
|
||||||
c.Set("email", claims["email"])
|
c.Set("email", claims["email"])
|
||||||
c.Set("username", claims["username"])
|
c.Set("username", claims["username"])
|
||||||
c.Set("role", claims["role"])
|
c.Set("role", claims["role"])
|
||||||
|
|||||||
61
internal/model/audit.go
Normal file
61
internal/model/audit.go
Normal 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
|
||||||
|
}
|
||||||
@ -19,3 +19,15 @@ type LoginRequest struct {
|
|||||||
type CheckEmailRequest struct {
|
type CheckEmailRequest struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangePasswordRequest 修改密码请求
|
||||||
|
type ChangePasswordRequest struct {
|
||||||
|
CurrentPassword string `json:"current_password" binding:"required"`
|
||||||
|
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAccountRequest 注销账号请求
|
||||||
|
type DeleteAccountRequest struct {
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|||||||
34
internal/model/notification.go
Normal file
34
internal/model/notification.go
Normal 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"`
|
||||||
|
}
|
||||||
7
internal/model/site_setting.go
Normal file
7
internal/model/site_setting.go
Normal 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"`
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// User 用户模型
|
// User 用户模型
|
||||||
type User struct {
|
type User struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
@ -13,6 +15,12 @@ type User struct {
|
|||||||
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
||||||
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
||||||
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
|
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
|
||||||
|
|
||||||
|
// 安全与审计
|
||||||
|
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
||||||
|
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
|
||||||
|
LastLoginAt *time.Time `gorm:"default:null" json:"last_login_at,omitempty"` // 最后登录时间
|
||||||
|
DeleteReason string `gorm:"type:text;default:''" json:"-"` // 注销原因
|
||||||
}
|
}
|
||||||
|
|
||||||
// 角色常量(仅用作字符串标识符,层级和权限由配置驱动)
|
// 角色常量(仅用作字符串标识符,层级和权限由配置驱动)
|
||||||
|
|||||||
103
internal/repository/audit_repo.go
Normal file
103
internal/repository/audit_repo.go
Normal 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
|
||||||
|
}
|
||||||
67
internal/repository/notification_repo.go
Normal file
67
internal/repository/notification_repo.go
Normal 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
|
||||||
|
}
|
||||||
34
internal/repository/site_setting_repo.go
Normal file
34
internal/repository/site_setting_repo.go
Normal 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
|
||||||
|
}
|
||||||
@ -21,10 +21,10 @@ func (r *UserRepo) Create(user *model.User) error {
|
|||||||
return r.db.Create(user).Error
|
return r.db.Create(user).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByEmail 按邮箱查找用户
|
// FindByEmail 按邮箱查找用户(含软删除,用于登录等需要找到已注销用户的场景)
|
||||||
func (r *UserRepo) FindByEmail(email string) (*model.User, error) {
|
func (r *UserRepo) FindByEmail(email string) (*model.User, error) {
|
||||||
var user model.User
|
var user model.User
|
||||||
err := r.db.Where("email = ?", email).First(&user).Error
|
err := r.db.Unscoped().Where("email = ?", email).First(&user).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Setup 注册所有路由
|
// Setup 注册所有路由
|
||||||
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) {
|
||||||
// --- 全局中间件 ---
|
// --- 全局中间件 ---
|
||||||
r.Use(middleware.SecurityHeaders())
|
r.Use(middleware.SecurityHeaders())
|
||||||
|
|
||||||
@ -28,14 +28,35 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
rateLimiter := middleware.NewRateLimiter()
|
rateLimiter := middleware.NewRateLimiter()
|
||||||
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
|
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
|
||||||
avatarSvc := service.NewAvatarService(userRepo)
|
avatarSvc := service.NewAvatarService(userRepo)
|
||||||
settingsCtrl := controller.NewSettingsController(authService, avatarSvc)
|
|
||||||
|
|
||||||
authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
|
authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
|
||||||
|
|
||||||
// 管理后台
|
// 管理后台
|
||||||
adminService := service.NewAdminService(userRepo)
|
adminService := service.NewAdminService(userRepo)
|
||||||
adminController := adminCtrl.NewAdminController(adminService)
|
adminController := adminCtrl.NewAdminController(adminService)
|
||||||
|
|
||||||
|
// 审核系统
|
||||||
|
auditRepo := repository.NewAuditRepo(db)
|
||||||
|
notificationRepo := repository.NewNotificationRepo(db)
|
||||||
|
notificationService := service.NewNotificationService(notificationRepo)
|
||||||
|
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService)
|
||||||
|
auditController := adminCtrl.NewAuditController(auditService, userRepo)
|
||||||
|
|
||||||
|
// 消息中心
|
||||||
|
messageController := controller.NewMessageController(notificationService)
|
||||||
|
|
||||||
|
// 站点设置
|
||||||
|
siteSettingService := service.NewSiteSettingService(siteSettings)
|
||||||
|
auditDefaults := &service.AuditDefaults{
|
||||||
|
Enabled: cfg.Audit.Enabled,
|
||||||
|
UsernameAudit: cfg.Audit.UsernameAudit,
|
||||||
|
AvatarAudit: cfg.Audit.AvatarAudit,
|
||||||
|
BioAudit: cfg.Audit.BioAudit,
|
||||||
|
}
|
||||||
|
siteSettingController := adminCtrl.NewSiteSettingController(siteSettingService, auditDefaults)
|
||||||
|
|
||||||
|
// 个人设置(需要审核服务)
|
||||||
|
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService)
|
||||||
|
|
||||||
// --- 页面路由(CSRF 仅下发 token,不验证——页面 GET 被豁免) ---
|
// --- 页面路由(CSRF 仅下发 token,不验证——页面 GET 被豁免) ---
|
||||||
pages := r.Group("/")
|
pages := r.Group("/")
|
||||||
pages.Use(authMdw.Optional())
|
pages.Use(authMdw.Optional())
|
||||||
@ -58,6 +79,9 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
c.Redirect(http.StatusFound, "/settings/profile")
|
c.Redirect(http.StatusFound, "/settings/profile")
|
||||||
})
|
})
|
||||||
pages.GET("/settings/:tab", settingsCtrl.SettingsPage)
|
pages.GET("/settings/:tab", settingsCtrl.SettingsPage)
|
||||||
|
|
||||||
|
// 消息中心(需登录,noindex)
|
||||||
|
pages.GET("/messages", messageController.MessagesPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 认证页面(已登录自动跳走)
|
// 认证页面(已登录自动跳走)
|
||||||
@ -80,6 +104,20 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
{
|
{
|
||||||
settingsAPI.PUT("/profile", settingsCtrl.UpdateProfile)
|
settingsAPI.PUT("/profile", settingsCtrl.UpdateProfile)
|
||||||
settingsAPI.POST("/avatar", settingsCtrl.UploadAvatar)
|
settingsAPI.POST("/avatar", settingsCtrl.UploadAvatar)
|
||||||
|
settingsAPI.PUT("/password", settingsCtrl.ChangePassword)
|
||||||
|
settingsAPI.POST("/delete-account", settingsCtrl.DeleteAccount)
|
||||||
|
// 查询当前用户的待审核状态
|
||||||
|
settingsAPI.GET("/audit-status", settingsCtrl.AuditStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 消息中心 API(需登录) ---
|
||||||
|
messagesAPI := r.Group("/api/messages")
|
||||||
|
messagesAPI.Use(authMdw.Required())
|
||||||
|
messagesAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
messagesAPI.GET("/unread", messageController.UnreadCount)
|
||||||
|
messagesAPI.POST("/:id/read", messageController.MarkRead)
|
||||||
|
messagesAPI.POST("/read-all", messageController.MarkAllRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 管理后台 SSR 页面(认证失败 302 跳首页) ---
|
// --- 管理后台 SSR 页面(认证失败 302 跳首页) ---
|
||||||
@ -95,6 +133,8 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
adminPages.GET("/", adminController.Dashboard)
|
adminPages.GET("/", adminController.Dashboard)
|
||||||
adminPages.GET("/users", adminController.UsersPage,
|
adminPages.GET("/users", adminController.UsersPage,
|
||||||
middleware.RequirePageRole(model.RoleAdmin))
|
middleware.RequirePageRole(model.RoleAdmin))
|
||||||
|
adminPages.GET("/audits", auditController.AuditPage,
|
||||||
|
middleware.RequirePageRole(model.RoleAdmin))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 管理后台 API(JSON 响应) ---
|
// --- 管理后台 API(JSON 响应) ---
|
||||||
@ -106,6 +146,20 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
adminAPI.GET("/users", adminController.ListUsers)
|
adminAPI.GET("/users", adminController.ListUsers)
|
||||||
adminAPI.PUT("/users/:uid/status", adminController.UpdateUserStatus)
|
adminAPI.PUT("/users/:uid/status", adminController.UpdateUserStatus)
|
||||||
adminAPI.POST("/users/:uid/reset-token", adminController.ResetToken)
|
adminAPI.POST("/users/:uid/reset-token", adminController.ResetToken)
|
||||||
|
// 审核 API
|
||||||
|
adminAPI.GET("/audits", auditController.ListAudits)
|
||||||
|
adminAPI.POST("/audits/:id/approve", auditController.Approve)
|
||||||
|
adminAPI.POST("/audits/:id/reject", auditController.Reject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 站点设置 API(仅 owner 可访问) ---
|
||||||
|
siteSettingsAPI := r.Group("/api/admin/site-settings")
|
||||||
|
siteSettingsAPI.Use(authMdw.Required())
|
||||||
|
siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner))
|
||||||
|
siteSettingsAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
siteSettingsAPI.GET("", siteSettingController.GetSettings)
|
||||||
|
siteSettingsAPI.PUT("", siteSettingController.UpdateSetting)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- API 路由(CSRF 严格验证) ---
|
// --- API 路由(CSRF 严格验证) ---
|
||||||
@ -115,6 +169,7 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
api.POST("/auth/check-email", authCtrl.CheckEmail)
|
api.POST("/auth/check-email", authCtrl.CheckEmail)
|
||||||
api.POST("/auth/register", authCtrl.Register)
|
api.POST("/auth/register", authCtrl.Register)
|
||||||
api.POST("/auth/login", authCtrl.Login)
|
api.POST("/auth/login", authCtrl.Login)
|
||||||
|
api.POST("/auth/confirm-restore", authCtrl.ConfirmRestore)
|
||||||
api.POST("/auth/logout", authCtrl.Logout)
|
api.POST("/auth/logout", authCtrl.Logout)
|
||||||
api.POST("/auth/refresh", authCtrl.RefreshToken)
|
api.POST("/auth/refresh", authCtrl.RefreshToken)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,6 @@ import (
|
|||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminService 管理后台业务逻辑
|
|
||||||
// 集中处理所有权限边界检查,避免胖控制器
|
|
||||||
type AdminService struct {
|
type AdminService struct {
|
||||||
userRepo userAdminStore
|
userRepo userAdminStore
|
||||||
}
|
}
|
||||||
@ -34,20 +31,15 @@ type ListUsersResult struct {
|
|||||||
|
|
||||||
// ListUsers 分页搜索用户列表
|
// ListUsers 分页搜索用户列表
|
||||||
func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, error) {
|
func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, error) {
|
||||||
if params.Page < 1 {
|
p := common.Pagination{Page: params.Page, PageSize: params.PageSize}
|
||||||
params.Page = 1
|
p.DefaultPagination()
|
||||||
}
|
|
||||||
if params.PageSize < 1 || params.PageSize > 100 {
|
|
||||||
params.PageSize = 20
|
|
||||||
}
|
|
||||||
offset := (params.Page - 1) * params.PageSize
|
|
||||||
|
|
||||||
total, err := s.userRepo.CountSearchUsers(params.Keyword, params.Role, params.Status)
|
total, err := s.userRepo.CountSearchUsers(params.Keyword, params.Role, params.Status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := s.userRepo.SearchUsers(params.Keyword, params.Role, params.Status, offset, params.PageSize)
|
users, err := s.userRepo.SearchUsers(params.Keyword, params.Role, params.Status, p.Offset(), p.PageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -55,7 +47,7 @@ func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, erro
|
|||||||
return &ListUsersResult{
|
return &ListUsersResult{
|
||||||
Users: users,
|
Users: users,
|
||||||
Total: total,
|
Total: total,
|
||||||
Page: params.Page,
|
Page: p.Page,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
295
internal/service/audit_service.go
Normal file
295
internal/service/audit_service.go
Normal 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"`
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
@ -37,6 +38,9 @@ var (
|
|||||||
ErrTokenInvalid = common.ErrTokenInvalid
|
ErrTokenInvalid = common.ErrTokenInvalid
|
||||||
ErrTokenRevoked = common.ErrTokenRevoked
|
ErrTokenRevoked = common.ErrTokenRevoked
|
||||||
ErrPermissionDenied = common.ErrPermissionDenied
|
ErrPermissionDenied = common.ErrPermissionDenied
|
||||||
|
ErrIncorrectPassword = common.ErrIncorrectPassword
|
||||||
|
ErrNeedsConfirmRestore = common.ErrNeedsConfirmRestore
|
||||||
|
ErrOwnerCannotDelete = common.ErrOwnerCannotDelete
|
||||||
)
|
)
|
||||||
|
|
||||||
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
|
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
|
||||||
@ -68,7 +72,8 @@ func validatePassword(pw string) error {
|
|||||||
// Register 注册
|
// Register 注册
|
||||||
// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT
|
// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT
|
||||||
// rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除)
|
// rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除)
|
||||||
func (s *AuthService) Register(req model.RegisterRequest) (string, string, *model.User, error) {
|
// regIP: 注册 IP 地址
|
||||||
|
func (s *AuthService) Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) {
|
||||||
// 1. 密码强度
|
// 1. 密码强度
|
||||||
if err := validatePassword(req.Password); err != nil {
|
if err := validatePassword(req.Password); err != nil {
|
||||||
return "", "", nil, err
|
return "", "", nil, err
|
||||||
@ -102,6 +107,7 @@ func (s *AuthService) Register(req model.RegisterRequest) (string, string, *mode
|
|||||||
Username: username,
|
Username: username,
|
||||||
Role: model.RoleUser,
|
Role: model.RoleUser,
|
||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
|
RegIP: regIP,
|
||||||
}
|
}
|
||||||
if err := s.userRepo.Create(user); err != nil {
|
if err := s.userRepo.Create(user); err != nil {
|
||||||
return "", "", nil, err
|
return "", "", nil, err
|
||||||
@ -123,24 +129,25 @@ func (s *AuthService) Register(req model.RegisterRequest) (string, string, *mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login 登录
|
// Login 登录
|
||||||
// 流程:按邮箱查找 → 检查状态 → 验证密码 → access JWT + refresh JWT
|
// 流程:按邮箱查找 → 已注销则验证密码后要求二次确认 → 检查状态 → 验证密码 → 记录登录 IP/时间 → access JWT + refresh JWT
|
||||||
// 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对
|
// 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对
|
||||||
// rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除)
|
// rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除)
|
||||||
func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User, error) {
|
// loginIP: 登录 IP 地址
|
||||||
|
// 若用户处于 deleted 状态且密码正确 → 返回 ErrNeedsConfirmRestore(不自动恢复,不签发 token)
|
||||||
|
func (s *AuthService) Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) {
|
||||||
user, err := s.userRepo.FindByEmail(req.Email)
|
user, err := s.userRepo.FindByEmail(req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||||
return "", "", nil, ErrInvalidCred
|
return "", "", nil, ErrInvalidCred
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已注销(deleted)→ 自动恢复
|
// 已注销(deleted)→ 验证密码后要求二次确认,不自动恢复
|
||||||
if user.Status == model.StatusDeleted {
|
if user.Status == model.StatusDeleted {
|
||||||
user.Status = model.StatusActive
|
if !common.CheckPassword(req.Password, user.PasswordHash) {
|
||||||
user.DeletedAt = gorm.DeletedAt{}
|
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||||
if err := s.userRepo.Update(user); err != nil {
|
return "", "", nil, ErrInvalidCred
|
||||||
return "", "", nil, err
|
|
||||||
}
|
}
|
||||||
// 恢复后继续正常登录流程
|
return "", "", nil, ErrNeedsConfirmRestore
|
||||||
}
|
}
|
||||||
|
|
||||||
// 永久锁定
|
// 永久锁定
|
||||||
@ -158,6 +165,14 @@ func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User
|
|||||||
return "", "", nil, ErrInvalidCred
|
return "", "", nil, ErrInvalidCred
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录登录 IP 和时间
|
||||||
|
now := time.Now()
|
||||||
|
user.LastLoginIP = loginIP
|
||||||
|
user.LastLoginAt = &now
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// 生成 access JWT
|
// 生成 access JWT
|
||||||
accessToken, err := s.tokenService.BuildAccessToken(user)
|
accessToken, err := s.tokenService.BuildAccessToken(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -173,6 +188,47 @@ func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User
|
|||||||
return accessToken, refreshToken, user, nil
|
return accessToken, refreshToken, user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfirmRestore 二次确认恢复已注销账号
|
||||||
|
// 流程:按邮箱查找 → 验证密码 → 恢复为 active → 记录登录 IP/时间 → 签发 token
|
||||||
|
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) {
|
||||||
|
user, err := s.userRepo.FindByEmail(req.Email)
|
||||||
|
if err != nil {
|
||||||
|
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||||
|
return "", "", nil, ErrInvalidCred
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅 deleted 状态允许恢复
|
||||||
|
if user.Status != model.StatusDeleted {
|
||||||
|
return "", "", nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if !common.CheckPassword(req.Password, user.PasswordHash) {
|
||||||
|
return "", "", nil, ErrInvalidCred
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复账号
|
||||||
|
user.Status = model.StatusActive
|
||||||
|
user.DeletedAt = gorm.DeletedAt{}
|
||||||
|
now := time.Now()
|
||||||
|
user.LastLoginIP = loginIP
|
||||||
|
user.LastLoginAt = &now
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := s.tokenService.BuildAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, refreshToken, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查)
|
// CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查)
|
||||||
func (s *AuthService) CheckEmail(email string) (bool, error) {
|
func (s *AuthService) CheckEmail(email string) (bool, error) {
|
||||||
return s.userRepo.ExistsByEmail(email)
|
return s.userRepo.ExistsByEmail(email)
|
||||||
@ -183,6 +239,68 @@ func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
|
|||||||
return s.userRepo.FindByID(userID)
|
return s.userRepo.FindByID(userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改密码
|
||||||
|
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 吊销所有 JWT(强制重新登录)
|
||||||
|
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证当前密码
|
||||||
|
if !common.CheckPassword(currentPassword, user.PasswordHash) {
|
||||||
|
return ErrIncorrectPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新密码强度校验
|
||||||
|
if err := validatePassword(newPassword); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 哈希新密码
|
||||||
|
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PasswordHash = hash
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制所有设备重新登录(安全性)
|
||||||
|
return s.InvalidateSessions(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAccount 用户自主注销
|
||||||
|
// 流程:检查角色 → 验证密码 → 记录原因 → 设置 deleted 状态 → 吊销所有 JWT → 7 天冷却期内登录需二次确认恢复
|
||||||
|
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 站长不允许自主注销(避免权限体系死锁)
|
||||||
|
if user.Role == model.RoleOwner {
|
||||||
|
return ErrOwnerCannotDelete
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证当前密码(防止 CSRF 或未授权操作)
|
||||||
|
if !common.CheckPassword(password, user.PasswordHash) {
|
||||||
|
return ErrIncorrectPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Status = model.StatusDeleted
|
||||||
|
user.DeleteReason = reason
|
||||||
|
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递增 token_version,即时吊销所有 JWT 强制退登
|
||||||
|
return s.InvalidateSessions(userID)
|
||||||
|
}
|
||||||
|
|
||||||
// InvalidateSessions 吊销某用户所有 JWT(递增 token_version,强制所有设备重新登录)
|
// InvalidateSessions 吊销某用户所有 JWT(递增 token_version,强制所有设备重新登录)
|
||||||
// 适用场景:修改密码、账号被盗、管理员强制下线
|
// 适用场景:修改密码、账号被盗、管理员强制下线
|
||||||
func (s *AuthService) InvalidateSessions(userID uint) error {
|
func (s *AuthService) InvalidateSessions(userID uint) error {
|
||||||
|
|||||||
@ -36,6 +36,7 @@ const (
|
|||||||
type userAvatarStore interface {
|
type userAvatarStore interface {
|
||||||
FindByID(id uint) (*model.User, error)
|
FindByID(id uint) (*model.User, error)
|
||||||
Update(user *model.User) error
|
Update(user *model.User) error
|
||||||
|
FindByIDUnscoped(userID uint) (*model.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AvatarService 头像上传处理服务
|
// AvatarService 头像上传处理服务
|
||||||
@ -49,10 +50,27 @@ func NewAvatarService(userRepo userAvatarStore) *AvatarService {
|
|||||||
return &AvatarService{userRepo: userRepo, storageDir: avatarStorageDir}
|
return &AvatarService{userRepo: userRepo, storageDir: avatarStorageDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessAvatar 处理头像上传流程
|
// ProcessAvatar 处理头像上传流程(完整流程:处理图片 + 更新 User)
|
||||||
// cropX, cropY, cropSize: 自定义裁切参数(原图像素坐标),全 0 则默认中心裁切
|
|
||||||
// 返回头像 URL,失败返回错误
|
|
||||||
func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) {
|
func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) {
|
||||||
|
avatarURL, err := s.ProcessImage(userID, file, contentType, cropX, cropY, cropSize)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("用户不存在")
|
||||||
|
}
|
||||||
|
user.Avatar = avatarURL
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return "", fmt.Errorf("更新头像失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return avatarURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessImage 处理头像上传流程(仅处理图片 + 保存文件,不更新 User,审核场景用)
|
||||||
|
func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) {
|
||||||
// 1. 检查文件类型(不支持 GIF:裁切后无法保持动画)
|
// 1. 检查文件类型(不支持 GIF:裁切后无法保持动画)
|
||||||
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
|
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
|
||||||
return "", fmt.Errorf("仅支持 JPEG、PNG、WebP 格式")
|
return "", fmt.Errorf("仅支持 JPEG、PNG、WebP 格式")
|
||||||
@ -127,18 +145,7 @@ func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType s
|
|||||||
return "", fmt.Errorf("保存文件失败")
|
return "", fmt.Errorf("保存文件失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. 更新用户头像字段
|
return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
|
||||||
user, err := s.userRepo.FindByID(userID)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("用户不存在")
|
|
||||||
}
|
|
||||||
avatarURL := fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts)
|
|
||||||
user.Avatar = avatarURL
|
|
||||||
if err := s.userRepo.Update(user); err != nil {
|
|
||||||
return "", fmt.Errorf("更新头像失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return avatarURL, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传)
|
// cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传)
|
||||||
|
|||||||
112
internal/service/notification_service.go
Normal file
112
internal/service/notification_service.go
Normal 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)
|
||||||
|
}
|
||||||
82
internal/service/site_setting_service.go
Normal file
82
internal/service/site_setting_service.go
Normal 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)
|
||||||
|
}
|
||||||
@ -7,6 +7,12 @@
|
|||||||
<ul class="nav-links" id="navLinks">
|
<ul class="nav-links" id="navLinks">
|
||||||
<li><a href="/">首页</a></li>
|
<li><a href="/">首页</a></li>
|
||||||
{{if .IsLoggedIn}}
|
{{if .IsLoggedIn}}
|
||||||
|
<li>
|
||||||
|
<a href="/messages" class="nav-messages" id="messagesBell" title="消息中心">
|
||||||
|
<svg class="nav-bell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
|
<span class="nav-bell-badge" id="msgBadge" style="display:none">0</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li>
|
<li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li>
|
||||||
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
|
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
172
templates/MetaLab-2026/html/messages/index.html
Normal file
172
templates/MetaLab-2026/html/messages/index.html
Normal 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>
|
||||||
@ -35,6 +35,16 @@
|
|||||||
<h2>个人资料</h2>
|
<h2>个人资料</h2>
|
||||||
<p class="settings-card-desc">你的公开信息和基本资料</p>
|
<p class="settings-card-desc">你的公开信息和基本资料</p>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .PendingTypes}}
|
||||||
|
<div class="settings-audit-notice">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{{range $i, $t := .PendingTypes}}{{if $i}}、{{end}}{{index $.AuditTypes $t}}{{end}}审核中
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
<div class="settings-card-body">
|
<div class="settings-card-body">
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">用户名</span>
|
<span class="info-label">用户名</span>
|
||||||
@ -103,12 +113,62 @@
|
|||||||
<span class="info-label">注册时间</span>
|
<span class="info-label">注册时间</span>
|
||||||
<span class="info-value">{{.User.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
<span class="info-value">{{.User.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .User.LastLoginAt}}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">上次登录</span>
|
||||||
|
<span class="info-value">{{.User.LastLoginAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">最后更新</span>
|
<span class="info-label">最后更新</span>
|
||||||
<span class="info-value">{{.User.UpdatedAt.Format "2006-01-02 15:04:05"}}</span>
|
<span class="info-value">{{.User.UpdatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 修改密码 -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h2>修改密码</h2>
|
||||||
|
<p class="settings-card-desc">修改密码后需重新登录</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="currentPassword">当前密码</label>
|
||||||
|
<input type="password" id="currentPassword" class="settings-text-input" placeholder="输入当前密码" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="newPassword">新密码</label>
|
||||||
|
<input type="password" id="newPassword" class="settings-text-input" placeholder="至少 8 位,包含字母和数字" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-footer">
|
||||||
|
<button id="changePwdBtn" class="settings-save-btn">修改密码</button>
|
||||||
|
<span class="form-hint">修改成功后需重新登录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 注销账号 -->
|
||||||
|
<div class="settings-card settings-card-danger">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h2>注销账号</h2>
|
||||||
|
<p class="settings-card-desc">账号将在 7 天后正式注销,期间可随时重新登录恢复</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="deletePassword">确认密码</label>
|
||||||
|
<input type="password" id="deletePassword" class="settings-text-input" placeholder="输入密码确认" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="deleteReason">注销原因(选填)</label>
|
||||||
|
<textarea id="deleteReason" class="settings-textarea" maxlength="500" placeholder="可以告诉我们为什么要离开吗?"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-footer">
|
||||||
|
<button id="deleteAccountBtn" class="settings-danger-btn">确认注销</button>
|
||||||
|
<span class="form-hint">此操作不可逆,请谨慎处理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@ -138,11 +198,6 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var usernameInput = document.getElementById('usernameInput');
|
|
||||||
var bioInput = document.getElementById('bioInput');
|
|
||||||
var btn = document.getElementById('saveProfileBtn');
|
|
||||||
if (!usernameInput || !bioInput || !btn) return;
|
|
||||||
|
|
||||||
var toastEl = document.getElementById('settingsToast');
|
var toastEl = document.getElementById('settingsToast');
|
||||||
var toastTimer = null;
|
var toastTimer = null;
|
||||||
|
|
||||||
@ -158,6 +213,13 @@
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 个人资料 Tab =====
|
||||||
|
(function () {
|
||||||
|
var usernameInput = document.getElementById('usernameInput');
|
||||||
|
var bioInput = document.getElementById('bioInput');
|
||||||
|
var btn = document.getElementById('saveProfileBtn');
|
||||||
|
if (!usernameInput || !bioInput || !btn) return;
|
||||||
|
|
||||||
// ---- 字符计数器 ----
|
// ---- 字符计数器 ----
|
||||||
var usernameCounter = document.getElementById('usernameCounter');
|
var usernameCounter = document.getElementById('usernameCounter');
|
||||||
var bioCounterEl = document.getElementById('bioCounter');
|
var bioCounterEl = document.getElementById('bioCounter');
|
||||||
@ -435,8 +497,8 @@
|
|||||||
try {
|
try {
|
||||||
var res = JSON.parse(xhr.responseText);
|
var res = JSON.parse(xhr.responseText);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
showSettingsToast('头像已更新');
|
showSettingsToast(res.message || '头像已更新');
|
||||||
if (avatarPreview.tagName === 'IMG') {
|
if (res.data && res.data.url && avatarPreview.tagName === 'IMG') {
|
||||||
avatarPreview.src = res.data.url + '?t=' + Date.now();
|
avatarPreview.src = res.data.url + '?t=' + Date.now();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -460,6 +522,99 @@
|
|||||||
openCrop(file);
|
openCrop(file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
})(); // 结束个人资料 Tab 子 IIFE
|
||||||
|
|
||||||
|
// ---- 修改密码 ----
|
||||||
|
var currentPwd = document.getElementById('currentPassword');
|
||||||
|
var newPwd = document.getElementById('newPassword');
|
||||||
|
var changePwdBtn = document.getElementById('changePwdBtn');
|
||||||
|
if (currentPwd && newPwd && changePwdBtn) {
|
||||||
|
changePwdBtn.addEventListener('click', function () {
|
||||||
|
if (!currentPwd.value) {
|
||||||
|
showSettingsToast('请输入当前密码', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newPwd.value || !/[a-zA-Z]/.test(newPwd.value) || !/\d/.test(newPwd.value) || newPwd.value.length < 8) {
|
||||||
|
showSettingsToast('新密码至少 8 位,包含字母和数字', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changePwdBtn.disabled = true;
|
||||||
|
changePwdBtn.textContent = '修改中...';
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('PUT', '/api/settings/password', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
changePwdBtn.disabled = false;
|
||||||
|
changePwdBtn.textContent = '修改密码';
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
showSettingsToast('密码已修改,请重新登录');
|
||||||
|
currentPwd.value = '';
|
||||||
|
newPwd.value = '';
|
||||||
|
// 延时后执行登出并跳转到首页
|
||||||
|
setTimeout(function () {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
showSettingsToast(res.message || '修改失败', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('修改失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify({
|
||||||
|
current_password: currentPwd.value,
|
||||||
|
new_password: newPwd.value
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 注销账号 ----
|
||||||
|
var deletePwd = document.getElementById('deletePassword');
|
||||||
|
var deleteReason = document.getElementById('deleteReason');
|
||||||
|
var deleteAccountBtn = document.getElementById('deleteAccountBtn');
|
||||||
|
if (deletePwd && deleteAccountBtn) {
|
||||||
|
deleteAccountBtn.addEventListener('click', function () {
|
||||||
|
if (!deletePwd.value) {
|
||||||
|
showSettingsToast('请输入密码确认', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm('确定要注销账号吗?\n\n注销后 7 天内重新登录可自动恢复,期满后将永久删除。')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteAccountBtn.disabled = true;
|
||||||
|
deleteAccountBtn.textContent = '注销中...';
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/settings/delete-account', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
deleteAccountBtn.disabled = false;
|
||||||
|
deleteAccountBtn.textContent = '确认注销';
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
showSettingsToast(res.message || '账号已注销');
|
||||||
|
setTimeout(function () {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
showSettingsToast(res.message || '操作失败', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('操作失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify({
|
||||||
|
password: deletePwd.value,
|
||||||
|
reason: deleteReason ? deleteReason.value : ''
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -130,6 +130,45 @@ header {
|
|||||||
color: var(--color-secondary) !important;
|
color: var(--color-secondary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Nav Messages Bell ---------- */
|
||||||
|
.nav-messages {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-messages:hover {
|
||||||
|
background: rgba(52, 152, 219, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bell-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bell-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-danger);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-menu-btn {
|
.mobile-menu-btn {
|
||||||
display: none;
|
display: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
294
templates/MetaLab-2026/static/css/messages.css
Normal file
294
templates/MetaLab-2026/static/css/messages.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -131,6 +131,18 @@ body {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 审核状态提示 */
|
||||||
|
.settings-audit-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: #fff8e1;
|
||||||
|
border-bottom: 1px solid #ffe082;
|
||||||
|
color: #e65100;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-card-body {
|
.settings-card-body {
|
||||||
padding: 1.25rem 2rem 1.5rem;
|
padding: 1.25rem 2rem 1.5rem;
|
||||||
}
|
}
|
||||||
@ -495,3 +507,64 @@ body {
|
|||||||
padding: 1rem 1.25rem 1.25rem;
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Form Group (Password / Delete) ---------- */
|
||||||
|
.settings-card + .settings-card {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: #bbb;
|
||||||
|
margin-left: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Danger Card (Delete Account) ---------- */
|
||||||
|
.settings-card-danger {
|
||||||
|
border: 1px solid #ffcdd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-danger .settings-card-header h2 {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-danger-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: .55rem 1.5rem;
|
||||||
|
font-size: .88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-danger);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s ease, opacity .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-danger-btn:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-danger-btn:disabled {
|
||||||
|
opacity: .6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
@ -137,6 +137,28 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Unread message badge polling ----
|
||||||
|
var msgBadge = document.getElementById('msgBadge');
|
||||||
|
if (msgBadge) {
|
||||||
|
var POLL_INTERVAL = 60 * 1000; // 1 minute
|
||||||
|
function pollUnread() {
|
||||||
|
fetch('/api/messages/unread')
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) return;
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data || !data.success) return;
|
||||||
|
var count = data.data && data.data.unread ? data.data.unread : 0;
|
||||||
|
msgBadge.textContent = count > 99 ? '99+' : count;
|
||||||
|
msgBadge.style.display = count > 0 ? 'flex' : 'none';
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
pollUnread();
|
||||||
|
setInterval(pollUnread, POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Auto-refresh token (silent) ----
|
// ---- Auto-refresh token (silent) ----
|
||||||
// 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期)
|
// 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期)
|
||||||
// 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效
|
// 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效
|
||||||
|
|||||||
@ -64,7 +64,12 @@
|
|||||||
var resp;
|
var resp;
|
||||||
try { resp = JSON.parse(xhr.responseText); } catch (err) { resp = null; }
|
try { resp = JSON.parse(xhr.responseText); } catch (err) { resp = null; }
|
||||||
|
|
||||||
if (xhr.status === 200 && resp && resp.success) {
|
if (xhr.status === 200 && resp && resp.success && resp.action === 'confirm_restore') {
|
||||||
|
// 注销账号登录 → 二次确认恢复
|
||||||
|
if (confirm(resp.message + '\n\n确认继续登录将中止注销流程。')) {
|
||||||
|
doConfirmRestore(email, password);
|
||||||
|
}
|
||||||
|
} else if (xhr.status === 200 && resp && resp.success) {
|
||||||
window.MetaLab.toast(toast, '登录成功', false);
|
window.MetaLab.toast(toast, '登录成功', false);
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
@ -77,4 +82,27 @@
|
|||||||
};
|
};
|
||||||
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 }));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
110
templates/admin/html/audit/index.html
Normal file
110
templates/admin/html/audit/index.html
Normal 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" .}}
|
||||||
@ -33,6 +33,12 @@
|
|||||||
用户管理
|
用户管理
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .CanManageAudits}}
|
||||||
|
<a href="/admin/audits" class="sidebar-item {{if eq .CurrentPath "/admin/audits"}}active{{end}}">
|
||||||
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="m9 14 2 2 4-4"/></svg></span>
|
||||||
|
审核管理
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
|
|||||||
185
templates/admin/static/css/audit.css
Normal file
185
templates/admin/static/css/audit.css
Normal 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;
|
||||||
|
}
|
||||||
260
templates/admin/static/js/audit.js
Normal file
260
templates/admin/static/js/audit.js
Normal 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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user