diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..8f8d49f
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,36 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ lint:
+ name: Lint + Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.26"
+
+ - name: Cache Go modules
+ uses: actions/cache@v4
+ with:
+ path: ~/go/pkg/mod
+ key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
+ restore-keys: ${{ runner.os }}-go-
+
+ - name: Install tools
+ run: |
+ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
+ go install mvdan.cc/gofumpt@latest
+ go install golang.org/x/tools/cmd/goimports@latest
+
+ - name: CI (strict)
+ run: bash scripts/ci.sh --strict
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 1c2ef34..07a3e16 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -6,6 +6,7 @@ import (
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/config"
+ "metazone.cc/mce/internal/middleware"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/repository"
"metazone.cc/mce/internal/router"
@@ -22,6 +23,16 @@ func main() {
// 配置
cfg := config.Load("config.yaml")
+ // 设置时区
+ if cfg.Server.Timezone != "" {
+ loc, err := time.LoadLocation(cfg.Server.Timezone)
+ if err != nil {
+ log.Printf("警告:时区 %s 无效,使用系统时区: %v", cfg.Server.Timezone, err)
+ } else {
+ time.Local = loc
+ }
+ }
+
// 从配置初始化角色系统(OCP:新增角色无需修改源代码)
model.InitRoles(cfg.Roles.Levels, cfg.Roles.Names, cfg.Roles.Permissions.OperableRoles)
@@ -98,6 +109,9 @@ func main() {
log.Fatalf("初始化站点设置失败: %v", err)
}
+ // 启动数据库健康检测(每 5s ping,失败自动降级)
+ middleware.StartDBHealthCheck(db)
+
// 路由注册
router.Setup(r, db, cfg, siteSettings, redisClient)
diff --git a/config.yaml b/config.yaml
index 1995c07..0044c9d 100644
--- a/config.yaml
+++ b/config.yaml
@@ -5,6 +5,7 @@ server:
port: 8080
mode: debug # debug | release | test
cookie_secure: false # Cookie Secure 标志(生产环境应设为 true,部分反向代理环境下可能需要灵活控制)
+ timezone: Asia/Shanghai # 时区(影响 time.Now() 和模板时间格式化)
database:
host: 127.0.0.1
diff --git a/internal/common/context.go b/internal/common/context.go
index 202220b..9b85a38 100644
--- a/internal/common/context.go
+++ b/internal/common/context.go
@@ -1,7 +1,11 @@
// Package common 提供通用工具函数、错误哨兵和响应辅助方法。
package common
-import "github.com/gin-gonic/gin"
+import (
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+)
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
// 如果用户未登录,ok 返回 false;role 可能为空字符串
@@ -19,3 +23,28 @@ func GetGinUser(c *gin.Context) (uid uint, role string, ok bool) {
}
return
}
+
+// GetGinExp 从 Gin context 中提取当前用户的经验值
+// 如果用户未登录或 exp 不存在,ok 返回 false
+func GetGinExp(c *gin.Context) (exp int, ok bool) {
+ if v, exists := c.Get("exp"); exists {
+ if e, isInt := v.(int); isInt {
+ return e, true
+ }
+ }
+ return 0, false
+}
+
+// ParseUIDParam 从 URL 路径参数中提取 uint 类型的 UID
+// 统一 UID 解析逻辑,避免各 Controller 重复 strconv.ParseUint
+func ParseUIDParam(c *gin.Context, paramName string) (uint, error) {
+ s := c.Param(paramName)
+ if s == "" {
+ return 0, ErrInvalidParam
+ }
+ v, err := strconv.ParseUint(s, 10, 64)
+ if err != nil {
+ return 0, ErrInvalidParam
+ }
+ return uint(v), nil
+}
diff --git a/internal/common/errors.go b/internal/common/errors.go
index 3c72945..f33e5ca 100644
--- a/internal/common/errors.go
+++ b/internal/common/errors.go
@@ -58,4 +58,7 @@ var (
// 评论相关
ErrCommentNotFound = errors.New("评论不存在")
ErrCommentEmpty = errors.New("评论内容不能为空")
+
+ // 通用参数校验
+ ErrInvalidParam = errors.New("参数无效")
)
diff --git a/internal/config/config.go b/internal/config/config.go
index b76b010..4c5235c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -33,6 +33,7 @@ type ServerConfig struct {
Port string `mapstructure:"port"`
Mode string `mapstructure:"mode"`
CookieSecure bool `mapstructure:"cookie_secure"`
+ Timezone string `mapstructure:"timezone"`
}
type DatabaseConfig struct {
diff --git a/internal/controller/admin/admin_controller.go b/internal/controller/admin/admin_controller.go
index 89609c9..82720c8 100644
--- a/internal/controller/admin/admin_controller.go
+++ b/internal/controller/admin/admin_controller.go
@@ -134,11 +134,7 @@ func (ac *AdminController) ResetToken(c *gin.Context) {
// parseUIDParam 从 URL 路径参数解析 uid
func parseUIDParam(c *gin.Context) (uint, error) {
- uid, err := strconv.ParseUint(c.Param("uid"), 10, 64)
- if err != nil {
- return 0, err
- }
- return uint(uid), nil
+ return common.ParseUIDParam(c, "uid")
}
// handleServiceError 统一处理 service 层返回的错误
diff --git a/internal/controller/admin/site_setting_controller.go b/internal/controller/admin/site_setting_controller.go
index 4fb8c85..db64b30 100644
--- a/internal/controller/admin/site_setting_controller.go
+++ b/internal/controller/admin/site_setting_controller.go
@@ -21,12 +21,48 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi
return &SiteSettingController{settingService: settingService, defaults: defaults}
}
-// SiteSettingsPage 站点设置管理页面
+// SiteSettingsPage 旧站点设置页 — 301 重定向到品牌页
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
- c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{
- "Title": "站点设置",
- "ExtraCSS": "/admin/static/css/site-settings.css",
- "ExtraJS": "/admin/static/js/site-settings.js",
+ c.Redirect(http.StatusMovedPermanently, "/admin/settings/brand")
+}
+
+// BrandPage 品牌与合规设置页
+func (sc *SiteSettingController) BrandPage(c *gin.Context) {
+ c.HTML(http.StatusOK, "admin/site-settings/brand.html", common.BuildAdminPageData(c, gin.H{
+ "Title": "站点设置 - 品牌与合规",
+ "ExtraCSS": "/admin/static/css/site-settings.css",
+ "ExtraJS": "/admin/static/js/site-settings.js",
+ "ActiveSetting": "brand",
+ }))
+}
+
+// SecurityPage 安全与维护设置页
+func (sc *SiteSettingController) SecurityPage(c *gin.Context) {
+ c.HTML(http.StatusOK, "admin/site-settings/security.html", common.BuildAdminPageData(c, gin.H{
+ "Title": "站点设置 - 安全与维护",
+ "ExtraCSS": "/admin/static/css/site-settings.css",
+ "ExtraJS": "/admin/static/js/site-settings.js",
+ "ActiveSetting": "security",
+ }))
+}
+
+// RegistrationPage 注册策略设置页
+func (sc *SiteSettingController) RegistrationPage(c *gin.Context) {
+ c.HTML(http.StatusOK, "admin/site-settings/registration.html", common.BuildAdminPageData(c, gin.H{
+ "Title": "站点设置 - 注册策略",
+ "ExtraCSS": "/admin/static/css/site-settings.css",
+ "ExtraJS": "/admin/static/js/site-settings.js",
+ "ActiveSetting": "registration",
+ }))
+}
+
+// ContentPage 审核与内容设置页
+func (sc *SiteSettingController) ContentPage(c *gin.Context) {
+ c.HTML(http.StatusOK, "admin/site-settings/content.html", common.BuildAdminPageData(c, gin.H{
+ "Title": "站点设置 - 审核与内容",
+ "ExtraCSS": "/admin/static/css/site-settings.css",
+ "ExtraJS": "/admin/static/js/site-settings.js",
+ "ActiveSetting": "content",
}))
}
diff --git a/internal/controller/comment_upload_controller.go b/internal/controller/comment_upload_controller.go
index 692b12b..a4c0d6c 100644
--- a/internal/controller/comment_upload_controller.go
+++ b/internal/controller/comment_upload_controller.go
@@ -28,12 +28,11 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
}
// LV4+ 检查(Exp ≥ 1500)
- expVal, exists := c.Get("exp")
- if !exists {
+ exp, ok := common.GetGinExp(c)
+ if !ok {
common.Error(c, http.StatusForbidden, "无法获取经验值")
return
}
- exp := expVal.(int)
if exp < 1500 {
common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片")
return
diff --git a/internal/controller/follow_controller.go b/internal/controller/follow_controller.go
index 61e9e6f..6db74cb 100644
--- a/internal/controller/follow_controller.go
+++ b/internal/controller/follow_controller.go
@@ -29,13 +29,13 @@ func (fc *FollowController) Toggle(c *gin.Context) {
return
}
- targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
+ targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
- isFollowing, err := fc.followSvc.Toggle(uid, uint(targetUID))
+ isFollowing, err := fc.followSvc.Toggle(uid, targetUID)
if err != nil {
if err.Error() == "不能关注自己" {
common.Error(c, http.StatusBadRequest, err.Error())
@@ -46,7 +46,7 @@ func (fc *FollowController) Toggle(c *gin.Context) {
}
// 获取最新状态
- status, _ := fc.followSvc.GetStatus(uid, uint(targetUID))
+ status, _ := fc.followSvc.GetStatus(uid, targetUID)
common.Ok(c, gin.H{
"is_following": isFollowing,
@@ -56,7 +56,7 @@ func (fc *FollowController) Toggle(c *gin.Context) {
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
func (fc *FollowController) GetStatus(c *gin.Context) {
- targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
+ targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
@@ -64,7 +64,7 @@ func (fc *FollowController) GetStatus(c *gin.Context) {
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false)
- status, err := fc.followSvc.GetStatus(uid, uint(targetUID))
+ status, err := fc.followSvc.GetStatus(uid, targetUID)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
@@ -75,7 +75,7 @@ func (fc *FollowController) GetStatus(c *gin.Context) {
// Followers 粉丝列表 GET /api/users/:uid/followers
func (fc *FollowController) Followers(c *gin.Context) {
- targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
+ targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
@@ -86,7 +86,7 @@ func (fc *FollowController) Followers(c *gin.Context) {
uid, _, _ := common.GetGinUser(c) // 允许未登录
- result, err := fc.followSvc.ListFollowers(uint(targetUID), uid, page, pageSize)
+ result, err := fc.followSvc.ListFollowers(targetUID, uid, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
@@ -97,7 +97,7 @@ func (fc *FollowController) Followers(c *gin.Context) {
// Following 关注列表 GET /api/users/:uid/following
func (fc *FollowController) Following(c *gin.Context) {
- targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
+ targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
@@ -108,7 +108,7 @@ func (fc *FollowController) Following(c *gin.Context) {
uid, _, _ := common.GetGinUser(c)
- result, err := fc.followSvc.ListFollowing(uint(targetUID), uid, page, pageSize)
+ result, err := fc.followSvc.ListFollowing(targetUID, uid, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
@@ -129,7 +129,7 @@ func NewFollowPageController(followSvc followUseCase) *FollowPageController {
// FollowersPage 粉丝列表页面
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
- targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
+ targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
@@ -138,7 +138,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
uid, _, _ := common.GetGinUser(c)
- result, err := fpc.followSvc.ListFollowers(uint(targetUID), uid, page, 20)
+ result, err := fpc.followSvc.ListFollowers(targetUID, uid, page, 20)
if err != nil {
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
}
@@ -159,7 +159,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
// FollowingPage 关注列表页面
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
- targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
+ targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
@@ -168,7 +168,7 @@ func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
uid, _, _ := common.GetGinUser(c)
- result, err := fpc.followSvc.ListFollowing(uint(targetUID), uid, page, 20)
+ result, err := fpc.followSvc.ListFollowing(targetUID, uid, page, 20)
if err != nil {
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
}
diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go
index f53476b..f98c03e 100644
--- a/internal/controller/settings_controller.go
+++ b/internal/controller/settings_controller.go
@@ -68,6 +68,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
data := gin.H{
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
+ "ExtraJS": "/static/js/settings-" + tab + ".js",
"ActiveTab": tab,
"User": user,
"RoleName": model.RoleDisplayNames[user.Role],
@@ -113,5 +114,22 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
data["CurrentSID"] = currentSID
}
- c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
+ // 按 tab 渲染独立模板
+ tplName := "settings/index.html"
+ switch tab {
+ case "profile":
+ tplName = "settings/profile.html"
+ case "account":
+ tplName = "settings/account.html"
+ case "sessions":
+ tplName = "settings/sessions.html"
+ case "energy":
+ tplName = "settings/energy.html"
+ case "favorites":
+ tplName = "settings/favorites.html"
+ case "notify":
+ tplName = "settings/notify.html"
+ }
+
+ c.HTML(http.StatusOK, tplName, common.BuildPageData(c, data))
}
diff --git a/internal/middleware/db_health.go b/internal/middleware/db_health.go
new file mode 100644
index 0000000..0b8bedc
--- /dev/null
+++ b/internal/middleware/db_health.go
@@ -0,0 +1,84 @@
+package middleware
+
+import (
+ "log"
+ "net/http"
+ "sync/atomic"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// dbHealthy 全局数据库健康状态(原子读写,默认为健康)
+var dbHealthy atomic.Bool
+
+func init() {
+ dbHealthy.Store(true)
+}
+
+// StartDBHealthCheck 启动定时 DB 健康检测(在 main goroutine 中调用)
+// 每 5 秒 ping 一次,失败则标记不健康,恢复则重新标记健康
+func StartDBHealthCheck(db *gorm.DB) {
+ go func() {
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+ for range ticker.C {
+ sqlDB, err := db.DB()
+ if err != nil {
+ dbHealthy.Store(false)
+ log.Printf("[DBHealth] 获取底层连接失败: %v", err)
+ continue
+ }
+ if err := sqlDB.Ping(); err != nil {
+ if dbHealthy.Swap(false) {
+ log.Printf("[DBHealth] 数据库不可达,整站进入降级模式: %v", err)
+ }
+ } else {
+ if !dbHealthy.Swap(true) {
+ log.Println("[DBHealth] 数据库已恢复,退出降级模式")
+ }
+ }
+ }
+ }()
+}
+
+// DBHealthGuard 数据库健康检查中间件
+// 检测到 DB 不可达时,返回固定维护页面,避免暴露内部堆栈信息
+func DBHealthGuard() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if dbHealthy.Load() {
+ c.Next()
+ return
+ }
+
+ path := c.Request.URL.Path
+ // 静态资源和 API 直接返回错误,不渲染页面
+ if isStaticPath(path) {
+ c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
+ "success": false,
+ "message": "服务暂时不可用,请稍后重试",
+ })
+ return
+ }
+ // 页面请求返回友好 HTML
+ c.AbortWithStatus(http.StatusServiceUnavailable)
+ c.Header("Content-Type", "text/html; charset=utf-8")
+ _, _ = c.Writer.WriteString(`
+
+
服务暂时不可用
+
+`)
+ }
+}
+
+// isStaticPath 判断是否为静态资源或 API 路径
+func isStaticPath(path string) bool {
+ for _, prefix := range []string{"/static/", "/shared/", "/admin/static/", "/api/"} {
+ if len(path) >= len(prefix) && path[:len(prefix)] == prefix {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/middleware/security.go b/internal/middleware/security.go
index 90d346d..2828a62 100644
--- a/internal/middleware/security.go
+++ b/internal/middleware/security.go
@@ -3,8 +3,7 @@ package middleware
import (
"crypto/rand"
"encoding/base64"
-
- "metazone.cc/mce/internal/config"
+ "fmt"
"github.com/gin-gonic/gin"
)
@@ -28,12 +27,13 @@ func SecurityHeaders() gin.HandlerFunc {
c.Set("csp_nonce", nonce)
// Content-Security-Policy
- // script-src/style-src: 本站 + nonce(替代 unsafe-inline)
+ // script-src: 本站 + nonce(替代 unsafe-inline),保留 unsafe-eval 供 Vditor 使用
+ // style-src: 本站(所有内联样式已使用 nonce)
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
c.Header("Content-Security-Policy",
"default-src 'self'; "+
- "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
- "style-src 'self' 'unsafe-inline'; "+
+ fmt.Sprintf("script-src 'self' 'unsafe-eval' 'nonce-%s'; ", nonce)+
+ "style-src 'self' 'nonce-"+nonce+"'; "+
"img-src 'self' data: blob:; "+
"connect-src 'self'")
@@ -46,10 +46,8 @@ func SecurityHeaders() gin.HandlerFunc {
// 引用策略
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
- // HSTS:仅在 release 模式启用,避免开发环境 localhost 证书问题
- if config.App != nil && config.App.Server.Mode == "release" {
- c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
- }
+ // HSTS:始终启用(max-age=1年,含子域名,允许 preload 列表收录)
+ c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
c.Next()
}
diff --git a/internal/router/admin.go b/internal/router/admin.go
index 057c3b4..2526eaa 100644
--- a/internal/router/admin.go
+++ b/internal/router/admin.go
@@ -28,6 +28,14 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
middleware.RequirePageRole(model.RoleOwner))
+ adminPages.GET("/settings/brand", d.siteSettingCtrl.BrandPage,
+ middleware.RequirePageRole(model.RoleOwner))
+ adminPages.GET("/settings/security", d.siteSettingCtrl.SecurityPage,
+ middleware.RequirePageRole(model.RoleOwner))
+ adminPages.GET("/settings/registration", d.siteSettingCtrl.RegistrationPage,
+ middleware.RequirePageRole(model.RoleOwner))
+ adminPages.GET("/settings/content", d.siteSettingCtrl.ContentPage,
+ middleware.RequirePageRole(model.RoleOwner))
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
diff --git a/internal/router/api.go b/internal/router/api.go
index df08d9b..23e899e 100644
--- a/internal/router/api.go
+++ b/internal/router/api.go
@@ -2,197 +2,17 @@ package router
import (
"metazone.cc/mce/internal/config"
- "metazone.cc/mce/internal/middleware"
"github.com/gin-gonic/gin"
)
-// setupAPIRoutes 注册 API 路由
+// setupAPIRoutes 按域分发 API 路由注册
func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
- // --- 设置页 API(需登录) ---
- settingsAPI := r.Group("/api/settings")
- settingsAPI.Use(d.authMdw.Required())
- settingsAPI.Use(d.authMdw.BannedWriteGuard())
- settingsAPI.Use(middleware.CSRF(cfg))
- {
- settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
- settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar)
- settingsAPI.PUT("/password", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.ChangePassword)
- settingsAPI.POST("/delete-account", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.DeleteAccount)
- settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus)
- // 登录管理
- settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions)
- settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
- settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
- settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
- // 通知偏好
- settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs)
- settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref)
- }
-
- // --- 消息中心 API(需登录) ---
- messagesAPI := r.Group("/api/messages")
- messagesAPI.Use(d.authMdw.Required())
- messagesAPI.Use(d.authMdw.BannedWriteGuard())
- messagesAPI.Use(middleware.CSRF(cfg))
- {
- messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
- messagesAPI.POST("/:id/read", d.messageCtrl.MarkRead)
- messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
- }
-
- // --- 积分/等级 API(需登录) ---
- levelAPI := r.Group("/api/level")
- levelAPI.Use(d.authMdw.Required())
- levelAPI.Use(d.authMdw.BannedWriteGuard())
- levelAPI.Use(middleware.CSRF(cfg))
- {
- levelAPI.POST("/checkin", middleware.SensitiveRateLimit(d.rateLimiter), d.levelCtrl.CheckIn)
- levelAPI.GET("/info", d.levelCtrl.GetLevel)
- }
-
- // --- 认证 API ---
- api := r.Group("/api")
- api.Use(middleware.CSRF(cfg))
- {
- api.POST("/auth/check-email", d.authCtrl.CheckEmail)
- api.POST("/auth/register", d.authCtrl.Register)
- api.POST("/auth/login", d.authCtrl.Login)
- api.POST("/auth/confirm-restore", d.authCtrl.ConfirmRestore)
- api.POST("/auth/logout", d.authCtrl.Logout)
- api.POST("/auth/refresh", d.authCtrl.RefreshToken)
-
- // 帖子公开 API(无需登录)
- api.GET("/posts", d.postCtrl.ListAPI)
- api.GET("/posts/:id", d.postCtrl.ShowAPI)
- }
-
- // --- 帖子 API(需登录) ---
- postsAPI := r.Group("/api/posts")
- postsAPI.Use(d.authMdw.Required())
- postsAPI.Use(d.authMdw.BannedWriteGuard())
- postsAPI.Use(middleware.CSRF(cfg))
- {
- postsAPI.POST("/:id/submit", d.postCtrl.Submit)
- postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
- }
-
- // --- 创作中心 API(需登录) ---
- studioAPI := r.Group("/api/studio")
- studioAPI.Use(d.authMdw.Required())
- studioAPI.Use(d.authMdw.BannedWriteGuard())
- studioAPI.Use(middleware.CSRF(cfg))
- {
- studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)
- studioAPI.GET("/posts", d.studioCtrl.ListPostsAPI)
- studioAPI.POST("/posts", d.studioCtrl.Create)
- studioAPI.PUT("/posts/:id", d.studioCtrl.Update)
- studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
- studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
- }
-
- // --- 域能 API(需登录) ---
- energyAPI := r.Group("/api/energy")
- energyAPI.Use(d.authMdw.Required())
- energyAPI.Use(d.authMdw.BannedWriteGuard())
- energyAPI.Use(middleware.CSRF(cfg))
- {
- energyAPI.POST("/energize", d.energyCtrl.Energize)
- energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
- energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
- }
-
- // --- 评论 API(部分需登录) ---
- // 公开读取
- commentsPublic := r.Group("/api/posts/:id/comments")
- {
- commentsPublic.GET("", d.commentCtrl.ListRootComments)
- commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies)
- }
- // 需要登录的操作
- commentsAPI := r.Group("/api")
- commentsAPI.Use(d.authMdw.Required())
- commentsAPI.Use(d.authMdw.BannedWriteGuard())
- commentsAPI.Use(middleware.CSRF(cfg))
- {
- commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot)
- commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
- commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
- commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
- commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage)
- }
-
- // --- 赞/踩 API ---
- // 公开:查询反应状态(未登录返回 none + 点赞数)
- r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction)
- // 需登录:切换赞/踩
- reactionAPI := r.Group("/api/posts/:id")
- reactionAPI.Use(d.authMdw.Required())
- reactionAPI.Use(d.authMdw.BannedWriteGuard())
- reactionAPI.Use(middleware.CSRF(cfg))
- {
- reactionAPI.POST("/like", d.reactionCtrl.ToggleLike)
- reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike)
- }
-
- // --- 关注 API ---
- // 公开(Optional 认证以获取当前用户关注状态,未登录返回 false)
- followPublic := r.Group("/api/users/:uid")
- followPublic.Use(d.authMdw.Optional())
- {
- followPublic.GET("/follow-status", d.followCtrl.GetStatus)
- followPublic.GET("/followers", d.followCtrl.Followers)
- followPublic.GET("/following", d.followCtrl.Following)
- }
- // 需登录:切换关注
- followAPI := r.Group("/api/users/:uid")
- followAPI.Use(d.authMdw.Required())
- followAPI.Use(d.authMdw.BannedWriteGuard())
- followAPI.Use(middleware.CSRF(cfg))
- {
- followAPI.POST("/follow", d.followCtrl.Toggle)
- }
-
- // --- 收藏夹 API ---
- // 公开:文章收藏状态(未登录返回 false)
- r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus)
- // 公开:查看公开收藏夹内容
- r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems)
- // 需登录:收藏操作
- folderAPI := r.Group("/api/folders")
- folderAPI.Use(d.authMdw.Required())
- folderAPI.Use(d.authMdw.BannedWriteGuard())
- folderAPI.Use(middleware.CSRF(cfg))
- {
- folderAPI.GET("", d.favoriteCtrl.ListFolders)
- folderAPI.POST("", d.favoriteCtrl.CreateFolder)
- folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder)
- folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder)
- folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder)
- folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder)
- }
- // 需登录:切换收藏(Toggle)
- favToggleAPI := r.Group("/api/posts/:id")
- favToggleAPI.Use(d.authMdw.Required())
- favToggleAPI.Use(d.authMdw.BannedWriteGuard())
- favToggleAPI.Use(middleware.CSRF(cfg))
- {
- favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite)
- }
-
- // --- 空间/隐私设置 API(需登录) ---
- spaceAPI := r.Group("/api/space")
- spaceAPI.Use(d.authMdw.Required())
- spaceAPI.Use(d.authMdw.BannedWriteGuard())
- spaceAPI.Use(middleware.CSRF(cfg))
- {
- spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy)
- }
-
- // --- Studio 趋势 API(需登录) ---
- trendsAPI := r.Group("/api/studio")
- trendsAPI.Use(d.authMdw.Required())
- {
- trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI)
- }
+ setupAuthAPIRoutes(r, cfg, d)
+ setupSettingsAPIRoutes(r, cfg, d)
+ setupPostsAPIRoutes(r, cfg, d)
+ setupCommentsAPIRoutes(r, cfg, d)
+ setupStudioAPIRoutes(r, cfg, d)
+ setupReactionsAPIRoutes(r, cfg, d)
+ setupSocialAPIRoutes(r, cfg, d)
}
diff --git a/internal/router/api_auth.go b/internal/router/api_auth.go
new file mode 100644
index 0000000..a9c6694
--- /dev/null
+++ b/internal/router/api_auth.go
@@ -0,0 +1,27 @@
+package router
+
+import (
+ "metazone.cc/mce/internal/config"
+ "metazone.cc/mce/internal/middleware"
+
+ "github.com/gin-gonic/gin"
+)
+
+// setupAuthAPIRoutes 注册认证相关 API 路由
+func setupAuthAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
+ api := r.Group("/api")
+ api.Use(middleware.CSRF(cfg))
+ {
+ // 认证
+ api.POST("/auth/check-email", d.authCtrl.CheckEmail)
+ api.POST("/auth/register", d.authCtrl.Register)
+ api.POST("/auth/login", d.authCtrl.Login)
+ api.POST("/auth/confirm-restore", d.authCtrl.ConfirmRestore)
+ api.POST("/auth/logout", d.authCtrl.Logout)
+ api.POST("/auth/refresh", d.authCtrl.RefreshToken)
+
+ // 帖子公开 API(无需登录)
+ api.GET("/posts", d.postCtrl.ListAPI)
+ api.GET("/posts/:id", d.postCtrl.ShowAPI)
+ }
+}
diff --git a/internal/router/api_comments.go b/internal/router/api_comments.go
new file mode 100644
index 0000000..2f9427c
--- /dev/null
+++ b/internal/router/api_comments.go
@@ -0,0 +1,31 @@
+package router
+
+import (
+ "metazone.cc/mce/internal/config"
+ "metazone.cc/mce/internal/middleware"
+
+ "github.com/gin-gonic/gin"
+)
+
+// setupCommentsAPIRoutes 注册评论相关 API 路由
+func setupCommentsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
+ // 公开读取
+ commentsPublic := r.Group("/api/posts/:id/comments")
+ {
+ commentsPublic.GET("", d.commentCtrl.ListRootComments)
+ commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies)
+ }
+
+ // 需要登录的操作
+ commentsAPI := r.Group("/api")
+ commentsAPI.Use(d.authMdw.Required())
+ commentsAPI.Use(d.authMdw.BannedWriteGuard())
+ commentsAPI.Use(middleware.CSRF(cfg))
+ {
+ commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot)
+ commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
+ commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
+ commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
+ commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage)
+ }
+}
diff --git a/internal/router/api_posts.go b/internal/router/api_posts.go
new file mode 100644
index 0000000..574f8fa
--- /dev/null
+++ b/internal/router/api_posts.go
@@ -0,0 +1,20 @@
+package router
+
+import (
+ "metazone.cc/mce/internal/config"
+ "metazone.cc/mce/internal/middleware"
+
+ "github.com/gin-gonic/gin"
+)
+
+// setupPostsAPIRoutes 注册帖子相关 API 路由
+func setupPostsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
+ postsAPI := r.Group("/api/posts")
+ postsAPI.Use(d.authMdw.Required())
+ postsAPI.Use(d.authMdw.BannedWriteGuard())
+ postsAPI.Use(middleware.CSRF(cfg))
+ {
+ postsAPI.POST("/:id/submit", d.postCtrl.Submit)
+ postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
+ }
+}
diff --git a/internal/router/api_reactions.go b/internal/router/api_reactions.go
new file mode 100644
index 0000000..6c06548
--- /dev/null
+++ b/internal/router/api_reactions.go
@@ -0,0 +1,24 @@
+package router
+
+import (
+ "metazone.cc/mce/internal/config"
+ "metazone.cc/mce/internal/middleware"
+
+ "github.com/gin-gonic/gin"
+)
+
+// setupReactionsAPIRoutes 注册赞/踩相关 API 路由
+func setupReactionsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
+ // 公开:查询反应状态
+ r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction)
+
+ // 需登录:切换赞/踩
+ reactionAPI := r.Group("/api/posts/:id")
+ reactionAPI.Use(d.authMdw.Required())
+ reactionAPI.Use(d.authMdw.BannedWriteGuard())
+ reactionAPI.Use(middleware.CSRF(cfg))
+ {
+ reactionAPI.POST("/like", d.reactionCtrl.ToggleLike)
+ reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike)
+ }
+}
diff --git a/internal/router/api_settings.go b/internal/router/api_settings.go
new file mode 100644
index 0000000..fabe02b
--- /dev/null
+++ b/internal/router/api_settings.go
@@ -0,0 +1,51 @@
+package router
+
+import (
+ "metazone.cc/mce/internal/config"
+ "metazone.cc/mce/internal/middleware"
+
+ "github.com/gin-gonic/gin"
+)
+
+// setupSettingsAPIRoutes 注册设置、消息、等级相关 API 路由
+func setupSettingsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
+ // 设置页 API
+ settingsAPI := r.Group("/api/settings")
+ settingsAPI.Use(d.authMdw.Required())
+ settingsAPI.Use(d.authMdw.BannedWriteGuard())
+ settingsAPI.Use(middleware.CSRF(cfg))
+ {
+ settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
+ settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar)
+ settingsAPI.PUT("/password", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.ChangePassword)
+ settingsAPI.POST("/delete-account", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.DeleteAccount)
+ settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus)
+ settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions)
+ settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
+ settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
+ settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
+ settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs)
+ settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref)
+ }
+
+ // 消息中心 API
+ messagesAPI := r.Group("/api/messages")
+ messagesAPI.Use(d.authMdw.Required())
+ messagesAPI.Use(d.authMdw.BannedWriteGuard())
+ messagesAPI.Use(middleware.CSRF(cfg))
+ {
+ messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
+ messagesAPI.POST("/:id/read", d.messageCtrl.MarkRead)
+ messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
+ }
+
+ // 积分/等级 API
+ levelAPI := r.Group("/api/level")
+ levelAPI.Use(d.authMdw.Required())
+ levelAPI.Use(d.authMdw.BannedWriteGuard())
+ levelAPI.Use(middleware.CSRF(cfg))
+ {
+ levelAPI.POST("/checkin", middleware.SensitiveRateLimit(d.rateLimiter), d.levelCtrl.CheckIn)
+ levelAPI.GET("/info", d.levelCtrl.GetLevel)
+ }
+}
diff --git a/internal/router/api_social.go b/internal/router/api_social.go
new file mode 100644
index 0000000..25026b6
--- /dev/null
+++ b/internal/router/api_social.go
@@ -0,0 +1,64 @@
+package router
+
+import (
+ "metazone.cc/mce/internal/config"
+ "metazone.cc/mce/internal/middleware"
+
+ "github.com/gin-gonic/gin"
+)
+
+// setupSocialAPIRoutes 注册关注、收藏夹、空间相关 API 路由
+func setupSocialAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
+ // 关注 — 公开(Optional 认证)
+ followPublic := r.Group("/api/users/:uid")
+ followPublic.Use(d.authMdw.Optional())
+ {
+ followPublic.GET("/follow-status", d.followCtrl.GetStatus)
+ followPublic.GET("/followers", d.followCtrl.Followers)
+ followPublic.GET("/following", d.followCtrl.Following)
+ }
+ // 需登录:切换关注
+ followAPI := r.Group("/api/users/:uid")
+ followAPI.Use(d.authMdw.Required())
+ followAPI.Use(d.authMdw.BannedWriteGuard())
+ followAPI.Use(middleware.CSRF(cfg))
+ {
+ followAPI.POST("/follow", d.followCtrl.Toggle)
+ }
+
+ // 收藏夹 — 公开
+ r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus)
+ r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems)
+
+ // 收藏夹 — 需登录
+ folderAPI := r.Group("/api/folders")
+ folderAPI.Use(d.authMdw.Required())
+ folderAPI.Use(d.authMdw.BannedWriteGuard())
+ folderAPI.Use(middleware.CSRF(cfg))
+ {
+ folderAPI.GET("", d.favoriteCtrl.ListFolders)
+ folderAPI.POST("", d.favoriteCtrl.CreateFolder)
+ folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder)
+ folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder)
+ folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder)
+ folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder)
+ }
+
+ // 切换收藏 Toggle
+ favToggleAPI := r.Group("/api/posts/:id")
+ favToggleAPI.Use(d.authMdw.Required())
+ favToggleAPI.Use(d.authMdw.BannedWriteGuard())
+ favToggleAPI.Use(middleware.CSRF(cfg))
+ {
+ favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite)
+ }
+
+ // 空间/隐私设置
+ spaceAPI := r.Group("/api/space")
+ spaceAPI.Use(d.authMdw.Required())
+ spaceAPI.Use(d.authMdw.BannedWriteGuard())
+ spaceAPI.Use(middleware.CSRF(cfg))
+ {
+ spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy)
+ }
+}
diff --git a/internal/router/api_studio.go b/internal/router/api_studio.go
new file mode 100644
index 0000000..0a56ccd
--- /dev/null
+++ b/internal/router/api_studio.go
@@ -0,0 +1,43 @@
+package router
+
+import (
+ "metazone.cc/mce/internal/config"
+ "metazone.cc/mce/internal/middleware"
+
+ "github.com/gin-gonic/gin"
+)
+
+// setupStudioAPIRoutes 注册创作中心、域能、趋势相关 API 路由
+func setupStudioAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
+ // 创作中心
+ studioAPI := r.Group("/api/studio")
+ studioAPI.Use(d.authMdw.Required())
+ studioAPI.Use(d.authMdw.BannedWriteGuard())
+ studioAPI.Use(middleware.CSRF(cfg))
+ {
+ studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)
+ studioAPI.GET("/posts", d.studioCtrl.ListPostsAPI)
+ studioAPI.POST("/posts", d.studioCtrl.Create)
+ studioAPI.PUT("/posts/:id", d.studioCtrl.Update)
+ studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
+ studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
+ }
+
+ // 域能
+ energyAPI := r.Group("/api/energy")
+ energyAPI.Use(d.authMdw.Required())
+ energyAPI.Use(d.authMdw.BannedWriteGuard())
+ energyAPI.Use(middleware.CSRF(cfg))
+ {
+ energyAPI.POST("/energize", d.energyCtrl.Energize)
+ energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
+ energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
+ }
+
+ // 趋势
+ trendsAPI := r.Group("/api/studio")
+ trendsAPI.Use(d.authMdw.Required())
+ {
+ trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI)
+ }
+}
diff --git a/internal/router/router.go b/internal/router/router.go
index 739e3c5..dbcd515 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -14,6 +14,7 @@ import (
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) {
r.Use(middleware.SecurityHeaders())
r.Use(middleware.InjectSiteInfo(siteSettings))
+ r.Use(middleware.DBHealthGuard())
deps := buildDeps(db, cfg, siteSettings, redisClient)
diff --git a/templates/MetaLab-2026/html/settings/_footer.html b/templates/MetaLab-2026/html/settings/_footer.html
new file mode 100644
index 0000000..f8b91d6
--- /dev/null
+++ b/templates/MetaLab-2026/html/settings/_footer.html
@@ -0,0 +1,5 @@
+
+
+{{template "layout/footer.html" .}}
+