fix: golangci-lint 零告警通过 (57→0) + gofumpt/goimports 全量格式化

## CI 修复 (P0/P1)

P0 — 编译阻塞:
  - interfaces.go: postUseCase ISP 接口移除不用的 Create/Update/Delete

P1 — 必须修复:
  - ST1000: 为 13 个包添加包注释 (common/config/model/service/...)
  - ST1005: redis_store.go 全部错误消息改为小写开头
  - errcheck (19处): defer Close()→闭包忽略, notifier.Create→_=, r.Run→检查error
  - errorlint (9处): switch-on-error→errors.Is 链, ==→errors.Is
  - ST1020/ST1022: 导出符号注释以符号名开头

P2 — 安全评审:
  - gosec (12处): G203/G301/G304/G306 添加 nolint 注释并附理由

P3 — 清理:
  - unused: 移除 hasUnicode/energyStore/current/energyAdminUseCase
  - gofumpt + goimports 全量格式化 (35+ 文件)
This commit is contained in:
2026-06-22 02:27:35 +08:00
parent e65f903362
commit 97adf54d6d
75 changed files with 418 additions and 420 deletions

View File

@ -106,5 +106,7 @@ func main() {
// 启动
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
r.Run("0.0.0.0:" + cfg.Server.Port)
if err := r.Run("0.0.0.0:" + cfg.Server.Port); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
}

View File

@ -1,3 +1,4 @@
// Package cache 提供首页等热点数据的内存缓存层。
package cache
import (

View File

@ -1,3 +1,4 @@
// Package common 提供通用工具函数、错误哨兵和响应辅助方法。
package common
import "github.com/gin-gonic/gin"

View File

@ -37,4 +37,3 @@ func ClearSessionCookie(c *gin.Context, cfg *config.Config, siteSettings *config
secure := siteSettings.CookieSecure()
c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true)
}

View File

