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) 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 package cache
import ( import (

View File

@ -1,3 +1,4 @@
// Package common 提供通用工具函数、错误哨兵和响应辅助方法。
package common package common
import "github.com/gin-gonic/gin" 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() secure := siteSettings.CookieSecure()
c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true) c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true)
} }

View File

@ -7,7 +7,7 @@ var (
ErrEmailExists = errors.New("该邮箱已注册") ErrEmailExists = errors.New("该邮箱已注册")
ErrUsernameTaken = errors.New("该用户名已被占用") ErrUsernameTaken = errors.New("该用户名已被占用")
ErrUsernameInvalid = errors.New("用户名格式无效支持中英文、数字、下划线、连字符1-16个字符") ErrUsernameInvalid = errors.New("用户名格式无效支持中英文、数字、下划线、连字符1-16个字符")
ErrBioTooLong = errors.New("个性签名不能超过 128 个字符") ErrBioTooLong = errors.New("个性签名不能超过 128 个字符")
ErrInvalidCred = errors.New("邮箱或密码错误") ErrInvalidCred = errors.New("邮箱或密码错误")
ErrUserNotFound = errors.New("用户不存在") ErrUserNotFound = errors.New("用户不存在")
ErrUserBanned = errors.New("该账号已被封禁") ErrUserBanned = errors.New("该账号已被封禁")
@ -24,10 +24,10 @@ var (
ErrPermissionDenied = errors.New("权限不足") ErrPermissionDenied = errors.New("权限不足")
// 审核相关 // 审核相关
ErrAuditNotFound = errors.New("审核记录不存在") ErrAuditNotFound = errors.New("审核记录不存在")
ErrAuditNotPending = errors.New("该审核记录已处理") ErrAuditNotPending = errors.New("该审核记录已处理")
ErrAuditDisabled = errors.New("审核功能未开启") ErrAuditDisabled = errors.New("审核功能未开启")
ErrAuditTypeOff = errors.New("该类型审核未开启") ErrAuditTypeOff = errors.New("该类型审核未开启")
// 密码 & 注销相关 // 密码 & 注销相关
ErrIncorrectPassword = errors.New("当前密码错误") ErrIncorrectPassword = errors.New("当前密码错误")
@ -38,24 +38,24 @@ var (
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录") ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
// 帖子相关 // 帖子相关
ErrPostNotFound = errors.New("帖子不存在") ErrPostNotFound = errors.New("帖子不存在")
ErrPostCannotEdit = errors.New("当前状态不允许编辑") ErrPostCannotEdit = errors.New("当前状态不允许编辑")
ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟") ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟")
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核") ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
ErrPostCannotApprove = errors.New("仅待审核帖子可通过") ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回") ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
ErrPostCannotLock = errors.New("待审核帖子不允许锁定") ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁") ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
// 域能相关 // 域能相关
ErrInvalidEnergyAmount = errors.New("无效的赋能数量") ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能") ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取") ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限") ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
ErrCreatePostNoExp = errors.New("经验不足Lv1 解锁投稿") ErrCreatePostNoExp = errors.New("经验不足Lv1 解锁投稿")
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作") ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
// 评论相关 // 评论相关
ErrCommentNotFound = errors.New("评论不存在") ErrCommentNotFound = errors.New("评论不存在")
ErrCommentEmpty = errors.New("评论内容不能为空") ErrCommentEmpty = errors.New("评论内容不能为空")
) )

View File

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

View File

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

View File

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

View File

@ -20,8 +20,8 @@ func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s配合 FallbackStore 快速降级 DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s配合 FallbackStore 快速降级
ReadTimeout: 1 * time.Second, ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second, WriteTimeout: 1 * time.Second,
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间 MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS
}) })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -29,7 +29,7 @@ func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
if err := client.Ping(ctx).Err(); err != nil { if err := client.Ping(ctx).Err(); err != nil {
log.Printf("[Redis] 连接失败: %v将降级为内存存储", err) log.Printf("[Redis] 连接失败: %v将降级为内存存储", err)
return client, fmt.Errorf("Redis 连接失败: %w", err) return client, fmt.Errorf("redis 连接失败: %w", err)
} }
return client, nil return client, nil

View File

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

View File

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

View File

