This repository has been archived on 2026-06-21. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MetaLab/internal/service/audit_service.go
Victor_Jay 55c408d86c fix: 审计问题全量修复 + RateLimiter 原子化重构 + CDN 本地化收紧
- 安全:移除 CSP 中 esm.sh/cdnjs.cloudflare.com,highlight.js 主题已本地化 73 个文件
- 安全:StatusBanned 分支补 dummy hash 防时序攻击
- 安全:手写 constantTimeEq 替换为 crypto/subtle.ConstantTimeCompare
- Bug:锁定操作消息已删除→已锁定
- YAGNI:删除 12 个空预留模板目录
- KISS:删除 common.CheckPassword 薄封装,统一用 bcrypt 调用
- KISS:删除 tokenCtrl 别名字段,api.go 统一用 authCtrl
- RateLimiter:check()+recordFail 闭包模式重构为 try() 原子操作,消除竞态
- RateLimiter:新增 ClearIP() 方法,注册成功时清除 IP 计数
- 文档:修正 audit_service.go 注释编号跳跃(3→5→4)
- 文档:修正 deps_core.go 过清理→过期清理
2026-05-31 11:13:32 +08:00

283 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}
}
// 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 common.ErrAuditDisabled
}
// 检查类型开关
if !s.settings.IsAuditTypeEnabled(auditType, s.typeDefault(auditType)) {
return common.ErrAuditTypeOff
}
// 用户名冲突检查(提交时即拦截,避免提交到后台后审核失败)
if auditType == model.AuditTypeUsername {
// 1) users 表中已被占用
exists, err := s.userRepo.ExistsByUsername(newValue)
if err != nil {
return err
}
if exists {
return common.ErrUsernameTaken
}
// 2) 审核表中已有其他用户提交了同名 pending
pendingExists, err := s.auditRepo.ExistsPendingByTypeAndValue(auditType, newValue, userID)
if err != nil {
return err
}
if pendingExists {
return common.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 common.ErrAuditNotFound
}
return err
}
// 2. 必须 pending
if submission.Status != model.AuditStatusPending {
return common.ErrAuditNotPending
}
// 3. 查审核人信息
reviewer, err := s.userRepo.FindByID(reviewerID)
if err != nil {
return common.ErrUserNotFound
}
// 4. 标记审核结果
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 common.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) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
total, err := s.auditRepo.CountAudits(auditType, status)
if err != nil {
return nil, err
}
items, err := s.auditRepo.ListAudits(auditType, status, p.Offset(), p.PageSize)
if err != nil {
return nil, err
}
if items == nil {
items = []model.AuditSubmission{}
}
return &AuditListResult{
Items: items,
Total: total,
Page: p.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"`
}