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:
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
internal/cache/home_cache.go
vendored
1
internal/cache/home_cache.go
vendored
@ -1,3 +1,4 @@
|
|||||||
|
// Package cache 提供首页等热点数据的内存缓存层。
|
||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package common 提供通用工具函数、错误哨兵和响应辅助方法。
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import "github.com/gin-gonic/gin"
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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("评论内容不能为空")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 响应
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 默认值)
|
||||||
|
|||||||
@ -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, "操作失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, "操作失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 的最小依赖(ISP:8 个方法)
|
// postUseCase PostController 对 PostService 的最小依赖(ISP:6 个方法)
|
||||||
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 的依赖(ISP:4 个方法)
|
|
||||||
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 的最小依赖(ISP:6 个方法)
|
// commentUseCase CommentController 对 CommentService 的最小依赖(ISP:6 个方法)
|
||||||
type commentUseCase interface {
|
type commentUseCase interface {
|
||||||
CreateRoot(userID, postID uint, body string) (*model.Comment, error)
|
CreateRoot(userID, postID uint, body string) (*model.Comment, error)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"))
|
||||||
|
|||||||
@ -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, "提交审核失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 加载对应数据 ----
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package middleware 提供 HTTP 中间件:认证、授权、CSRF、安全头、限流等。
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -40,5 +40,3 @@ func generateCSRFToken() (string, error) {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(b), nil
|
return hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package model 定义数据模型、常量、角色体系和审核相关类型。
|
||||||
package model
|
package model
|
||||||
|
|
||||||
// 审核类型常量
|
// 审核类型常量
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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"` // 父评论ID,NULL=顶级评论
|
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论ID,NULL=顶级评论
|
||||||
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字段,查询填充)
|
||||||
|
|||||||
@ -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-DD(Asia/Shanghai)
|
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DD(Asia/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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: "后台调整",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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 消息/通知模型
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 从配置初始化角色系统(唯一数据源)
|
||||||
// 必须在服务启动前调用,不提供默认值
|
// 必须在服务启动前调用,不提供默认值
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package repository 提供基于 GORM 的数据访问层实现。
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package router 负责依赖注入组装和 HTTP 路由注册。
|
||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package scheduler 提供定时任务调度器,管理定时发布等周期性任务。
|
||||||
package scheduler
|
package scheduler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package service 包含核心业务逻辑实现,通过 Store 接口隔离数据访问层。
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 构造函数
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 查询)
|
||||||
|
|||||||
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 在渲染时替换,仅作降级显示。
|
||||||
|
|||||||
@ -168,14 +168,6 @@ func (fs *FallbackStore) ping() bool {
|
|||||||
return fs.client.Ping(ctx).Err() == nil
|
return fs.client.Ping(ctx).Err() == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// current 返回当前应使用的 Store:Redis 健康用 Redis,否则用 Memory
|
|
||||||
func (fs *FallbackStore) current() Store {
|
|
||||||
if fs.healthy.Load() {
|
|
||||||
return fs.redis
|
|
||||||
}
|
|
||||||
return fs.memory
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Store 接口实现(Redis 操作失败时立即降级到内存)----
|
// ---- Store 接口实现(Redis 操作失败时立即降级到内存)----
|
||||||
// 不依赖周期性健康检查,操作级故障即时响应
|
// 不依赖周期性健康检查,操作级故障即时响应
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package session 提供服务端会话管理:创建、验证、销毁、Redis/内存双存储及自动降级。
|
||||||
package session
|
package session
|
||||||
|
|
||||||
// StoreMetrics 存储状态信息(供管理面板展示)
|
// StoreMetrics 存储状态信息(供管理面板展示)
|
||||||
|
|||||||
@ -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 分钟")
|
||||||
|
|||||||
Reference in New Issue
Block a user