@ -1,3 +1,4 @@
// Package config 管理应用配置的加载、解析和运行时访问。
package config package config
import ( import (
@ -22,10 +23,10 @@ type Config struct {
// AuditConfig 审核系统配置config.yaml 提供默认值,站点设置可运行时覆盖) // AuditConfig 审核系统配置config.yaml 提供默认值,站点设置可运行时覆盖)
type AuditConfig struct { type AuditConfig struct {
Enabled bool `mapstructure:"enabled"` Enabled bool `mapstructure:"enabled"`
UsernameAudit bool `mapstructure:"username_audit"` UsernameAudit bool `mapstructure:"username_audit"`
AvatarAudit bool `mapstructure:"avatar_audit"` AvatarAudit bool `mapstructure:"avatar_audit"`
BioAudit bool `mapstructure:"bio_audit"` BioAudit bool `mapstructure:"bio_audit"`
} }
type ServerConfig struct { type ServerConfig struct {
@ -65,9 +66,9 @@ type BcryptConfig struct {
// RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml // RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml
type RolesConfig struct { type RolesConfig struct {
Levels map[string]int `mapstructure:"levels"` Levels map[string]int `mapstructure:"levels"`
Names map[string]string `mapstructure:"names"` Names map[string]string `mapstructure:"names"`
Permissions RolesPermissions `mapstructure:"permissions"` Permissions RolesPermissions `mapstructure:"permissions"`
} }
// RolesPermissions 角色间操作权限 // RolesPermissions 角色间操作权限
@ -75,7 +76,7 @@ type RolesPermissions struct {
OperableRoles map[string][]string `mapstructure:"operable_roles"` OperableRoles map[string][]string `mapstructure:"operable_roles"`
} }
// 全局配置实例(初始化后只读) // App 全局配置实例(初始化后只读)
var App *Config var App *Config
// Load 加载配置config.yaml → .env 覆盖 // Load 加载配置config.yaml → .env 覆盖
@ -111,11 +112,11 @@ func Load(configPath string) *Config {
// loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时) // loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时)
func loadEnvFile(path string) { func loadEnvFile(path string) {
f, err := os.Open(path) f, err := os.Open(path) //nolint:gosec // path 为内部 .env 文件路径
if err != nil { if err != nil {
return // .env 不存在,跳过 return // .env 不存在,跳过
} }
defer f.Close() defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
@ -130,7 +131,7 @@ func loadEnvFile(path string) {
key := strings.TrimSpace(parts[0]) key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1]) val := strings.TrimSpace(parts[1])
if os.Getenv(key) == "" { if os.Getenv(key) == "" {
os.Setenv(key, val) _ = os.Setenv(key, val)
} }
} }
} }

View File

@ -112,19 +112,19 @@ func (s *SiteSettings) IsAuditTypeEnabled(auditType string, defaultVal bool) boo
// SiteInfoDefaults 站点展示信息的默认值 // SiteInfoDefaults 站点展示信息的默认值
type SiteInfoDefaults struct { type SiteInfoDefaults struct {
SiteName string // 站点名称,默认 "MetaLab" SiteName string // 站点名称,默认 "MetaLab"
SiteTagline string // 站点标语,默认 "下一代开发者社区" SiteTagline string // 站点标语,默认 "下一代开发者社区"
ContactEmail string // 联系/申诉邮箱,默认 "metazone@foxmail.com" ContactEmail string // 联系/申诉邮箱,默认 "metazone@foxmail.com"
Framework string // 附加标识,默认 "Framework: MetaGenesis" Framework string // 附加标识,默认 "Framework: MetaGenesis"
CopyrightStart string // 版权起始年份,默认 "2024" CopyrightStart string // 版权起始年份,默认 "2024"
OperatorName string // 运营主体 OperatorName string // 运营主体
ICPNumber string // ICP 备案HTML含链接 ICPNumber string // ICP 备案HTML含链接
ICPLicense string // ICP 经营许可证HTML ICPLicense string // ICP 经营许可证HTML
PoliceNumber string // 公安备案HTML PoliceNumber string // 公安备案HTML
WenWangWen string // 文网文HTML WenWangWen string // 文网文HTML
AlgorithmFiling string // 算法备案HTML AlgorithmFiling string // 算法备案HTML
PrivacyURL string // 隐私政策HTML PrivacyURL string // 隐私政策HTML
TermsURL string // 服务条款HTML TermsURL string // 服务条款HTML
} }
// SiteInfo 获取站点展示信息(优先 DB 设置fallback 默认值) // SiteInfo 获取站点展示信息(优先 DB 设置fallback 默认值)

View File

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

View File

@ -1,6 +1,7 @@
package admin package admin
import ( import (
"errors"
"net/http" "net/http"
"strconv" "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 := 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, "公户余额不足,无法执行操作") common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作")
return return
} }
@ -99,7 +100,7 @@ func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) {
return return
} }
common.Ok(c, gin.H{ common.Ok(c, gin.H{
"balance": balance, "balance": balance,
"balance_display": float64(balance) / 10, "balance_display": float64(balance) / 10,
}) })
} }

View File

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

View File