@ -7,11 +7,11 @@ import (
// SaveUploadedFile 将 multipart.File 内容写入目标路径
func SaveUploadedFile(file multipart.File, dst string) error {
out, err := os.Create(dst)
out, err := os.Create(dst) //nolint:gosec // dst 为服务器内部路径
if err != nil {
return err
}
defer out.Close()
defer func() { _ = out.Close() }()
buf := make([]byte, 32*1024)
for {

View File

@ -35,7 +35,7 @@ func ComputeAssetHashes(templateDir string) {
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32存入 assetHashes
func walkStaticDir(dir, urlPrefix string) {
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
@ -43,7 +43,7 @@ func walkStaticDir(dir, urlPrefix string) {
if ext != ".js" && ext != ".css" {
return nil
}
data, err := os.ReadFile(path)
data, err := os.ReadFile(path) //nolint:gosec // path 来自 filepath.WalkDir 遍历,非用户输入
if err != nil {
return nil
}

View File

@ -57,5 +57,5 @@ func PageCount(total int64, pageSize int) int {
return int((total + int64(pageSize) - 1) / int64(pageSize))
}
// 固定时间格式,前后端统一
// TimeFormat 固定时间格式,前后端统一
const TimeFormat = time.RFC3339

View File

@ -29,7 +29,7 @@ func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
if err := client.Ping(ctx).Err(); err != nil {
log.Printf("[Redis] 连接失败: %v将降级为内存存储", err)
return client, fmt.Errorf("Redis 连接失败: %w", err)
return client, fmt.Errorf("redis 连接失败: %w", err)
}
return client, nil

View File

@ -1,8 +1,9 @@
package common
import (
"github.com/gin-gonic/gin"
"net/http"
"github.com/gin-gonic/gin"
)
// 统一 JSON 响应

View File

@ -7,8 +7,10 @@ import (
)
// usernameChars 随机用户名字符集(小写字母 + 数字)
const usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
const usernameLen = 10
const (
usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
usernameLen = 10
)
// UsernameChecker 用户名查重接口(避免 common 反向依赖 repository
type UsernameChecker interface {

View File

@ -1,3 +1,4 @@
// Package config 管理应用配置的加载、解析和运行时访问。
package config
import (
@ -75,7 +76,7 @@ type RolesPermissions struct {
OperableRoles map[string][]string `mapstructure:"operable_roles"`
}
// 全局配置实例(初始化后只读)
// App 全局配置实例(初始化后只读)
var App *Config
// Load 加载配置config.yaml → .env 覆盖
@ -111,11 +112,11 @@ func Load(configPath string) *Config {
// loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时)
func loadEnvFile(path string) {
f, err := os.Open(path)
f, err := os.Open(path) //nolint:gosec // path 为内部 .env 文件路径
if err != nil {
return // .env 不存在,跳过
}
defer f.Close()
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
@ -130,7 +131,7 @@ func loadEnvFile(path string) {
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if os.Getenv(key) == "" {
os.Setenv(key, val)
_ = os.Setenv(key, val)
}
}
}

View File

@ -1,6 +1,8 @@
// Package admin 提供管理后台的 HTTP 控制器:用户管理、审核、站点设置等。
package admin
import (
"errors"
"net/http"
"strconv"
@ -104,9 +106,10 @@ func (ac *AdminController) UpdateUserStatus(c *gin.Context) {
}
action := "封禁"
if req.Status == model.StatusActive {
switch req.Status {
case model.StatusActive:
action = "解封"
} else if req.Status == model.StatusLocked {
case model.StatusLocked:
action = "已锁定"
}
common.OkMessage(c, action+"成功")
@ -140,14 +143,13 @@ func parseUIDParam(c *gin.Context) (uint, error) {
// handleServiceError 统一处理 service 层返回的错误
func handleServiceError(c *gin.Context, err error) {
switch err {
case common.ErrPermissionDenied:
if errors.Is(err, common.ErrPermissionDenied) {
common.Error(c, http.StatusForbidden, "权限不足")
case common.ErrUserNotFound:
} else if errors.Is(err, common.ErrUserNotFound) {
common.Error(c, http.StatusNotFound, "用户不存在")
case common.ErrEmailExists:
} else if errors.Is(err, common.ErrEmailExists) {
common.Error(c, http.StatusConflict, "该邮箱已被其他用户注册,无法解锁")
default:
} else {
common.Error(c, http.StatusInternalServerError, "操作失败")
}
}

View File

@ -1,6 +1,7 @@
package admin
import (
"errors"
"net/http"
"strconv"
@ -44,7 +45,7 @@ func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) {
}
if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil {
if err == common.ErrInsufficientFund {
if errors.Is(err, common.ErrInsufficientFund) {
common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作")
return
}

View File

@ -1,6 +1,7 @@
package admin
import (
"errors"
"net/http"
"strconv"
@ -125,16 +126,15 @@ func parseIDParam(s string) (uint, error) {
// handleAuditError 统一处理审核接口的 service 层错误
func handleAuditError(c *gin.Context, err error) {
switch err {
case common.ErrAuditNotFound:
if errors.Is(err, common.ErrAuditNotFound) {
common.Error(c, http.StatusNotFound, "审核记录不存在")
case common.ErrAuditNotPending:
} else if errors.Is(err, common.ErrAuditNotPending) {
common.Error(c, http.StatusBadRequest, "该审核记录已处理")
case common.ErrUserNotFound:
} else if errors.Is(err, common.ErrUserNotFound) {
common.Error(c, http.StatusNotFound, "用户不存在")
case common.ErrUsernameTaken:
} else if errors.Is(err, common.ErrUsernameTaken) {
common.Error(c, http.StatusConflict, "该用户名已被其他用户占用,审批失败")
default:
} else {
common.Error(c, http.StatusInternalServerError, "操作失败")
}
}

View File

@ -1,6 +1,7 @@
package controller
import (
"errors"
"net/http"
"metazone.cc/mce/internal/common"
@ -48,21 +49,20 @@ func (ac *AuthController) Login(c *gin.Context) {
user, err := ac.authService.Login(req, ip)
if err != nil {
// 失败计数已在 AllowAccount/AllowIP 中原子递增,无需额外记录
switch err {
case common.ErrInvalidCred:
if errors.Is(err, common.ErrInvalidCred) {
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrUserLocked:
} else if errors.Is(err, common.ErrUserLocked) {
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrMaintenanceMode:
} else if errors.Is(err, common.ErrMaintenanceMode) {
common.Error(c, http.StatusForbidden, "社区正在维护中,仅站长可登录")
case common.ErrNeedsConfirmRestore:
} else if errors.Is(err, common.ErrNeedsConfirmRestore) {
// 注销账号登录 → 需要二次确认恢复
c.JSON(http.StatusOK, gin.H{
"success": true,
"action": "confirm_restore",
"message": "你的账号正在注销中,登录将撤销注销并恢复账号",
})
default:
} else {
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
}
return
@ -93,10 +93,9 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
user, err := ac.authService.ConfirmRestore(req, clientIP(c))
if err != nil {
switch err {
case common.ErrInvalidCred:
if errors.Is(err, common.ErrInvalidCred) {
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
default:
} else {
common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试")
}
return

View File

@ -1,6 +1,7 @@
package controller
import (
"errors"
"net/http"
"metazone.cc/mce/internal/common"
@ -45,16 +46,15 @@ func (ac *AuthController) Register(c *gin.Context) {
user, err := ac.authService.Register(req, clientIP(c))
if err != nil {
// 失败计数已在 AllowIP 中原子递增,无需额外记录
switch err {
case common.ErrMaintenanceMode:
if errors.Is(err, common.ErrMaintenanceMode) {
common.Error(c, http.StatusForbidden, "社区正在维护中,暂不支持注册")
case common.ErrEmailExists:
} else if errors.Is(err, common.ErrEmailExists) {
common.Error(c, http.StatusConflict, "该邮箱已注册")
case common.ErrWeakPassword:
} else if errors.Is(err, common.ErrWeakPassword) {
common.Error(c, http.StatusBadRequest, err.Error())
case common.ErrRegistrationDisabled:
} else if errors.Is(err, common.ErrRegistrationDisabled) {
common.Error(c, http.StatusForbidden, "注册功能已关闭")
default:
} else {
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
}
return

View File

@ -37,7 +37,7 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
guidelinesHTML := ac.siteSettings.Get("site.guidelines", "")
var guidelines template.HTML
if guidelinesHTML != "" {
guidelines = template.HTML(guidelinesHTML)
guidelines = template.HTML(guidelinesHTML) //nolint:gosec // 准则内容由管理员配置,属信任输入
} else {
content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
if err != nil {
@ -48,10 +48,12 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
}
// 替换准则中的硬编码站点名和邮箱
siteInfo := ac.siteSettings.SiteInfo()
guidelines = template.HTML(strings.ReplaceAll(
guidelines = template.HTML( //nolint:gosec // 准则内容由管理员配置,站点名/邮箱来自 DB
strings.ReplaceAll(
strings.ReplaceAll(string(guidelines), "MetaLab", siteInfo.SiteName),
"metazone@foxmail.com", siteInfo.ContactEmail,
))
),
)
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
"Title": "注册",
"ExtraCSS": "/static/css/auth.css",

View File

@ -44,7 +44,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
common.Error(c, http.StatusBadRequest, "请选择文件")
return
}
defer file.Close()
defer func() { _ = file.Close() }()
// 检查扩展名
ext := strings.ToLower(filepath.Ext(header.Filename))
@ -67,7 +67,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
}
storageDir := "storage/uploads/posts"
if err := os.MkdirAll(storageDir, 0755); err != nil {
if err := os.MkdirAll(storageDir, 0o755); err != nil { //nolint:gosec // 上传目录标准权限
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
return
}
@ -88,7 +88,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
if webpData != nil && len(webpData) < len(data) {
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp"
savePath = filepath.Join(storageDir, filename)
os.WriteFile(savePath, webpData, 0644)
_ = os.WriteFile(savePath, webpData, 0o644) //nolint:gosec // 上传文件标准权限
url = "/uploads/posts/" + filename
}
}
@ -101,16 +101,16 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
if decErr == nil {
var buf bytes.Buffer
if ext == ".png" {
png.Encode(&buf, decImg)
_ = png.Encode(&buf, decImg)
} else {
jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
_ = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
}
dataOut = buf.Bytes()
}
}
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext
savePath = filepath.Join(storageDir, filename)
os.WriteFile(savePath, dataOut, 0644)
_ = os.WriteFile(savePath, dataOut, 0o644) //nolint:gosec // 上传文件标准权限
url = "/uploads/posts/" + filename
}

View File

@ -1,6 +1,7 @@
package controller
import (
"errors"
"net/http"
"strconv"
@ -39,18 +40,17 @@ func (ec *EnergyController) Energize(c *gin.Context) {
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
if err != nil {
switch err {
case common.ErrPostNotFound:
if errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusNotFound, "文章不存在")
case common.ErrCannotEnergizeSelf:
} else if errors.Is(err, common.ErrCannotEnergizeSelf) {
common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能")
case common.ErrInsufficientEnergy:
} else if errors.Is(err, common.ErrInsufficientEnergy) {
common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取")
case common.ErrEnergizeLimitReached:
} else if errors.Is(err, common.ErrEnergizeLimitReached) {
common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限")
case common.ErrInvalidEnergyAmount:
} else if errors.Is(err, common.ErrInvalidEnergyAmount) {
common.Error(c, http.StatusBadRequest, "无效的赋能数量")
default:
} else {
common.Error(c, http.StatusInternalServerError, "赋能失败")
}
return

View File

@ -1,6 +1,7 @@
package controller
import (
"errors"
"net/http"
"strconv"
@ -24,7 +25,7 @@ func (fc *FavoriteController) ListFolderItems(c *gin.Context) {
result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize)
if err != nil {
if err == common.ErrPermissionDenied {
if errors.Is(err, common.ErrPermissionDenied) {
common.Error(c, http.StatusForbidden, "该收藏夹未公开")
return
}

View File

@ -1,3 +1,4 @@
// Package controller 定义 HTTP 请求处理层,通过 ISP 接口隔离依赖 Service。
package controller
import (
@ -27,13 +28,10 @@ type rateLimiter interface {
ClearIP(ipKey string)
}
// postUseCase PostController 对 PostService 的最小依赖ISP8 个方法)
// postUseCase PostController 对 PostService 的最小依赖ISP6 个方法)
type postUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
List(keyword string, page, pageSize int) ([]model.Post, int64, error)
Update(postID uint, title, body string) error
Delete(postID uint) error
SubmitForAudit(postID uint) error
RecordRead(userID, postID uint)
RecordGuestRead(visitorID string, postID uint)
@ -100,14 +98,6 @@ type energyRenameHandler interface {
DeductOnRename(userID uint) error
}
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖ISP4 个方法)
type energyAdminUseCase interface {
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
GetFundBalance() (int, error)
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
}
// commentUseCase CommentController 对 CommentService 的最小依赖ISP6 个方法)
type commentUseCase interface {
CreateRoot(userID, postID uint, body string) (*model.Comment, error)

View File

@ -34,7 +34,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
common.Error(c, http.StatusBadRequest, "请选择文件")
return
}
defer file.Close()
defer func() { _ = file.Close() }()
// 检查扩展名
ext := strings.ToLower(filepath.Ext(header.Filename))
@ -59,7 +59,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
// 存储路径
storageDir := "storage/uploads/posts"
if err := os.MkdirAll(storageDir, 0755); err != nil {
if err := os.MkdirAll(storageDir, 0o755); err != nil { //nolint:gosec // 上传目录标准权限
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
return
}
@ -83,7 +83,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
if webpData != nil && len(webpData) < len(data) {
filename := fmt.Sprintf("%d_%d.webp", uid, ts)
savePath = filepath.Join(storageDir, filename)
if err := os.WriteFile(savePath, webpData, 0644); err == nil {
if err := os.WriteFile(savePath, webpData, 0o644); err == nil { //nolint:gosec // 上传文件标准权限
url = "/uploads/posts/" + filename
}
}
@ -127,7 +127,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
savePath = filepath.Join(storageDir, filename)
if err := os.WriteFile(savePath, dataOut, 0644); err != nil {
if err := os.WriteFile(savePath, dataOut, 0o644); err != nil { //nolint:gosec // 上传文件标准权限
common.Error(c, http.StatusInternalServerError, "保存图片失败")
return
}

View File

@ -1,6 +1,7 @@
package controller
import (
"errors"
"net/http"
"strconv"
@ -49,10 +50,9 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
if hasCompleted {
if err := sc.energySvc.DeductOnRename(userID); err != nil {
switch err {
case common.ErrInsufficientEnergy:
if errors.Is(err, common.ErrInsufficientEnergy) {
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
default:
} else {
common.Error(c, http.StatusInternalServerError, "操作失败")
}
return
@ -81,10 +81,9 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
if hasCompleted {
if err := sc.energySvc.DeductOnRename(userID); err != nil {
switch err {
case common.ErrInsufficientEnergy:
if errors.Is(err, common.ErrInsufficientEnergy) {
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
default:
} else {
common.Error(c, http.StatusInternalServerError, "操作失败")
}
return
@ -122,7 +121,7 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
common.Error(c, http.StatusBadRequest, "请选择文件")
return
}
defer file.Close()
defer func() { _ = file.Close() }()
// 裁切参数(可选,来自前端裁切弹窗)
cropX, _ := strconv.Atoi(c.PostForm("crop_x"))

View File

@ -1,6 +1,7 @@
package controller
import (
"errors"
"net/http"
"metazone.cc/mce/internal/common"
@ -10,32 +11,30 @@ import (
// handleSettingsError 统一处理 settings 接口的 service 层错误
func handleSettingsError(c *gin.Context, err error) {
switch err {
case common.ErrUsernameTaken:
if errors.Is(err, common.ErrUsernameTaken) {
common.Error(c, http.StatusConflict, err.Error())
case common.ErrUsernameInvalid:
} else if errors.Is(err, common.ErrUsernameInvalid) {
common.Error(c, http.StatusBadRequest, err.Error())
case common.ErrBioTooLong:
} else if errors.Is(err, common.ErrBioTooLong) {
common.Error(c, http.StatusBadRequest, err.Error())
case common.ErrIncorrectPassword:
} else if errors.Is(err, common.ErrIncorrectPassword) {
common.Error(c, http.StatusForbidden, err.Error())
case common.ErrOwnerCannotDelete:
} else if errors.Is(err, common.ErrOwnerCannotDelete) {
common.Error(c, http.StatusForbidden, err.Error())
default:
} else {
common.Error(c, http.StatusInternalServerError, "操作失败")
}
}
// handleAuditSubmitError 统一处理审核提交的错误
func handleAuditSubmitError(c *gin.Context, err error) {
switch err {
case common.ErrAuditDisabled:
if errors.Is(err, common.ErrAuditDisabled) {
common.Error(c, http.StatusForbidden, "审核功能未开启")
case common.ErrAuditTypeOff:
} else if errors.Is(err, common.ErrAuditTypeOff) {
common.Error(c, http.StatusForbidden, "该类型审核未开启,请联系管理员")
case common.ErrUsernameTaken:
} else if errors.Is(err, common.ErrUsernameTaken) {
common.Error(c, http.StatusConflict, err.Error())
default:
} else {
common.Error(c, http.StatusInternalServerError, "提交审核失败")
}
}

View File

@ -73,9 +73,13 @@ func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
// 分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 { page = 1 }
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "18"))
if pageSize < 1 || pageSize > 50 { pageSize = 18 }
if pageSize < 1 || pageSize > 50 {
pageSize = 18
}
// 构建基础数据(含用户统计)
postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid))

View File

@ -1,6 +1,7 @@
package controller
import (
"errors"
"net/http"
"strconv"
@ -32,7 +33,7 @@ func (ctrl *StudioController) Create(c *gin.Context) {
post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID, req.ScheduledAt)
if err != nil {
if err == common.ErrScheduledTooSoon {
if errors.Is(err, common.ErrScheduledTooSoon) {
common.Error(c, http.StatusBadRequest, err.Error())
return
}

View File

@ -1,3 +1,4 @@
// Package middleware 提供 HTTP 中间件认证、授权、CSRF、安全头、限流等。
package middleware
import (

View File

@ -40,5 +40,3 @@ func generateCSRFToken() (string, error) {
}
return hex.EncodeToString(b), nil
}

View File

@ -1,3 +1,4 @@
// Package model 定义数据模型、常量、角色体系和审核相关类型。
package model
// 审核类型常量

View File

@ -17,4 +17,3 @@ type PostGuestReadLog struct {
PostID uint `gorm:"uniqueIndex:idx_visitor_post_read;not null" json:"post_id"`
ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"`
}

View File

@ -52,9 +52,11 @@ const (
)
// --- 以下由 InitRoles 从配置初始化,无硬编码默认值 ---
var roleLevel map[string]int
var operableRoles map[string][]string
var RoleDisplayNames map[string]string
var (
roleLevel map[string]int
operableRoles map[string][]string
RoleDisplayNames map[string]string
)
// InitRoles 从配置初始化角色系统(唯一数据源)
// 必须在服务启动前调用,不提供默认值

View File

@ -1,3 +1,4 @@
// Package repository 提供基于 GORM 的数据访问层实现。
package repository
import (

View File

@ -73,7 +73,8 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel, limit int, fo
Username string
Avatar string
Exp int
}, error) {
}, error,
) {
type result struct {
UID uint
Username string

View File

@ -161,7 +161,7 @@ func (r *CommentRepo) DecrCommentsCount(postID uint) error {
UpdateColumn("comments_count", gorm.Expr("GREATEST(comments_count - 1, 0)")).Error
}
// CountRepliesByRootByStatus 统计某条顶级评论的回复数(可选是否统计已删除)
// CountRepliesByRoot 统计某条顶级评论的回复数(可选是否统计已删除)
func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, error) {
var count int64
q := r.db.Model(&model.Comment{}).Where("root_id = ? AND parent_id IS NOT NULL", rootID)

View File

@ -1,6 +1,8 @@
package repository
import (
"errors"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
@ -103,7 +105,7 @@ func (r *ReactionRepo) GetPostAuthorID(postID uint) (uint, error) {
func (r *ReactionRepo) UpsertDailyLikeSummary(authorUID uint, date string, likerUID string) error {
var existing model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND date = ?", authorUID, date).First(&existing).Error
if err == gorm.ErrRecordNotFound {
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.Create(&model.DailyLikeSummary{
AuthorUID: authorUID,
Date: date,

View File

@ -1,6 +1,8 @@
package repository
import (
"errors"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
@ -29,7 +31,7 @@ func (r *TagRepo) FindOrCreate(name, slug string) (*model.Tag, error) {
if err == nil {
return &tag, nil
}
if err != gorm.ErrRecordNotFound {
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
tag = model.Tag{Name: name, Slug: slug}

View File

@ -151,4 +151,3 @@ func (r *UserRepo) CountSearchUsers(keyword, role, status string) (int64, error)
err := r.buildSearchQuery(keyword, role, status).Count(&count).Error
return count, err
}

View File

@ -1,3 +1,4 @@
// Package router 负责依赖注入组装和 HTTP 路由注册。
package router
import (

View File

@ -1,3 +1,4 @@
// Package scheduler 提供定时任务调度器,管理定时发布等周期性任务。
package scheduler
import (

View File

@ -1,3 +1,4 @@
// Package service 包含核心业务逻辑实现,通过 Store 接口隔离数据访问层。
package service
import (

View File

@ -1,6 +1,8 @@
package service
import (
"errors"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
@ -12,7 +14,7 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool,
// 1. 查审核记录
submission, err := s.auditRepo.FindByID(submissionID)
if err != nil {
if err == gorm.ErrRecordNotFound {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrAuditNotFound
}
return err

View File

@ -4,7 +4,6 @@ import (
"regexp"
"strings"
"time"
"unicode"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
@ -13,10 +12,12 @@ import (
"gorm.io/gorm"
)
var pwLower = regexp.MustCompile(`[a-z]`)
var pwUpper = regexp.MustCompile(`[A-Z]`)
var pwDigit = regexp.MustCompile(`\d`)
var pwSymbol = regexp.MustCompile(`[^a-zA-Z0-9]`)
var (
pwLower = regexp.MustCompile(`[a-z]`)
pwUpper = regexp.MustCompile(`[A-Z]`)
pwDigit = regexp.MustCompile(`\d`)
pwSymbol = regexp.MustCompile(`[^a-zA-Z0-9]`)
)
// 键盘水平序列QWERTY 布局)
var keyboardHoriz = []string{
@ -181,16 +182,6 @@ func PasswordStrengthHint(level string) string {
return "密码至少 8 位"
}
// hasUnicode 检查是否包含非 ASCII 字符
func hasUnicode(s string) bool {
for _, r := range s {
if r > unicode.MaxASCII {
return true
}
}
return false
}
// ChangePassword 修改密码
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session强制重新登录
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {

View File

@ -97,7 +97,7 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
img, _, err = image.Decode(bytes.NewReader(data))
}
if err != nil {
return "", fmt.Errorf("图片解码失败:%v", err)
return "", fmt.Errorf("图片解码失败:%w", err)
}
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
@ -129,11 +129,11 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
// 6. 编码为 WebP目标 ≤100KB
webpData, err := encodeWebP(resized, avatarTargetKB*1024)
if err != nil {
return "", fmt.Errorf("图片编码失败:%v", err)
return "", fmt.Errorf("图片编码失败:%w", err)
}
// 7. 确保存储目录存在
if err := os.MkdirAll(s.storageDir, 0755); err != nil {
if err := os.MkdirAll(s.storageDir, 0o755); err != nil { //nolint:gosec // 头像存储目录标准权限
return "", fmt.Errorf("创建存储目录失败")
}
@ -143,11 +143,11 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
filename := fmt.Sprintf("%d_%d.webp", userID, ts)
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
finalPath := filepath.Join(s.storageDir, filename)
if err := os.WriteFile(tmpPath, webpData, 0644); err != nil {
if err := os.WriteFile(tmpPath, webpData, 0o644); err != nil { //nolint:gosec // 上传头像标准权限
return "", fmt.Errorf("写入临时文件失败")
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
_ = os.Remove(tmpPath)
return "", fmt.Errorf("保存文件失败")
}
@ -163,7 +163,7 @@ func (s *AvatarService) CleanOldAvatars(userID uint) {
}
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ".webp") {
os.Remove(filepath.Join(s.storageDir, e.Name()))
_ = os.Remove(filepath.Join(s.storageDir, e.Name()))
}
}
}
@ -242,4 +242,3 @@ func encodeWebP(img image.Image, targetBytes int) ([]byte, error) {
}
return buf.Bytes(), nil
}

View File

@ -64,7 +64,7 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod
// 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && userID != postAuthorID {
s.notifier.Create(postAuthorID, model.NotifyComment,
_ = s.notifier.Create(postAuthorID, model.NotifyComment,
"新评论",
fmt.Sprintf("%s 评论了你的文章", commenterName),
&comment.PostID, &comment.ID)
@ -112,7 +112,7 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*
// 通知被回复者不自己回复自己RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && parent.UserID != userID {
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
_ = s.notifier.Create(parent.UserID, model.NotifyCommentReply,
"新回复",
fmt.Sprintf("%s 回复了你的评论", commenterName),
&parent.PostID, &comment.ID)
@ -235,7 +235,7 @@ func (s *CommentService) parseMentions(commentID uint, postID uint, commenterID
// 向被@用户发送通知(排除自己@自己、重复通知)
if s.notifier != nil && mentionedUID != commenterID && !notified[mentionedUID] {
notified[mentionedUID] = true
s.notifier.Create(mentionedUID, model.NotifyCommentMention,
_ = s.notifier.Create(mentionedUID, model.NotifyCommentMention,
"新提及",
fmt.Sprintf("%s 在评论中 @ 了你", commenterName),
&postID, &commentID)

View File

@ -19,7 +19,6 @@ func (s *FavoriteService) AddToFolder(userID, folderID, postID uint) error {
}
return s.repo.Transaction(func(txRepo FavoriteStore) error {
// 如果该文章已在其他收藏夹中,先移除
existing, err := txRepo.FindItemByUserAndPost(userID, postID)
if err == nil && existing.FolderID != folderID {

View File

@ -125,9 +125,6 @@ type EnergyStore interface {
Transaction(fn func(txEnergy EnergyStore, txFund FundStore) error) error
}
// energyStore 向后兼容别名
type energyStore = EnergyStore
// FundStore 公户仓储接口ISP
type FundStore interface {
GetBalance() (int, error)

View File

@ -98,6 +98,7 @@ func parseShortcode(raw string) (model.Shortcode, bool) {
// renderPlaceholder 根据 shortcode 类型生成前端占位 HTML
//
// 占位 div 结构约定:
//
// <div class="zone-card" data-zone-type="类型" data-zone-id="参数"
// data-zone-extra="附加信息">
// <span class="zone-card-loading">卡片加载中...</span>

View File

@ -168,14 +168,6 @@ func (fs *FallbackStore) ping() bool {
return fs.client.Ping(ctx).Err() == nil
}
// current 返回当前应使用的 StoreRedis 健康用 Redis否则用 Memory
func (fs *FallbackStore) current() Store {
if fs.healthy.Load() {
return fs.redis
}
return fs.memory
}
// ---- Store 接口实现Redis 操作失败时立即降级到内存)----
// 不依赖周期性健康检查,操作级故障即时响应

View File

@ -70,7 +70,7 @@ func (m *Manager) Validate(sid string) (*Session, error) {
user, err := m.userRepo.FindByIDForAuth(s.UserID)
if err != nil {
log.Printf("[SessionManager] Validate: user not found uid=%d err=%v", s.UserID, err)
m.store.Delete(sid)
_ = m.store.Delete(sid)
return nil, nil
}

View File

@ -39,7 +39,7 @@ func (ms *MemoryStore) Get(id string) (*Session, error) {
timeout = ms.rememberTimeout
}
if s.IsExpired(timeout) {
ms.Delete(id)
_ = ms.Delete(id)
return nil, nil
}
return s, nil

View File

@ -3,6 +3,7 @@ package session
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"time"
@ -51,16 +52,16 @@ func (rs *RedisStore) sessionKeys(sids []string) []string {
func (rs *RedisStore) Get(id string) (*Session, error) {
ctx := context.Background()
data, err := rs.client.Get(ctx, keyPrefix+id).Bytes()
if err == redis.Nil {
if errors.Is(err, redis.Nil) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Redis GET session: %w", err)
return nil, fmt.Errorf("redis GET session: %w", err)
}
var s Session
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("Redis 反序列化 session: %w", err)
return nil, fmt.Errorf("redis 反序列化 session: %w", err)
}
return &s, nil
}
@ -71,7 +72,7 @@ func (rs *RedisStore) Set(s *Session) error {
data, err := json.Marshal(s)
if err != nil {
return fmt.Errorf("Redis 序列化 session: %w", err)
return fmt.Errorf("redis 序列化 session: %w", err)
}
ttl := rs.idleTimeout
@ -91,7 +92,7 @@ func (rs *RedisStore) Set(s *Session) error {
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("Redis SET session: %w", err)
return fmt.Errorf("redis SET session: %w", err)
}
return nil
}
@ -102,11 +103,11 @@ func (rs *RedisStore) Delete(id string) error {
// 先读取 session 获取 UserID再从集合中移除
data, err := rs.client.Get(ctx, keyPrefix+id).Bytes()
if err == redis.Nil {
if errors.Is(err, redis.Nil) {
return nil
}
if err != nil {
return fmt.Errorf("Redis GET session for delete: %w", err)
return fmt.Errorf("redis GET session for delete: %w", err)
}
var s Session
@ -126,7 +127,7 @@ func (rs *RedisStore) DeleteByUID(uid uint) error {
sids, err := rs.client.SMembers(ctx, uidKey).Result()
if err != nil {
return fmt.Errorf("Redis SMEMBERS user_sessions: %w", err)
return fmt.Errorf("redis SMEMBERS user_sessions: %w", err)
}
if len(sids) == 0 {
@ -138,7 +139,7 @@ func (rs *RedisStore) DeleteByUID(uid uint) error {
pipe.Del(ctx, uidKey)
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("Redis DeleteByUID: %w", err)
return fmt.Errorf("redis DeleteByUID: %w", err)
}
log.Printf("[RedisStore] DeleteByUID uid=%d count=%d", uid, len(sids))
@ -152,7 +153,7 @@ func (rs *RedisStore) DeleteByUIDExclude(uid uint, excludeSID string) error {
sids, err := rs.client.SMembers(ctx, uidKey).Result()
if err != nil {
return fmt.Errorf("Redis SMEMBERS user_sessions: %w", err)
return fmt.Errorf("redis SMEMBERS user_sessions: %w", err)
}
var toDelete []string
@ -176,7 +177,7 @@ func (rs *RedisStore) DeleteByUIDExclude(uid uint, excludeSID string) error {
}
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("Redis DeleteByUIDExclude: %w", err)
return fmt.Errorf("redis DeleteByUIDExclude: %w", err)
}
log.Printf("[RedisStore] DeleteByUIDExclude uid=%d removed=%d kept=%d", uid, len(toDelete), kept)
@ -190,7 +191,7 @@ func (rs *RedisStore) ListByUID(uid uint) ([]*Session, error) {
sids, err := rs.client.SMembers(ctx, uidKey).Result()
if err != nil {
return nil, fmt.Errorf("Redis SMEMBERS user_sessions: %w", err)
return nil, fmt.Errorf("redis SMEMBERS user_sessions: %w", err)
}
if len(sids) == 0 {
@ -199,7 +200,7 @@ func (rs *RedisStore) ListByUID(uid uint) ([]*Session, error) {
results, err := rs.client.MGet(ctx, rs.sessionKeys(sids)...).Result()
if err != nil {
return nil, fmt.Errorf("Redis MGET sessions: %w", err)
return nil, fmt.Errorf("redis MGET sessions: %w", err)
}
var sessions []*Session

View File

@ -1,3 +1,4 @@
// Package session 提供服务端会话管理创建、验证、销毁、Redis/内存双存储及自动降级。
package session
// StoreMetrics 存储状态信息(供管理面板展示)

View File

@ -1,3 +1,4 @@
// Package theme 负责多主题模板的加载、渲染和静态内容管理。
package theme
import (
@ -27,7 +28,7 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
"formatCount": formatCount,
"str": str,
"assetV": common.AssetV,
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"safeHTML": func(s string) template.HTML { return template.HTML(s) }, //nolint:gosec // 模板函数,由调用方保证内容安全
"declarationLabel": func(declaration string) string {
return model.DeclarationLabels[declaration]
},
@ -62,7 +63,7 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
if info.IsDir() || filepath.Ext(path) != ".html" {
return nil
}
content, err := os.ReadFile(path)
content, err := os.ReadFile(path) //nolint:gosec // path 来自 filepath.Walk 遍历,非用户输入
if err != nil {
return err
}
@ -81,11 +82,11 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
// LoadContent 读取主题内容文件(准则等),返回不转义的 template.HTML。
// 后期改为查数据库时,只需修改此函数实现,调用方不变。
func LoadContent(path string) (template.HTML, error) {
content, err := os.ReadFile(path)
content, err := os.ReadFile(path) //nolint:gosec // path 为内部主题文件路径
if err != nil {
return "", err
}
return template.HTML(content), nil
return template.HTML(content), nil //nolint:gosec // 主题内容由管理员维护,属信任输入
}
// formatTTL 将剩余分钟数格式化为人类可读的时间(如 "23 小时"、"3 天"、"15 分钟"