diff --git a/cmd/server/main.go b/cmd/server/main.go index 349db07..758664e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -25,7 +25,7 @@ func main() { if err != nil { log.Fatalf("连接数据库失败: %v", err) } - if err := db.AutoMigrate(&model.User{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}); err != nil { log.Fatalf("数据库迁移失败: %v", err) } @@ -64,8 +64,14 @@ func main() { r.Static("/shared/static", "./templates/shared/static") r.Static("/uploads/avatars", "./storage/uploads/avatars") + // 站点设置管理器(DB 持久化 + 内存缓存) + siteSettings, err := config.NewSiteSettings(db) + if err != nil { + log.Fatalf("初始化站点设置失败: %v", err) + } + // 路由注册 - router.Setup(r, db, cfg) + router.Setup(r, db, cfg, siteSettings) // 启动 log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port) diff --git a/config.yaml b/config.yaml index e41ecdc..eebd386 100644 --- a/config.yaml +++ b/config.yaml @@ -22,6 +22,13 @@ jwt: bcrypt: cost: 12 +# 审核系统配置(站点设置可运行时覆盖,无需重启) +audit: + enabled: true # 审核总开关 + username_audit: true # 用户名修改审核 + avatar_audit: true # 头像修改审核 + bio_audit: true # 个性签名审核 + # 角色配置(可扩展,无需修改源代码) roles: # 角色层级(数字越大权限越高) diff --git a/internal/common/errors.go b/internal/common/errors.go index 0bcbbc8..a014b46 100644 --- a/internal/common/errors.go +++ b/internal/common/errors.go @@ -22,4 +22,16 @@ var ( ErrRateLimitIP = errors.New("请求过于频繁,请稍后重试") ErrRateLimitRegisterIP = errors.New("注册请求过于频繁,请稍后重试") ErrPermissionDenied = errors.New("权限不足") + + // 审核相关 + ErrAuditNotFound = errors.New("审核记录不存在") + ErrAuditNotPending = errors.New("该审核记录已处理") + ErrAuditDisabled = errors.New("审核功能未开启") + ErrAuditTypeOff = errors.New("该类型审核未开启") + + // 密码 & 注销相关 + ErrIncorrectPassword = errors.New("当前密码错误") + ErrLoginBeforeDelete = errors.New("请先登录再注销") + ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号") + ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限") ) diff --git a/internal/common/helper.go b/internal/common/helper.go index 703898f..c2330fc 100644 --- a/internal/common/helper.go +++ b/internal/common/helper.go @@ -42,6 +42,7 @@ func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H { data["RoleDisplayName"] = roleStr } data["CanManageUsers"] = model.HasMinRole(roleStr, model.RoleAdmin) + data["CanManageAudits"] = model.HasMinRole(roleStr, model.RoleAdmin) data["CanManageSettings"] = model.HasMinRole(roleStr, model.RoleOwner) data["IsOwner"] = model.HasMinRole(roleStr, model.RoleOwner) } diff --git a/internal/config/config.go b/internal/config/config.go index cdb1cb0..206ab06 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,15 @@ type Config struct { JWT JWTConfig `mapstructure:"jwt"` Bcrypt BcryptConfig `mapstructure:"bcrypt"` Roles RolesConfig `mapstructure:"roles"` + Audit AuditConfig `mapstructure:"audit"` +} + +// AuditConfig 审核系统配置(config.yaml 提供默认值,站点设置可运行时覆盖) +type AuditConfig struct { + Enabled bool `mapstructure:"enabled"` + UsernameAudit bool `mapstructure:"username_audit"` + AvatarAudit bool `mapstructure:"avatar_audit"` + BioAudit bool `mapstructure:"bio_audit"` } type ServerConfig struct { diff --git a/internal/config/site_settings.go b/internal/config/site_settings.go new file mode 100644 index 0000000..ad63b20 --- /dev/null +++ b/internal/config/site_settings.go @@ -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) +} diff --git a/internal/controller/admin/audit_controller.go b/internal/controller/admin/audit_controller.go new file mode 100644 index 0000000..d3bad63 --- /dev/null +++ b/internal/controller/admin/audit_controller.go @@ -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, "操作失败") + } +} diff --git a/internal/controller/admin/interfaces.go b/internal/controller/admin/interfaces.go index 505f36c..7fcfead 100644 --- a/internal/controller/admin/interfaces.go +++ b/internal/controller/admin/interfaces.go @@ -1,6 +1,9 @@ package admin -import "metazone.cc/metalab/internal/service" +import ( + "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/service" +) // adminUseCase AdminController 对 AdminService 的最小依赖(ISP:4 个方法) type adminUseCase interface { @@ -9,3 +12,24 @@ type adminUseCase interface { ResetUserToken(operatorUID, targetUID uint) 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) +} diff --git a/internal/controller/admin/site_setting_controller.go b/internal/controller/admin/site_setting_controller.go new file mode 100644 index 0000000..d45f14e --- /dev/null +++ b/internal/controller/admin/site_setting_controller.go @@ -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}) + } +} diff --git a/internal/controller/auth_controller.go b/internal/controller/auth_controller.go index d7be4a5..00f4c3e 100644 --- a/internal/controller/auth_controller.go +++ b/internal/controller/auth_controller.go @@ -90,7 +90,7 @@ func (ac *AuthController) Login(c *gin.Context) { return } - accessToken, refreshToken, user, err := ac.authService.Login(req) + accessToken, refreshToken, user, err := ac.authService.Login(req, ip) if err != nil { // 记录失败 → 两个维度各 +1 if recordAccount != nil { @@ -107,9 +107,13 @@ func (ac *AuthController) Login(c *gin.Context) { common.Error(c, http.StatusForbidden, "账号已被封禁") case common.ErrUserLocked: common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") - case common.ErrUserDeleted: - // deleted 状态本应在 Login 中自动恢复,此 case 作为兜底 - common.Error(c, http.StatusForbidden, "该账号已申请注销,登录即自动恢复") + case common.ErrNeedsConfirmRestore: + // 注销账号登录 → 需要二次确认恢复 + c.JSON(http.StatusOK, gin.H{ + "success": true, + "action": "confirm_restore", + "message": "你的账号正在注销中,登录将中止注销流程", + }) default: common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试") } @@ -124,6 +128,29 @@ func (ac *AuthController) Login(c *gin.Context) { common.OkWithMessage(c, user, "登录成功") } +// ConfirmRestore 二次确认恢复已注销账号 +func (ac *AuthController) ConfirmRestore(c *gin.Context) { + var req model.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "请检查输入") + return + } + + accessToken, refreshToken, user, err := ac.authService.ConfirmRestore(req, clientIP(c)) + if err != nil { + switch err { + case common.ErrInvalidCred: + common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") + default: + common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试") + } + return + } + + common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg) + common.OkWithMessage(c, user, "账号已恢复,欢迎回来") +} + // CheckEmail 检查邮箱是否已注册 func (ac *AuthController) CheckEmail(c *gin.Context) { var req model.CheckEmailRequest @@ -156,7 +183,7 @@ func (ac *AuthController) Register(c *gin.Context) { return } - accessToken, refreshToken, user, err := ac.authService.Register(req) + accessToken, refreshToken, user, err := ac.authService.Register(req, clientIP(c)) if err != nil { if recordReg != nil { recordReg() diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index 4ca648b..7b2c2fb 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -5,10 +5,11 @@ import ( "metazone.cc/metalab/internal/model" ) -// authUseCase AuthController 对 AuthService 的最小依赖(ISP:3 个方法) +// authUseCase AuthController 对 AuthService 的最小依赖(ISP:4 个方法) type authUseCase interface { - Register(req model.RegisterRequest) (string, string, *model.User, error) - Login(req model.LoginRequest) (string, string, *model.User, error) + Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) + Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) + ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) CheckEmail(email string) (bool, error) } diff --git a/internal/controller/message_controller.go b/internal/controller/message_controller.go new file mode 100644 index 0000000..6c2888d --- /dev/null +++ b/internal/controller/message_controller.go @@ -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, "已全部标为已读") +} diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go index c9ed1de..155f561 100644 --- a/internal/controller/settings_controller.go +++ b/internal/controller/settings_controller.go @@ -11,26 +11,38 @@ import ( "github.com/gin-gonic/gin" ) -// profileProvider SettingsController 对 Service 层的最小依赖(ISP:2 个方法) +// profileProvider SettingsController 对 Service 层的最小依赖(ISP:4 个方法) type profileProvider interface { GetProfile(userID uint) (*model.User, error) UpdateProfile(userID uint, username, bio string) error + ChangePassword(userID uint, currentPassword, newPassword string) error + DeleteAccount(userID uint, password, reason string) error } -// avatarProvider 头像上传对 Service 层的最小依赖(ISP:1 个方法) +// avatarProvider 头像上传对 Service 层的最小依赖(ISP:2 个方法) type avatarProvider interface { ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) + ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) +} + +// auditSubmittable 个人设置对审核服务的依赖(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 个人设置控制器 type SettingsController struct { authService profileProvider avatarService avatarProvider + auditService auditSubmittable } // NewSettingsController 构造函数 -func NewSettingsController(authService profileProvider, avatarService avatarProvider) *SettingsController { - return &SettingsController{authService: authService, avatarService: avatarService} +func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable) *SettingsController { + return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService} } // SettingsPage 个人设置页面(需登录) @@ -55,17 +67,23 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { return } + // 查询待审核类型(用于前端显示审核提示) + pendingTypes, _ := sc.auditService.GetPendingTypes(uid) + c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{ - "Title": "个人设置", - "ExtraCSS": "/static/css/settings.css", - "ActiveTab": tab, - "User": user, - "RoleName": model.RoleDisplayNames[user.Role], - "StatusName": model.StatusDisplayNames[user.Status], + "Title": "个人设置", + "ExtraCSS": "/static/css/settings.css", + "ActiveTab": tab, + "User": user, + "RoleName": model.RoleDisplayNames[user.Role], + "StatusName": model.StatusDisplayNames[user.Status], + "PendingTypes": pendingTypes, + "AuditTypes": model.AuditTypeNames, })) } // UpdateProfile 修改个人资料(用户名 + 个性签名,需登录) +// 普通用户走审核流程,管理员及以上直接落库 func (sc *SettingsController) UpdateProfile(c *gin.Context) { uid, exists := c.Get("uid") if !exists { @@ -82,7 +100,31 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) { return } - if err := sc.authService.UpdateProfile(uid.(uint), req.Username, req.Bio); err != nil { + userID := uid.(uint) + + // 判断是否需要审核 + shouldAudit, err := sc.auditService.ShouldAudit(userID) + if err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + + if shouldAudit { + user, err := sc.authService.GetProfile(userID) + if err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil { + handleAuditSubmitError(c, err) + return + } + common.OkMessage(c, "修改已提交审核,通过前当前信息保持不变") + return + } + + // 管理员及以上直接更新 + if err := sc.authService.UpdateProfile(userID, req.Username, req.Bio); err != nil { handleSettingsError(c, err) return } @@ -91,6 +133,7 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) { } // UploadAvatar 上传头像(需登录,multipart/form-data) +// 普通用户走审核流程,管理员及以上直接更新 func (sc *SettingsController) UploadAvatar(c *gin.Context) { uid, exists := c.Get("uid") if !exists { @@ -110,7 +153,32 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) { cropY, _ := strconv.Atoi(c.PostForm("crop_y")) cropSize, _ := strconv.Atoi(c.PostForm("crop_size")) - url, err := sc.avatarService.ProcessAvatar(uid.(uint), file, header.Header.Get("Content-Type"), cropX, cropY, cropSize) + userID := uid.(uint) + + // 判断是否需要审核 + shouldAudit, err := sc.auditService.ShouldAudit(userID) + if err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + + if shouldAudit { + // 仅处理图片,不更新用户 + avatarURL, err := sc.avatarService.ProcessImage(userID, file, header.Header.Get("Content-Type"), cropX, cropY, cropSize) + if err != nil { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + if err := sc.auditService.Submit(userID, model.AuditTypeAvatar, avatarURL); err != nil { + handleAuditSubmitError(c, err) + return + } + common.OkMessage(c, "头像已提交审核,通过前当前头像保持不变") + return + } + + // 管理员及以上直接更新 + url, err := sc.avatarService.ProcessAvatar(userID, file, header.Header.Get("Content-Type"), cropX, cropY, cropSize) if err != nil { common.Error(c, http.StatusBadRequest, err.Error()) return @@ -119,6 +187,56 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) { common.Ok(c, gin.H{"url": url}) } +// ChangePassword 修改密码(需登录) +func (sc *SettingsController) ChangePassword(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + var req struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "请检查输入") + return + } + + if err := sc.authService.ChangePassword(uid.(uint), req.CurrentPassword, req.NewPassword); err != nil { + handleSettingsError(c, err) + return + } + + common.OkMessage(c, "密码已修改,请重新登录") +} + +// DeleteAccount 自助注销账号(需登录,验证密码 + 注销原因) +func (sc *SettingsController) DeleteAccount(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + var req struct { + Password string `json:"password" binding:"required"` + Reason string `json:"reason"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "请检查输入") + return + } + + if err := sc.authService.DeleteAccount(uid.(uint), req.Password, req.Reason); err != nil { + handleSettingsError(c, err) + return + } + + common.OkMessage(c, "账号已注销,7 天内重新登录即可恢复") +} + // handleSettingsError 统一处理 settings 接口的 service 层错误 func handleSettingsError(c *gin.Context, err error) { switch err { @@ -128,7 +246,44 @@ func handleSettingsError(c *gin.Context, err error) { common.Error(c, http.StatusBadRequest, err.Error()) case common.ErrBioTooLong: common.Error(c, http.StatusBadRequest, err.Error()) + case common.ErrIncorrectPassword: + common.Error(c, http.StatusForbidden, err.Error()) + case common.ErrOwnerCannotDelete: + common.Error(c, http.StatusForbidden, err.Error()) default: common.Error(c, http.StatusInternalServerError, "操作失败") } } + +// handleAuditSubmitError 统一处理审核提交的错误 +func handleAuditSubmitError(c *gin.Context, err error) { + switch err { + case common.ErrAuditDisabled: + common.Error(c, http.StatusForbidden, "审核功能未开启") + case common.ErrAuditTypeOff: + common.Error(c, http.StatusForbidden, "该类型审核未开启,请联系管理员") + case common.ErrUsernameTaken: + common.Error(c, http.StatusConflict, err.Error()) + default: + common.Error(c, http.StatusInternalServerError, "提交审核失败") + } +} + +// AuditStatus 查询当前用户的待审核类型(需登录) +func (sc *SettingsController) AuditStatus(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + types, err := sc.auditService.GetPendingTypes(uid.(uint)) + if err != nil { + common.Ok(c, gin.H{"pending_types": []string{}}) + return + } + if types == nil { + types = []string{} + } + common.Ok(c, gin.H{"pending_types": types}) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 4bbd54d..8fbe3ef 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -70,7 +70,7 @@ func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc { return } - log.Printf("[AdminAuth] OK: uid=%d role=%v path=%s", uint(claims["uid"].(float64)), claims["role"], c.Request.URL.Path) + log.Printf("[AdminAuth] OK: uid=%v role=%v path=%s", claims["uid"], claims["role"], c.Request.URL.Path) injectUserContext(c, claims) c.Next() } @@ -93,19 +93,27 @@ func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, erro tokenVer := int(claims["ver"].(float64)) currentVer, err := am.userRepo.FindTokenVersion(uid) - if err != nil || tokenVer != currentVer { - if tokenVer != currentVer { - log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer) - } + if err != nil { return nil, err } + if tokenVer != currentVer { + log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer) + return nil, common.ErrTokenRevoked + } return claims, nil } // injectUserContext 将 JWT claims 中的用户信息注入 gin context func injectUserContext(c *gin.Context, claims jwt.MapClaims) { - c.Set("uid", uint(claims["uid"].(float64))) + if claims == nil { + return + } + uid, ok := claims["uid"].(float64) + if !ok { + return + } + c.Set("uid", uint(uid)) c.Set("email", claims["email"]) c.Set("username", claims["username"]) c.Set("role", claims["role"]) diff --git a/internal/model/audit.go b/internal/model/audit.go new file mode 100644 index 0000000..dc25cc3 --- /dev/null +++ b/internal/model/audit.go @@ -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 +} diff --git a/internal/model/dto.go b/internal/model/dto.go index f99ae78..d0fc7f6 100644 --- a/internal/model/dto.go +++ b/internal/model/dto.go @@ -19,3 +19,15 @@ type LoginRequest struct { type CheckEmailRequest struct { Email string `json:"email" binding:"required,email"` } + +// ChangePasswordRequest 修改密码请求 +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` +} + +// DeleteAccountRequest 注销账号请求 +type DeleteAccountRequest struct { + Password string `json:"password" binding:"required"` + Reason string `json:"reason"` +} diff --git a/internal/model/notification.go b/internal/model/notification.go new file mode 100644 index 0000000..d6efce2 --- /dev/null +++ b/internal/model/notification.go @@ -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"` +} diff --git a/internal/model/site_setting.go b/internal/model/site_setting.go new file mode 100644 index 0000000..c859a50 --- /dev/null +++ b/internal/model/site_setting.go @@ -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"` +} diff --git a/internal/model/user.go b/internal/model/user.go index df816cc..71d7a61 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -1,5 +1,7 @@ package model +import "time" + // User 用户模型 type User struct { BaseModel @@ -12,7 +14,13 @@ type User struct { Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"` Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"` - TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT + TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT + + // 安全与审计 + RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP + LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP + LastLoginAt *time.Time `gorm:"default:null" json:"last_login_at,omitempty"` // 最后登录时间 + DeleteReason string `gorm:"type:text;default:''" json:"-"` // 注销原因 } // 角色常量(仅用作字符串标识符,层级和权限由配置驱动) diff --git a/internal/repository/audit_repo.go b/internal/repository/audit_repo.go new file mode 100644 index 0000000..b58dd48 --- /dev/null +++ b/internal/repository/audit_repo.go @@ -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 +} diff --git a/internal/repository/notification_repo.go b/internal/repository/notification_repo.go new file mode 100644 index 0000000..89ff7cb --- /dev/null +++ b/internal/repository/notification_repo.go @@ -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 +} diff --git a/internal/repository/site_setting_repo.go b/internal/repository/site_setting_repo.go new file mode 100644 index 0000000..bb8e02c --- /dev/null +++ b/internal/repository/site_setting_repo.go @@ -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 +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index 4644b6f..3710538 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -21,10 +21,10 @@ func (r *UserRepo) Create(user *model.User) error { return r.db.Create(user).Error } -// FindByEmail 按邮箱查找用户 +// FindByEmail 按邮箱查找用户(含软删除,用于登录等需要找到已注销用户的场景) func (r *UserRepo) FindByEmail(email string) (*model.User, error) { var user model.User - err := r.db.Where("email = ?", email).First(&user).Error + err := r.db.Unscoped().Where("email = ?", email).First(&user).Error if err != nil { return nil, err } diff --git a/internal/router/router.go b/internal/router/router.go index cf16265..4f00f1f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -17,7 +17,7 @@ import ( ) // Setup 注册所有路由 -func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) { +func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) { // --- 全局中间件 --- r.Use(middleware.SecurityHeaders()) @@ -28,14 +28,35 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) { rateLimiter := middleware.NewRateLimiter() authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg) avatarSvc := service.NewAvatarService(userRepo) - settingsCtrl := controller.NewSettingsController(authService, avatarSvc) - authMdw := middleware.NewAuthMiddleware(cfg, userRepo) // 管理后台 adminService := service.NewAdminService(userRepo) adminController := adminCtrl.NewAdminController(adminService) + // 审核系统 + auditRepo := repository.NewAuditRepo(db) + notificationRepo := repository.NewNotificationRepo(db) + notificationService := service.NewNotificationService(notificationRepo) + auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService) + auditController := adminCtrl.NewAuditController(auditService, userRepo) + + // 消息中心 + messageController := controller.NewMessageController(notificationService) + + // 站点设置 + siteSettingService := service.NewSiteSettingService(siteSettings) + auditDefaults := &service.AuditDefaults{ + Enabled: cfg.Audit.Enabled, + UsernameAudit: cfg.Audit.UsernameAudit, + AvatarAudit: cfg.Audit.AvatarAudit, + BioAudit: cfg.Audit.BioAudit, + } + siteSettingController := adminCtrl.NewSiteSettingController(siteSettingService, auditDefaults) + + // 个人设置(需要审核服务) + settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService) + // --- 页面路由(CSRF 仅下发 token,不验证——页面 GET 被豁免) --- pages := r.Group("/") pages.Use(authMdw.Optional()) @@ -58,6 +79,9 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) { c.Redirect(http.StatusFound, "/settings/profile") }) pages.GET("/settings/:tab", settingsCtrl.SettingsPage) + + // 消息中心(需登录,noindex) + pages.GET("/messages", messageController.MessagesPage) } // 认证页面(已登录自动跳走) @@ -80,6 +104,20 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) { { settingsAPI.PUT("/profile", settingsCtrl.UpdateProfile) settingsAPI.POST("/avatar", settingsCtrl.UploadAvatar) + settingsAPI.PUT("/password", settingsCtrl.ChangePassword) + settingsAPI.POST("/delete-account", settingsCtrl.DeleteAccount) + // 查询当前用户的待审核状态 + settingsAPI.GET("/audit-status", settingsCtrl.AuditStatus) + } + + // --- 消息中心 API(需登录) --- + messagesAPI := r.Group("/api/messages") + messagesAPI.Use(authMdw.Required()) + messagesAPI.Use(middleware.CSRF(cfg)) + { + messagesAPI.GET("/unread", messageController.UnreadCount) + messagesAPI.POST("/:id/read", messageController.MarkRead) + messagesAPI.POST("/read-all", messageController.MarkAllRead) } // --- 管理后台 SSR 页面(认证失败 302 跳首页) --- @@ -95,6 +133,8 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) { adminPages.GET("/", adminController.Dashboard) adminPages.GET("/users", adminController.UsersPage, middleware.RequirePageRole(model.RoleAdmin)) + adminPages.GET("/audits", auditController.AuditPage, + middleware.RequirePageRole(model.RoleAdmin)) } // --- 管理后台 API(JSON 响应) --- @@ -106,6 +146,20 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) { adminAPI.GET("/users", adminController.ListUsers) adminAPI.PUT("/users/:uid/status", adminController.UpdateUserStatus) adminAPI.POST("/users/:uid/reset-token", adminController.ResetToken) + // 审核 API + adminAPI.GET("/audits", auditController.ListAudits) + adminAPI.POST("/audits/:id/approve", auditController.Approve) + adminAPI.POST("/audits/:id/reject", auditController.Reject) + } + + // --- 站点设置 API(仅 owner 可访问) --- + siteSettingsAPI := r.Group("/api/admin/site-settings") + siteSettingsAPI.Use(authMdw.Required()) + siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner)) + siteSettingsAPI.Use(middleware.CSRF(cfg)) + { + siteSettingsAPI.GET("", siteSettingController.GetSettings) + siteSettingsAPI.PUT("", siteSettingController.UpdateSetting) } // --- API 路由(CSRF 严格验证) --- @@ -115,6 +169,7 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) { api.POST("/auth/check-email", authCtrl.CheckEmail) api.POST("/auth/register", authCtrl.Register) api.POST("/auth/login", authCtrl.Login) + api.POST("/auth/confirm-restore", authCtrl.ConfirmRestore) api.POST("/auth/logout", authCtrl.Logout) api.POST("/auth/refresh", authCtrl.RefreshToken) } diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index efeb1d9..46df3b1 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -4,9 +4,6 @@ import ( "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/model" ) - -// AdminService 管理后台业务逻辑 -// 集中处理所有权限边界检查,避免胖控制器 type AdminService struct { userRepo userAdminStore } @@ -34,20 +31,15 @@ type ListUsersResult struct { // ListUsers 分页搜索用户列表 func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, error) { - if params.Page < 1 { - params.Page = 1 - } - if params.PageSize < 1 || params.PageSize > 100 { - params.PageSize = 20 - } - offset := (params.Page - 1) * params.PageSize + p := common.Pagination{Page: params.Page, PageSize: params.PageSize} + p.DefaultPagination() total, err := s.userRepo.CountSearchUsers(params.Keyword, params.Role, params.Status) if err != nil { return nil, err } - users, err := s.userRepo.SearchUsers(params.Keyword, params.Role, params.Status, offset, params.PageSize) + users, err := s.userRepo.SearchUsers(params.Keyword, params.Role, params.Status, p.Offset(), p.PageSize) if err != nil { return nil, err } @@ -55,7 +47,7 @@ func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, erro return &ListUsersResult{ Users: users, Total: total, - Page: params.Page, + Page: p.Page, }, nil } diff --git a/internal/service/audit_service.go b/internal/service/audit_service.go new file mode 100644 index 0000000..95220e1 --- /dev/null +++ b/internal/service/audit_service.go @@ -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"` +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index b4e3e9f..65ce43c 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -2,6 +2,7 @@ package service import ( "regexp" + "time" "unicode/utf8" "metazone.cc/metalab/internal/common" @@ -25,18 +26,21 @@ func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config. } var ( - ErrEmailExists = common.ErrEmailExists - ErrUsernameTaken = common.ErrUsernameTaken - ErrInvalidCred = common.ErrInvalidCred - ErrUserNotFound = common.ErrUserNotFound - ErrUserBanned = common.ErrUserBanned - ErrUserLocked = common.ErrUserLocked - ErrUserDeleted = common.ErrUserDeleted - ErrWeakPassword = common.ErrWeakPassword - ErrTokenExpired = common.ErrTokenExpired - ErrTokenInvalid = common.ErrTokenInvalid - ErrTokenRevoked = common.ErrTokenRevoked - ErrPermissionDenied = common.ErrPermissionDenied + ErrEmailExists = common.ErrEmailExists + ErrUsernameTaken = common.ErrUsernameTaken + ErrInvalidCred = common.ErrInvalidCred + ErrUserNotFound = common.ErrUserNotFound + ErrUserBanned = common.ErrUserBanned + ErrUserLocked = common.ErrUserLocked + ErrUserDeleted = common.ErrUserDeleted + ErrWeakPassword = common.ErrWeakPassword + ErrTokenExpired = common.ErrTokenExpired + ErrTokenInvalid = common.ErrTokenInvalid + ErrTokenRevoked = common.ErrTokenRevoked + ErrPermissionDenied = common.ErrPermissionDenied + ErrIncorrectPassword = common.ErrIncorrectPassword + ErrNeedsConfirmRestore = common.ErrNeedsConfirmRestore + ErrOwnerCannotDelete = common.ErrOwnerCannotDelete ) var pwLetter = regexp.MustCompile(`[a-zA-Z]`) @@ -68,7 +72,8 @@ func validatePassword(pw string) error { // Register 注册 // 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT // rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除) -func (s *AuthService) Register(req model.RegisterRequest) (string, string, *model.User, error) { +// regIP: 注册 IP 地址 +func (s *AuthService) Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) { // 1. 密码强度 if err := validatePassword(req.Password); err != nil { return "", "", nil, err @@ -102,6 +107,7 @@ func (s *AuthService) Register(req model.RegisterRequest) (string, string, *mode Username: username, Role: model.RoleUser, Status: model.StatusActive, + RegIP: regIP, } if err := s.userRepo.Create(user); err != nil { return "", "", nil, err @@ -123,24 +129,25 @@ func (s *AuthService) Register(req model.RegisterRequest) (string, string, *mode } // Login 登录 -// 流程:按邮箱查找 → 检查状态 → 验证密码 → access JWT + refresh JWT +// 流程:按邮箱查找 → 已注销则验证密码后要求二次确认 → 检查状态 → 验证密码 → 记录登录 IP/时间 → access JWT + refresh JWT // 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对 // rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除) -func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User, error) { +// loginIP: 登录 IP 地址 +// 若用户处于 deleted 状态且密码正确 → 返回 ErrNeedsConfirmRestore(不自动恢复,不签发 token) +func (s *AuthService) Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) { user, err := s.userRepo.FindByEmail(req.Email) if err != nil { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) return "", "", nil, ErrInvalidCred } - // 已注销(deleted)→ 自动恢复 + // 已注销(deleted)→ 验证密码后要求二次确认,不自动恢复 if user.Status == model.StatusDeleted { - user.Status = model.StatusActive - user.DeletedAt = gorm.DeletedAt{} - if err := s.userRepo.Update(user); err != nil { - return "", "", nil, err + if !common.CheckPassword(req.Password, user.PasswordHash) { + _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) + return "", "", nil, ErrInvalidCred } - // 恢复后继续正常登录流程 + return "", "", nil, ErrNeedsConfirmRestore } // 永久锁定 @@ -158,6 +165,14 @@ func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User return "", "", nil, ErrInvalidCred } + // 记录登录 IP 和时间 + now := time.Now() + user.LastLoginIP = loginIP + user.LastLoginAt = &now + if err := s.userRepo.Update(user); err != nil { + return "", "", nil, err + } + // 生成 access JWT accessToken, err := s.tokenService.BuildAccessToken(user) if err != nil { @@ -173,6 +188,47 @@ func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User return accessToken, refreshToken, user, nil } +// ConfirmRestore 二次确认恢复已注销账号 +// 流程:按邮箱查找 → 验证密码 → 恢复为 active → 记录登录 IP/时间 → 签发 token +func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) { + user, err := s.userRepo.FindByEmail(req.Email) + if err != nil { + _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) + return "", "", nil, ErrInvalidCred + } + + // 仅 deleted 状态允许恢复 + if user.Status != model.StatusDeleted { + return "", "", nil, ErrUserNotFound + } + + if !common.CheckPassword(req.Password, user.PasswordHash) { + return "", "", nil, ErrInvalidCred + } + + // 恢复账号 + user.Status = model.StatusActive + user.DeletedAt = gorm.DeletedAt{} + now := time.Now() + user.LastLoginIP = loginIP + user.LastLoginAt = &now + if err := s.userRepo.Update(user); err != nil { + return "", "", nil, err + } + + accessToken, err := s.tokenService.BuildAccessToken(user) + if err != nil { + return "", "", nil, err + } + + refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe) + if err != nil { + return "", "", nil, err + } + + return accessToken, refreshToken, user, nil +} + // CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查) func (s *AuthService) CheckEmail(email string) (bool, error) { return s.userRepo.ExistsByEmail(email) @@ -183,6 +239,68 @@ func (s *AuthService) GetProfile(userID uint) (*model.User, error) { return s.userRepo.FindByID(userID) } +// ChangePassword 修改密码 +// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 吊销所有 JWT(强制重新登录) +func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return common.ErrUserNotFound + } + + // 验证当前密码 + if !common.CheckPassword(currentPassword, user.PasswordHash) { + return ErrIncorrectPassword + } + + // 新密码强度校验 + if err := validatePassword(newPassword); err != nil { + return err + } + + // 哈希新密码 + hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost) + if err != nil { + return err + } + + user.PasswordHash = hash + if err := s.userRepo.Update(user); err != nil { + return err + } + + // 强制所有设备重新登录(安全性) + return s.InvalidateSessions(userID) +} + +// DeleteAccount 用户自主注销 +// 流程:检查角色 → 验证密码 → 记录原因 → 设置 deleted 状态 → 吊销所有 JWT → 7 天冷却期内登录需二次确认恢复 +func (s *AuthService) DeleteAccount(userID uint, password, reason string) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return common.ErrUserNotFound + } + + // 站长不允许自主注销(避免权限体系死锁) + if user.Role == model.RoleOwner { + return ErrOwnerCannotDelete + } + + // 验证当前密码(防止 CSRF 或未授权操作) + if !common.CheckPassword(password, user.PasswordHash) { + return ErrIncorrectPassword + } + + user.Status = model.StatusDeleted + user.DeleteReason = reason + + if err := s.userRepo.Update(user); err != nil { + return err + } + + // 递增 token_version,即时吊销所有 JWT 强制退登 + return s.InvalidateSessions(userID) +} + // InvalidateSessions 吊销某用户所有 JWT(递增 token_version,强制所有设备重新登录) // 适用场景:修改密码、账号被盗、管理员强制下线 func (s *AuthService) InvalidateSessions(userID uint) error { diff --git a/internal/service/avatar_service.go b/internal/service/avatar_service.go index 2a6bb15..34c63a1 100644 --- a/internal/service/avatar_service.go +++ b/internal/service/avatar_service.go @@ -36,6 +36,7 @@ const ( type userAvatarStore interface { FindByID(id uint) (*model.User, error) Update(user *model.User) error + FindByIDUnscoped(userID uint) (*model.User, error) } // AvatarService 头像上传处理服务 @@ -49,10 +50,27 @@ func NewAvatarService(userRepo userAvatarStore) *AvatarService { return &AvatarService{userRepo: userRepo, storageDir: avatarStorageDir} } -// ProcessAvatar 处理头像上传流程 -// cropX, cropY, cropSize: 自定义裁切参数(原图像素坐标),全 0 则默认中心裁切 -// 返回头像 URL,失败返回错误 +// ProcessAvatar 处理头像上传流程(完整流程:处理图片 + 更新 User) func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) { + avatarURL, err := s.ProcessImage(userID, file, contentType, cropX, cropY, cropSize) + if err != nil { + return "", err + } + + user, err := s.userRepo.FindByID(userID) + if err != nil { + return "", fmt.Errorf("用户不存在") + } + user.Avatar = avatarURL + if err := s.userRepo.Update(user); err != nil { + return "", fmt.Errorf("更新头像失败") + } + + return avatarURL, nil +} + +// ProcessImage 处理头像上传流程(仅处理图片 + 保存文件,不更新 User,审核场景用) +func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) { // 1. 检查文件类型(不支持 GIF:裁切后无法保持动画) if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" { return "", fmt.Errorf("仅支持 JPEG、PNG、WebP 格式") @@ -127,18 +145,7 @@ func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType s return "", fmt.Errorf("保存文件失败") } - // 10. 更新用户头像字段 - user, err := s.userRepo.FindByID(userID) - if err != nil { - return "", fmt.Errorf("用户不存在") - } - avatarURL := fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts) - user.Avatar = avatarURL - if err := s.userRepo.Update(user); err != nil { - return "", fmt.Errorf("更新头像失败") - } - - return avatarURL, nil + return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil } // cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传) diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go new file mode 100644 index 0000000..4d62dad --- /dev/null +++ b/internal/service/notification_service.go @@ -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) +} diff --git a/internal/service/site_setting_service.go b/internal/service/site_setting_service.go new file mode 100644 index 0000000..df5bb58 --- /dev/null +++ b/internal/service/site_setting_service.go @@ -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) +} diff --git a/templates/MetaLab-2026/html/layout/nav.html b/templates/MetaLab-2026/html/layout/nav.html index c78d203..f6d13ee 100644 --- a/templates/MetaLab-2026/html/layout/nav.html +++ b/templates/MetaLab-2026/html/layout/nav.html @@ -7,6 +7,12 @@