@ -24,7 +24,7 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi
// SiteSettingsPage 站点设置管理页面 // SiteSettingsPage 站点设置管理页面
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) { func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{ c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "站点设置", "Title": "站点设置",
"ExtraCSS": "/admin/static/css/site-settings.css", "ExtraCSS": "/admin/static/css/site-settings.css",
"ExtraJS": "/admin/static/js/site-settings.js", "ExtraJS": "/admin/static/js/site-settings.js",
})) }))

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
@ -39,18 +40,17 @@ func (ec *EnergyController) Energize(c *gin.Context) {
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount) expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
if err != nil { if err != nil {
switch err { if errors.Is(err, common.ErrPostNotFound) {
case common.ErrPostNotFound:
common.Error(c, http.StatusNotFound, "文章不存在") common.Error(c, http.StatusNotFound, "文章不存在")
case common.ErrCannotEnergizeSelf: } else if errors.Is(err, common.ErrCannotEnergizeSelf) {
common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能") common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能")
case common.ErrInsufficientEnergy: } else if errors.Is(err, common.ErrInsufficientEnergy) {
common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取") common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取")
case common.ErrEnergizeLimitReached: } else if errors.Is(err, common.ErrEnergizeLimitReached) {
common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限") common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限")
case common.ErrInvalidEnergyAmount: } else if errors.Is(err, common.ErrInvalidEnergyAmount) {
common.Error(c, http.StatusBadRequest, "无效的赋能数量") common.Error(c, http.StatusBadRequest, "无效的赋能数量")
default: } else {
common.Error(c, http.StatusInternalServerError, "赋能失败") common.Error(c, http.StatusInternalServerError, "赋能失败")
} }
return return
@ -136,13 +136,13 @@ func (ec *EnergyController) GetEnergyLogs(c *gin.Context) {
// EnergyLogView 前端展示用的域能流水视图 // EnergyLogView 前端展示用的域能流水视图
type EnergyLogView struct { type EnergyLogView struct {
ID uint `json:"id"` ID uint `json:"id"`
Amount int `json:"amount"` Amount int `json:"amount"`
AmountDisplay float64 `json:"amount_display"` AmountDisplay float64 `json:"amount_display"`
Type string `json:"type"` Type string `json:"type"`
TypeName string `json:"type_name"` TypeName string `json:"type_name"`
Description string `json:"description"` Description string `json:"description"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView { func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView {

View File

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

View File

@ -148,7 +148,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
"ExtraCSS": "/static/css/follow.css", "ExtraCSS": "/static/css/follow.css",
"Result": result, "Result": result,
"TargetUID": targetUID, "TargetUID": targetUID,
"Page": result.Page, "Page": result.Page,
"TotalPages": result.TotalPages, "TotalPages": result.TotalPages,
"HasPrev": result.Page > 1, "HasPrev": result.Page > 1,
"HasNext": result.Page < result.TotalPages, "HasNext": result.Page < result.TotalPages,

View File

@ -1,3 +1,4 @@
// Package controller 定义 HTTP 请求处理层,通过 ISP 接口隔离依赖 Service。
package controller package controller
import ( import (
@ -27,13 +28,10 @@ type rateLimiter interface {
ClearIP(ipKey string) ClearIP(ipKey string)
} }
// postUseCase PostController 对 PostService 的最小依赖ISP8 个方法) // postUseCase PostController 对 PostService 的最小依赖ISP6 个方法)
type postUseCase interface { type postUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error) GetByID(id uint) (*model.Post, error)
List(keyword string, page, pageSize int) ([]model.Post, int64, 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 SubmitForAudit(postID uint) error
RecordRead(userID, postID uint) RecordRead(userID, postID uint)
RecordGuestRead(visitorID string, postID uint) RecordGuestRead(visitorID string, postID uint)
@ -100,14 +98,6 @@ type energyRenameHandler interface {
DeductOnRename(userID uint) error 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 个方法) // commentUseCase CommentController 对 CommentService 的最小依赖ISP6 个方法)
type commentUseCase interface { type commentUseCase interface {
CreateRoot(userID, postID uint, body string) (*model.Comment, error) 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, "请选择文件") common.Error(c, http.StatusBadRequest, "请选择文件")
return return
} }
defer file.Close() defer func() { _ = file.Close() }()
// 检查扩展名 // 检查扩展名
ext := strings.ToLower(filepath.Ext(header.Filename)) ext := strings.ToLower(filepath.Ext(header.Filename))
@ -59,7 +59,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
// 存储路径 // 存储路径
storageDir := "storage/uploads/posts" 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, "存储初始化失败") common.Error(c, http.StatusInternalServerError, "存储初始化失败")
return return
} }
@ -83,7 +83,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
if webpData != nil && len(webpData) < len(data) { if webpData != nil && len(webpData) < len(data) {
filename := fmt.Sprintf("%d_%d.webp", uid, ts) filename := fmt.Sprintf("%d_%d.webp", uid, ts)
savePath = filepath.Join(storageDir, filename) 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 url = "/uploads/posts/" + filename
} }
} }
@ -127,7 +127,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext) filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
savePath = filepath.Join(storageDir, filename) 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, "保存图片失败") common.Error(c, http.StatusInternalServerError, "保存图片失败")
return return
} }

View File

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

View File

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

View File

@ -11,9 +11,9 @@ import (
// SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置) // SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置)
type SpaceController struct { type SpaceController struct {
spaceService spaceUseCase spaceService spaceUseCase
followSvc followUseCase followSvc followUseCase
favoriteSvc favoriteUseCaseForSpace favoriteSvc favoriteUseCaseForSpace
} }
// NewSpaceController 构造函数 // NewSpaceController 构造函数
@ -73,25 +73,29 @@ func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
// 分页参数 // 分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) 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")) 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)) postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid))
totalLikes, totalFavorites, totalReads, _ := ctrl.spaceService.GetUserStats(uint(uid)) totalLikes, totalFavorites, totalReads, _ := ctrl.spaceService.GetUserStats(uint(uid))
data := gin.H{ data := gin.H{
"Title": spaceUser.Username + " 的空间", "Title": spaceUser.Username + " 的空间",
"SpaceUser": spaceUser, "SpaceUser": spaceUser,
"IsOwnSpace": isOwnSpace, "IsOwnSpace": isOwnSpace,
"ActiveTab": tab, "ActiveTab": tab,
"CurrentUID": currentUID, "CurrentUID": currentUID,
"ExtraCSS": "/static/css/space.css", "ExtraCSS": "/static/css/space.css",
"PostCount": postCount, "PostCount": postCount,
"TotalLikes": totalLikes, "TotalLikes": totalLikes,
"TotalFavorites": totalFavorites, "TotalFavorites": totalFavorites,
"TotalReads": totalReads, "TotalReads": totalReads,
} }
// ---- 根据 Tab 加载对应数据 ---- // ---- 根据 Tab 加载对应数据 ----

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"strconv" "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) 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 != nil {
if err == common.ErrScheduledTooSoon { if errors.Is(err, common.ErrScheduledTooSoon) {
common.Error(c, http.StatusBadRequest, err.Error()) common.Error(c, http.StatusBadRequest, err.Error())
return return
} }

View File

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

View File

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

View File

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

View File

@ -4,14 +4,14 @@ import "time"
// Category 文章分类(两级树形结构) // Category 文章分类(两级树形结构)
type Category struct { type Category struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"type:varchar(50);not null" json:"name"` Name string `gorm:"type:varchar(50);not null" json:"name"`
Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"` Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"`
Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"` Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"`
ParentID *uint `gorm:"index" json:"parent_id,omitempty"` ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
Sort int `gorm:"default:0" json:"sort"` Sort int `gorm:"default:0" json:"sort"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
// 非 DB 字段(树形填充) // 非 DB 字段(树形填充)
Children []Category `gorm:"-" json:"children,omitempty"` Children []Category `gorm:"-" json:"children,omitempty"`

View File

@ -4,23 +4,23 @@ import "time"
// Comment 评论模型 // Comment 评论模型
type Comment struct { type Comment struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"` PostID uint `gorm:"index;not null" json:"post_id"`
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID顶级评论指向自身 RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID顶级评论指向自身
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论IDNULL=顶级评论 ParentID *uint `gorm:"index" json:"parent_id"` // 父评论IDNULL=顶级评论
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"` IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
LikesCount int `gorm:"default:0" json:"likes_count"` LikesCount int `gorm:"default:0" json:"likes_count"`
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见 DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
UserID uint `gorm:"index;not null" json:"user_id"` UserID uint `gorm:"index;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
// 非数据库字段(联表查询填充) // 非数据库字段(联表查询填充)
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"` AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,omitempty"` AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,omitempty"`
AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"` AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
// ReplyToName 被回复者当前显示名JOIN users 获取,动态解析,改名后自动更新) // ReplyToName 被回复者当前显示名JOIN users 获取,动态解析,改名后自动更新)
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"` ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
// RepliesCount 回复数非DB字段查询填充 // RepliesCount 回复数非DB字段查询填充

View File

@ -4,11 +4,11 @@ import "time"
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知) // DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
type DailyLikeSummary struct { type DailyLikeSummary struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者 AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DDAsia/Shanghai Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DDAsia/Shanghai
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔) LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知 Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }

View File

@ -4,20 +4,20 @@ import "time"
// 域能流水类型常量 // 域能流水类型常量
const ( const (
EnergyTypeSignIn = "sign_in" EnergyTypeSignIn = "sign_in"
EnergyTypeRename = "rename" EnergyTypeRename = "rename"
EnergyTypeRenameRefund = "rename_refund" EnergyTypeRenameRefund = "rename_refund"
EnergyTypeEnergize = "energize" EnergyTypeEnergize = "energize"
EnergyTypeEnergized = "energized" EnergyTypeEnergized = "energized"
EnergyTypeDeletePost = "delete_post" EnergyTypeDeletePost = "delete_post"
EnergyTypeAdminAdjust = "admin_adjust" EnergyTypeAdminAdjust = "admin_adjust"
) )
// EnergyLog 域能流水记录 // EnergyLog 域能流水记录
type EnergyLog struct { type EnergyLog struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"` UserID uint `gorm:"index;not null" json:"user_id"`
Amount int `gorm:"not null" json:"amount"` // 变化量乘10正为增加负为扣减 Amount int `gorm:"not null" json:"amount"` // 变化量乘10正为增加负为扣减
Type string `gorm:"type:varchar(30);index;not null" json:"type"` // sign_in/rename/rename_refund/energize/energized/delete_post/admin_adjust Type string `gorm:"type:varchar(30);index;not null" json:"type"` // sign_in/rename/rename_refund/energize/energized/delete_post/admin_adjust
RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"` RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"`
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"` RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
@ -28,11 +28,11 @@ type EnergyLog struct {
// EnergyLogDisplayNames 流水类型 → 中文名 // EnergyLogDisplayNames 流水类型 → 中文名
var EnergyLogDisplayNames = map[string]string{ var EnergyLogDisplayNames = map[string]string{
EnergyTypeSignIn: "每日签到", EnergyTypeSignIn: "每日签到",
EnergyTypeRename: "改名消耗", EnergyTypeRename: "改名消耗",
EnergyTypeRenameRefund: "改名退款", EnergyTypeRenameRefund: "改名退款",
EnergyTypeEnergize: "赋能消耗", EnergyTypeEnergize: "赋能消耗",
EnergyTypeEnergized: "被赋能", EnergyTypeEnergized: "被赋能",
EnergyTypeDeletePost: "删稿消耗", EnergyTypeDeletePost: "删稿消耗",
EnergyTypeAdminAdjust: "后台调整", EnergyTypeAdminAdjust: "后台调整",
} }

View File

@ -4,18 +4,18 @@ import "time"
// 公户流水类型常量 // 公户流水类型常量
const ( const (
FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户) FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户)
FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户) FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户)
FundTypeRenameRefund = "rename_refund" // 改名退款(出公户) FundTypeRenameRefund = "rename_refund" // 改名退款(出公户)
FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户) FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户)
FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束) FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束)
FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束) FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束)
) )
// FundLog 公户流水记录 // FundLog 公户流水记录
type FundLog struct { type FundLog struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Amount int `gorm:"not null" json:"amount"` // +入公户 / 出公户×10 Amount int `gorm:"not null" json:"amount"` // +入公户 / 出公户×10
Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型 Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型
RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"` RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"`
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"` RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`

View File

@ -2,18 +2,18 @@ package model
// 消息/通知类型常量 // 消息/通知类型常量
const ( const (
NotifyAuditApproved = "audit_approved" NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected" NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved" NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_rejected" NotifyPostRejected = "post_rejected"
NotifyPostLocked = "post_locked" NotifyPostLocked = "post_locked"
NotifyPostUnlocked = "post_unlocked" NotifyPostUnlocked = "post_unlocked"
NotifyLevelUp = "level_up" NotifyLevelUp = "level_up"
NotifyComment = "comment" NotifyComment = "comment"
NotifyCommentReply = "comment_reply" NotifyCommentReply = "comment_reply"
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知 NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
NotifyFollow = "follow" // 关注通知 NotifyFollow = "follow" // 关注通知
NotifyCommentMention = "comment_mention" // 评论@提及通知 NotifyCommentMention = "comment_mention" // 评论@提及通知
) )
// Notification 消息/通知模型 // Notification 消息/通知模型

View File

@ -8,28 +8,28 @@ import (
// Post 帖子/文章模型 // Post 帖子/文章模型
type Post struct { type Post struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"` Title string `gorm:"type:varchar(200);not null" json:"title"`
Body string `gorm:"type:text" json:"body"` // Markdown content Body string `gorm:"type:text" json:"body"` // Markdown content
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
UserID uint `gorm:"index;index:idx_posts_user_status,priority:1;not null" json:"user_id"` UserID uint `gorm:"index;index:idx_posts_user_status,priority:1;not null" json:"user_id"`
Status string `gorm:"type:varchar(20);index;index:idx_posts_user_status,priority:2;default:draft;not null" json:"status"` Status string `gorm:"type:varchar(20);index;index:idx_posts_user_status,priority:2;default:draft;not null" json:"status"`
IsLocked bool `gorm:"default:false" json:"is_locked"` IsLocked bool `gorm:"default:false" json:"is_locked"`
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"` LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"` PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"` PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"` RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
// 文章属性 // 文章属性
CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID
Visibility string `gorm:"type:varchar(10);default:public" json:"visibility"` // public / private Visibility string `gorm:"type:varchar(10);default:public" json:"visibility"` // public / private
PostType string `gorm:"type:varchar(10);default:original" json:"post_type"` // original / reprint PostType string `gorm:"type:varchar(10);default:original" json:"post_type"` // original / reprint
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源 ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源
Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明 Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载 ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间 PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间 ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间
AllowComment bool `gorm:"default:true" json:"allow_comment"` AllowComment bool `gorm:"default:true" json:"allow_comment"`
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器) CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
@ -37,7 +37,7 @@ type Post struct {
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器) LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见) DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器) FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重) ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`

View File

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

View File

@ -82,11 +82,11 @@ type ShortcodeResult struct {
// ShortcodeCard 渲染一张卡片的通用数据 // ShortcodeCard 渲染一张卡片的通用数据
// 前端根据 Type 决定具体 UI 组件,字段按需填充 // 前端根据 Type 决定具体 UI 组件,字段按需填充
type ShortcodeCard struct { type ShortcodeCard struct {
Type ShortcodeType `json:"type"` Type ShortcodeType `json:"type"`
ID string `json:"id"` // 业务 ID活动ID / 游戏slug ID string `json:"id"` // 业务 ID活动ID / 游戏slug
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Cover string `json:"cover,omitempty"` Cover string `json:"cover,omitempty"`
Desc string `json:"desc,omitempty"` Desc string `json:"desc,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Extra string `json:"extra,omitempty"` // 扩展字段时间、标签等JSON string Extra string `json:"extra,omitempty"` // 扩展字段时间、标签等JSON string
} }

