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

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

@ -20,8 +20,8 @@ func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s配合 FallbackStore 快速降级
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS
})
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 {
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 (
@ -22,10 +23,10 @@ type Config struct {
// AuditConfig 审核系统配置config.yaml 提供默认值,站点设置可运行时覆盖)
type AuditConfig struct {
Enabled bool `mapstructure:"enabled"`
UsernameAudit bool `mapstructure:"username_audit"`
AvatarAudit bool `mapstructure:"avatar_audit"`
BioAudit bool `mapstructure:"bio_audit"`
Enabled bool `mapstructure:"enabled"`
UsernameAudit bool `mapstructure:"username_audit"`
AvatarAudit bool `mapstructure:"avatar_audit"`
BioAudit bool `mapstructure:"bio_audit"`
}
type ServerConfig struct {
@ -65,9 +66,9 @@ type BcryptConfig struct {
// RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml
type RolesConfig struct {
Levels map[string]int `mapstructure:"levels"`
Names map[string]string `mapstructure:"names"`
Permissions RolesPermissions `mapstructure:"permissions"`
Levels map[string]int `mapstructure:"levels"`
Names map[string]string `mapstructure:"names"`
Permissions RolesPermissions `mapstructure:"permissions"`
}
// RolesPermissions 角色间操作权限
@ -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

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

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
}
@ -99,7 +100,7 @@ func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) {
return
}
common.Ok(c, gin.H{
"balance": balance,
"balance": balance,
"balance_display": float64(balance) / 10,
})
}

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

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

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(
strings.ReplaceAll(string(guidelines), "MetaLab", siteInfo.SiteName),
"metazone@foxmail.com", siteInfo.ContactEmail,
))
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
@ -136,13 +136,13 @@ func (ec *EnergyController) GetEnergyLogs(c *gin.Context) {
// EnergyLogView 前端展示用的域能流水视图
type EnergyLogView struct {
ID uint `json:"id"`
Amount int `json:"amount"`
ID uint `json:"id"`
Amount int `json:"amount"`
AmountDisplay float64 `json:"amount_display"`
Type string `json:"type"`
TypeName string `json:"type_name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
Type string `json:"type"`
TypeName string `json:"type_name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
}
func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView {

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

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

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

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

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

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

View File

@ -4,23 +4,23 @@ import "time"
// Comment 评论模型
type Comment struct {
ID uint `gorm:"primarykey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID顶级评论指向自身
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论IDNULL=顶级评论
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
LikesCount int `gorm:"default:0" json:"likes_count"`
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
UserID uint `gorm:"index;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primarykey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID顶级评论指向自身
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论IDNULL=顶级评论
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
LikesCount int `gorm:"default:0" json:"likes_count"`
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
UserID uint `gorm:"index;not null" json:"user_id"`
CreatedAt time.Time `json:"created_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"`
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 string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
// RepliesCount 回复数非DB字段查询填充

View File

@ -4,11 +4,11 @@ import "time"
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
type DailyLikeSummary struct {
ID uint `gorm:"primarykey" json:"id"`
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
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primarykey" json:"id"`
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
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -4,20 +4,20 @@ import "time"
// 域能流水类型常量
const (
EnergyTypeSignIn = "sign_in"
EnergyTypeRename = "rename"
EnergyTypeSignIn = "sign_in"
EnergyTypeRename = "rename"
EnergyTypeRenameRefund = "rename_refund"
EnergyTypeEnergize = "energize"
EnergyTypeEnergized = "energized"
EnergyTypeDeletePost = "delete_post"
EnergyTypeAdminAdjust = "admin_adjust"
EnergyTypeEnergize = "energize"
EnergyTypeEnergized = "energized"
EnergyTypeDeletePost = "delete_post"
EnergyTypeAdminAdjust = "admin_adjust"
)
// EnergyLog 域能流水记录
type EnergyLog struct {
ID uint `gorm:"primarykey" json:"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
RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"`
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
@ -28,11 +28,11 @@ type EnergyLog struct {
// EnergyLogDisplayNames 流水类型 → 中文名
var EnergyLogDisplayNames = map[string]string{
EnergyTypeSignIn: "每日签到",
EnergyTypeRename: "改名消耗",
EnergyTypeSignIn: "每日签到",
EnergyTypeRename: "改名消耗",
EnergyTypeRenameRefund: "改名退款",
EnergyTypeEnergize: "赋能消耗",
EnergyTypeEnergized: "被赋能",
EnergyTypeDeletePost: "删稿消耗",
EnergyTypeAdminAdjust: "后台调整",
EnergyTypeEnergize: "赋能消耗",
EnergyTypeEnergized: "被赋能",
EnergyTypeDeletePost: "删稿消耗",
EnergyTypeAdminAdjust: "后台调整",
}

View File

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

View File

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

View File

@ -8,28 +8,28 @@ import (
// Post 帖子/文章模型
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"`
Body string `gorm:"type:text" json:"body"` // Markdown content
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"`
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"`
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"`
Body string `gorm:"type:text" json:"body"` // Markdown content
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"`
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"`
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
// 文章属性
CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID
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
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源
Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间
CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID
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
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源
Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间
AllowComment bool `gorm:"default:true" json:"allow_comment"`
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
@ -37,7 +37,7 @@ type Post struct {
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
DislikesCount int `gorm:"default:0" json:"dislikes_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:"-"`
CreatedAt time.Time `json:"created_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"`
ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"`
}

View File

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

View File

@ -20,9 +20,9 @@ type User struct {
Energy int `gorm:"default:0" json:"energy"` // 域能余额可为负数乘10存储
// 关注系统
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
FollowerListPublic bool `gorm:"default:true" json:"follower_list_public"` // 粉丝列表是否公开
// 通知偏好 (JSON string)
@ -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

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

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

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

View File

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

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

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

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

@ -22,8 +22,8 @@ import (
// 头像处理常量
const (
avatarMaxFileSize = 5 * 1024 * 1024 // 5MB
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
avatarMaxFileSize = 5 * 1024 * 1024 // 5MB
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
avatarMinHeight = 128
avatarMaxWidth = 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))
}
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

@ -30,22 +30,22 @@ func (s *EnergyService) SetFundRepo(repo FundStore) {
// EnergyInfo 域能信息(含每日赋能经验状态)
type EnergyInfo struct {
Energy int // 域能余额乘10存储
DailyEnergizeExp int // 当日已通过赋能获得的经验值
DailyExpCap int // 每日赋能经验上限
DailyExpCapReached bool // 是否已达上限
PostEnergizeTotal int // 当前文章已赋能量DB乘10仅在传 postID 时返回)
Energy int // 域能余额乘10存储
DailyEnergizeExp int // 当日已通过赋能获得的经验值
DailyExpCap int // 每日赋能经验上限
DailyExpCapReached bool // 是否已达上限
PostEnergizeTotal int // 当前文章已赋能量DB乘10仅在传 postID 时返回)
}
// 域能相关常量乘10存储DB值
const (
EnergyCheckIn = 10 // +1.0 域能
EnergyRename = -60 // -6.0 域能
EnergyRenameRefund = 60 // +6.0 域能
EnergyDeletePost = -20 // -2.0 域能
EnergyEnergize1 = 10 // 轻赋 1 域能
EnergyEnergize2 = 20 // 重赋 2 域能
EnergizeExpPerEnergy = 10 // 每 1 域能 = 10 经验
DailyEnergizeExpCap = 50 // 每日赋能经验上限
MaxEnergizePerPost = 20 // 单用户单文章最多赋能 2 域能×10
EnergyCheckIn = 10 // +1.0 域能
EnergyRename = -60 // -6.0 域能
EnergyRenameRefund = 60 // +6.0 域能
EnergyDeletePost = -20 // -2.0 域能
EnergyEnergize1 = 10 // 轻赋 1 域能
EnergyEnergize2 = 20 // 重赋 2 域能
EnergizeExpPerEnergy = 10 // 每 1 域能 = 10 经验
DailyEnergizeExpCap = 50 // 每日赋能经验上限
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 {
// 如果该文章已在其他收藏夹中,先移除
existing, err := txRepo.FindItemByUserAndPost(userID, postID)
if err == nil && existing.FolderID != folderID {

View File

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

View File

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

View File

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

View File

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

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,10 +98,11 @@ 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>
// </div>
//
// <div class="zone-card" data-zone-type="类型" data-zone-id="参数"
// data-zone-extra="附加信息">
// <span class="zone-card-loading">卡片加载中...</span>
// </div>
//
// 前端 shortcode.js 根据 data-zone-* 属性决定如何渲染。
// "加载中..." 文本会被 shortcode.js 在渲染时替换,仅作降级显示。

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 (
@ -21,13 +22,13 @@ type TemplateRoot struct {
// 模板名 = prefix + 相对于 root.Dir 的相对路径
func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
funcMap := template.FuncMap{
"add": func(a, b int) int { return a + b },
"subtract": 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 },
"formatTTL": formatTTL,
"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 分钟"