View File

@ -20,9 +20,9 @@ type User struct {
Energy int `gorm:"default:0" json:"energy"` // 域能余额可为负数乘10存储 Energy int `gorm:"default:0" json:"energy"` // 域能余额可为负数乘10存储
// 关注系统 // 关注系统
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器) FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器) FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开 FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
FollowerListPublic bool `gorm:"default:true" json:"follower_list_public"` // 粉丝列表是否公开 FollowerListPublic bool `gorm:"default:true" json:"follower_list_public"` // 粉丝列表是否公开
// 通知偏好 (JSON string) // 通知偏好 (JSON string)
@ -52,9 +52,11 @@ const (
) )
// --- 以下由 InitRoles 从配置初始化,无硬编码默认值 --- // --- 以下由 InitRoles 从配置初始化,无硬编码默认值 ---
var roleLevel map[string]int var (
var operableRoles map[string][]string roleLevel map[string]int
var RoleDisplayNames map[string]string operableRoles map[string][]string
RoleDisplayNames map[string]string
)
// InitRoles 从配置初始化角色系统(唯一数据源) // InitRoles 从配置初始化角色系统(唯一数据源)
// 必须在服务启动前调用,不提供默认值 // 必须在服务启动前调用,不提供默认值

View File

@ -4,9 +4,9 @@ import "time"
// UserFollow 关注关系 // UserFollow 关注关系
type UserFollow struct { type UserFollow struct {
FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"` FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"`
FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"` FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// 联表查询填充(非数据库字段) // 联表查询填充(非数据库字段)
FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"` FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"`

View File

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

View File

@ -73,7 +73,8 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel, limit int, fo
Username string Username string
Avatar string Avatar string
Exp int Exp int
}, error) { }, error,
) {
type result struct { type result struct {
UID uint UID uint
Username string 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 UpdateColumn("comments_count", gorm.Expr("GREATEST(comments_count - 1, 0)")).Error
} }
// CountRepliesByRootByStatus 统计某条顶级评论的回复数(可选是否统计已删除) // CountRepliesByRoot 统计某条顶级评论的回复数(可选是否统计已删除)
func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, error) { func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, error) {
var count int64 var count int64
q := r.db.Model(&model.Comment{}).Where("root_id = ? AND parent_id IS NOT NULL", rootID) q := r.db.Model(&model.Comment{}).Where("root_id = ? AND parent_id IS NOT NULL", rootID)

View File

@ -1,6 +1,8 @@
package repository package repository
import ( import (
"errors"
"metazone.cc/mce/internal/model" "metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service" "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 { func (r *ReactionRepo) UpsertDailyLikeSummary(authorUID uint, date string, likerUID string) error {
var existing model.DailyLikeSummary var existing model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND date = ?", authorUID, date).First(&existing).Error 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{ return r.db.Create(&model.DailyLikeSummary{
AuthorUID: authorUID, AuthorUID: authorUID,
Date: date, Date: date,

View File

@ -1,6 +1,8 @@
package repository package repository
import ( import (
"errors"
"metazone.cc/mce/internal/model" "metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service" "metazone.cc/mce/internal/service"
@ -29,7 +31,7 @@ func (r *TagRepo) FindOrCreate(name, slug string) (*model.Tag, error) {
if err == nil { if err == nil {
return &tag, nil return &tag, nil
} }
if err != gorm.ErrRecordNotFound { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err return nil, err
} }
tag = model.Tag{Name: name, Slug: slug} 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 err := r.buildSearchQuery(keyword, role, status).Count(&count).Error
return count, err return count, err
} }

View File

@ -18,33 +18,33 @@ import (
) )
type dependencies struct { type dependencies struct {
authCtrl *controller.AuthController authCtrl *controller.AuthController
authMdw *middleware.AuthMiddleware authMdw *middleware.AuthMiddleware
rateLimiter *middleware.RateLimiter rateLimiter *middleware.RateLimiter
maintenanceMdw *middleware.MaintenanceMiddleware maintenanceMdw *middleware.MaintenanceMiddleware
settingsCtrl *controller.SettingsController settingsCtrl *controller.SettingsController
messageCtrl *controller.MessageController messageCtrl *controller.MessageController
postCtrl *controller.PostController postCtrl *controller.PostController
postService *service.PostService postService *service.PostService
spaceCtrl *controller.SpaceController spaceCtrl *controller.SpaceController
studioCtrl *controller.StudioController studioCtrl *controller.StudioController
adminPostCtrl *adminCtrl.AdminPostController adminPostCtrl *adminCtrl.AdminPostController
adminCtrl *adminCtrl.AdminController adminCtrl *adminCtrl.AdminController
auditCtrl *adminCtrl.AuditController auditCtrl *adminCtrl.AuditController
siteSettingCtrl *adminCtrl.SiteSettingController siteSettingCtrl *adminCtrl.SiteSettingController
levelCtrl *controller.LevelController levelCtrl *controller.LevelController
energyCtrl *controller.EnergyController energyCtrl *controller.EnergyController
adminEnergyCtrl *adminCtrl.AdminEnergyController adminEnergyCtrl *adminCtrl.AdminEnergyController
energyService *service.EnergyService energyService *service.EnergyService
commentCtrl *controller.CommentController commentCtrl *controller.CommentController
commentService *service.CommentService commentService *service.CommentService
adminCommentCtrl *adminCtrl.AdminCommentController adminCommentCtrl *adminCtrl.AdminCommentController
reactionCtrl *controller.ReactionController reactionCtrl *controller.ReactionController
followCtrl *controller.FollowController followCtrl *controller.FollowController
favoriteCtrl *controller.FavoriteController favoriteCtrl *controller.FavoriteController
favoriteService *service.FavoriteService favoriteService *service.FavoriteService
trendsService *service.TrendsService trendsService *service.TrendsService
homeCache *cache.HomePageCache homeCache *cache.HomePageCache
announcementService *service.AnnouncementService announcementService *service.AnnouncementService
announcementCtrl *adminCtrl.AnnouncementController announcementCtrl *adminCtrl.AnnouncementController
categoryCtrl *adminCtrl.CategoryController categoryCtrl *adminCtrl.CategoryController

View File

@ -131,25 +131,25 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
studioCtrl: studioCtrl, studioCtrl: studioCtrl,
adminPostCtrl: adminPostCtrl, adminPostCtrl: adminPostCtrl,
adminCtrl: adminController, auditCtrl: auditController, adminCtrl: adminController, auditCtrl: auditController,
siteSettingCtrl: siteSettingController, siteSettingCtrl: siteSettingController,
levelCtrl: levelCtrl, levelCtrl: levelCtrl,
energyCtrl: energyCtrl, energyCtrl: energyCtrl,
adminEnergyCtrl: adminEnergyCtrl, adminEnergyCtrl: adminEnergyCtrl,
energyService: energyService, energyService: energyService,
commentCtrl: commentCtrl, commentCtrl: commentCtrl,
commentService: commentService, commentService: commentService,
adminCommentCtrl: adminCommentCtrl, adminCommentCtrl: adminCommentCtrl,
reactionCtrl: reactionCtrl, reactionCtrl: reactionCtrl,
followCtrl: followCtrl, followCtrl: followCtrl,
favoriteCtrl: favoriteCtrl, favoriteCtrl: favoriteCtrl,
favoriteService: favoriteService, favoriteService: favoriteService,
trendsService: trendsService, trendsService: trendsService,
homeCache: homeCache, homeCache: homeCache,
announcementService: announcementService, announcementService: announcementService,
announcementCtrl: announcementCtrl, announcementCtrl: announcementCtrl,
categoryCtrl: categoryCtrl, categoryCtrl: categoryCtrl,
categoryService: categoryService, categoryService: categoryService,
tagCtrl: tagCtrl, tagCtrl: tagCtrl,
tagService: tagService, tagService: tagService,
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -56,14 +56,14 @@ type avatarFileCleaner interface {
// AuditService 审核业务逻辑 // AuditService 审核业务逻辑
type AuditService struct { type AuditService struct {
auditRepo auditRepo auditRepo auditRepo
userRepo userProfileStore userRepo userProfileStore
settings siteSettingsChecker settings siteSettingsChecker
defaultCfg config.AuditConfig defaultCfg config.AuditConfig
notifier auditNotifier notifier auditNotifier
levelSvc auditTaskCompleter levelSvc auditTaskCompleter
energyRefundSvc energyRenameRefunder energyRefundSvc energyRenameRefunder
avatarCleaner avatarFileCleaner avatarCleaner avatarFileCleaner
} }
// NewAuditService 构造函数 // NewAuditService 构造函数

View File

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

View File

@ -22,8 +22,8 @@ import (
// 头像处理常量 // 头像处理常量
const ( const (
avatarMaxFileSize = 5 * 1024 * 1024 // 5MB avatarMaxFileSize = 5 * 1024 * 1024 // 5MB
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升 avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
avatarMinHeight = 128 avatarMinHeight = 128
avatarMaxWidth = 3840 avatarMaxWidth = 3840
avatarMaxHeight = 3840 avatarMaxHeight = 3840
@ -97,7 +97,7 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
img, _, err = image.Decode(bytes.NewReader(data)) img, _, err = image.Decode(bytes.NewReader(data))
} }
if err != nil { if err != nil {
return "", fmt.Errorf("图片解码失败:%v", err) return "", fmt.Errorf("图片解码失败:%w", err)
} }
bounds := img.Bounds() bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy() w, h := bounds.Dx(), bounds.Dy()
@ -129,11 +129,11 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
// 6. 编码为 WebP目标 ≤100KB // 6. 编码为 WebP目标 ≤100KB
webpData, err := encodeWebP(resized, avatarTargetKB*1024) webpData, err := encodeWebP(resized, avatarTargetKB*1024)
if err != nil { if err != nil {
return "", fmt.Errorf("图片编码失败:%v", err) return "", fmt.Errorf("图片编码失败:%w", err)
} }
// 7. 确保存储目录存在 // 7. 确保存储目录存在
if err := os.MkdirAll(s.storageDir, 0755); err != nil { if err := os.MkdirAll(s.storageDir, 0o755); err != nil { //nolint:gosec // 头像存储目录标准权限
return "", fmt.Errorf("创建存储目录失败") 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) filename := fmt.Sprintf("%d_%d.webp", userID, ts)
tmpPath := filepath.Join(s.storageDir, filename+".tmp") tmpPath := filepath.Join(s.storageDir, filename+".tmp")
finalPath := filepath.Join(s.storageDir, filename) 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("写入临时文件失败") return "", fmt.Errorf("写入临时文件失败")
} }
if err := os.Rename(tmpPath, finalPath); err != nil { if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath) _ = os.Remove(tmpPath)
return "", fmt.Errorf("保存文件失败") return "", fmt.Errorf("保存文件失败")
} }
@ -163,7 +163,7 @@ func (s *AvatarService) CleanOldAvatars(userID uint) {
} }
for _, e := range entries { for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ".webp") { 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 return buf.Bytes(), nil
} }

View File

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

View File

@ -30,22 +30,22 @@ func (s *EnergyService) SetFundRepo(repo FundStore) {
// EnergyInfo 域能信息(含每日赋能经验状态) // EnergyInfo 域能信息(含每日赋能经验状态)
type EnergyInfo struct { type EnergyInfo struct {
Energy int // 域能余额乘10存储 Energy int // 域能余额乘10存储
DailyEnergizeExp int // 当日已通过赋能获得的经验值 DailyEnergizeExp int // 当日已通过赋能获得的经验值
DailyExpCap int // 每日赋能经验上限 DailyExpCap int // 每日赋能经验上限
DailyExpCapReached bool // 是否已达上限 DailyExpCapReached bool // 是否已达上限
PostEnergizeTotal int // 当前文章已赋能量DB乘10仅在传 postID 时返回) PostEnergizeTotal int // 当前文章已赋能量DB乘10仅在传 postID 时返回)
} }
// 域能相关常量乘10存储DB值 // 域能相关常量乘10存储DB值
const ( const (
EnergyCheckIn = 10 // +1.0 域能 EnergyCheckIn = 10 // +1.0 域能
EnergyRename = -60 // -6.0 域能 EnergyRename = -60 // -6.0 域能
EnergyRenameRefund = 60 // +6.0 域能 EnergyRenameRefund = 60 // +6.0 域能
EnergyDeletePost = -20 // -2.0 域能 EnergyDeletePost = -20 // -2.0 域能
EnergyEnergize1 = 10 // 轻赋 1 域能 EnergyEnergize1 = 10 // 轻赋 1 域能
EnergyEnergize2 = 20 // 重赋 2 域能 EnergyEnergize2 = 20 // 重赋 2 域能
EnergizeExpPerEnergy = 10 // 每 1 域能 = 10 经验 EnergizeExpPerEnergy = 10 // 每 1 域能 = 10 经验
DailyEnergizeExpCap = 50 // 每日赋能经验上限 DailyEnergizeExpCap = 50 // 每日赋能经验上限
MaxEnergizePerPost = 20 // 单用户单文章最多赋能 2 域能×10 MaxEnergizePerPost = 20 // 单用户单文章最多赋能 2 域能×10
) )

View File

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

View File

@ -51,8 +51,8 @@ type FolderItemsResult struct {
// FavoriteStatus 某文章的收藏状态 // FavoriteStatus 某文章的收藏状态
type FavoriteStatus struct { type FavoriteStatus struct {
Favorited bool `json:"favorited"` Favorited bool `json:"favorited"`
FolderID *uint `json:"folder_id,omitempty"` FolderID *uint `json:"folder_id,omitempty"`
FolderName *string `json:"folder_name,omitempty"` FolderName *string `json:"folder_name,omitempty"`
} }

View File

@ -9,10 +9,10 @@ import (
// LevelService 积分与等级业务逻辑 // LevelService 积分与等级业务逻辑
type LevelService struct { type LevelService struct {
userRepo userLevelStore userRepo userLevelStore
checkRepo checkInStore checkRepo checkInStore
taskRepo taskStore taskRepo taskStore
notifSvc levelNotifier notifSvc levelNotifier
energyCheckInSvc energyCheckIner energyCheckInSvc energyCheckIner
} }

View File

@ -15,11 +15,11 @@ import (
// PostService 帖子业务逻辑 // PostService 帖子业务逻辑
type PostService struct { type PostService struct {
repo postStore repo postStore
ss *config.SiteSettings ss *config.SiteSettings
notifier postNotifier notifier postNotifier
userRepo userExpReader userRepo userExpReader
onHomePageChanged func() onHomePageChanged func()
} }
// NewPostService 构造函数 // NewPostService 构造函数
@ -146,12 +146,12 @@ func (s *PostService) ListByUser(userID uint, status string, page, pageSize int)
// StudioOverview 创作中心数据概览 // StudioOverview 创作中心数据概览
type StudioOverview struct { type StudioOverview struct {
TotalPosts int64 `json:"total_posts"` TotalPosts int64 `json:"total_posts"`
DraftCount int64 `json:"draft_count"` DraftCount int64 `json:"draft_count"`
PendingCount int64 `json:"pending_count"` PendingCount int64 `json:"pending_count"`
ApprovedCount int64 `json:"approved_count"` ApprovedCount int64 `json:"approved_count"`
RejectedCount int64 `json:"rejected_count"` RejectedCount int64 `json:"rejected_count"`
TotalViews int64 `json:"total_views"` TotalViews int64 `json:"total_views"`
} }
// GetOverview 获取创作中心数据概览(单次 GROUP BY 查询) // GetOverview 获取创作中心数据概览(单次 GROUP BY 查询)

View File

@ -11,8 +11,8 @@ import (
type ReactionType string type ReactionType string
const ( const (
ReactionNone ReactionType = "none" ReactionNone ReactionType = "none"
ReactionLiked ReactionType = "liked" ReactionLiked ReactionType = "liked"
ReactionDisliked ReactionType = "disliked" ReactionDisliked ReactionType = "disliked"
) )

View File

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

View File

@ -98,10 +98,11 @@ func parseShortcode(raw string) (model.Shortcode, bool) {
// renderPlaceholder 根据 shortcode 类型生成前端占位 HTML // renderPlaceholder 根据 shortcode 类型生成前端占位 HTML
// //
// 占位 div 结构约定: // 占位 div 结构约定:
// <div class="zone-card" data-zone-type="类型" data-zone-id="参数" //
// data-zone-extra="附加信息"> // <div class="zone-card" data-zone-type="类型" data-zone-id="参数"
// <span class="zone-card-loading">卡片加载中...</span> // data-zone-extra="附加信息">
// </div> // <span class="zone-card-loading">卡片加载中...</span>
// </div>
// //
// 前端 shortcode.js 根据 data-zone-* 属性决定如何渲染。 // 前端 shortcode.js 根据 data-zone-* 属性决定如何渲染。
// "加载中..." 文本会被 shortcode.js 在渲染时替换,仅作降级显示。 // "加载中..." 文本会被 shortcode.js 在渲染时替换,仅作降级显示。

View File

@ -168,14 +168,6 @@ func (fs *FallbackStore) ping() bool {
return fs.client.Ping(ctx).Err() == nil 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 操作失败时立即降级到内存)---- // ---- Store 接口实现Redis 操作失败时立即降级到内存)----
// 不依赖周期性健康检查,操作级故障即时响应 // 不依赖周期性健康检查,操作级故障即时响应

View File

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

View File

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

View File

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

View File

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

View File

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