Files
mce/cmd/server/main.go
Victor_Jay e7092254ab
Some checks failed
CI / Lint + Build (push) Has been cancelled
feat(theme): 管理后台新增模板选择器,支持运行时动态切换主题
新增功能:
- 主题发现模块:扫描 templates/ 目录下含 theme.json 的目录
- 站点设置新增 site.theme 键,DB 持久化 + 内存缓存
- 运行时切换主题:保存后即时重载 Gin 模板 + 静态资源哈希
- 管理后台 '外观主题' 页面:列表展示、选择、一键切换
- 动态静态资源服务:根据当前主题目录提供 CSS/JS

修复:
- account.html 残留孤儿 {{end}} 模板语法错误
- 动态静态资源 handler 误判 Gin *filepath 路径前缀为绝对路径

变更文件(12 modified + 3 new):
- cmd/server/main.go: 动态主题加载 + 运行时重载 + 动态静态服务
- internal/theme/discovery.go: 新增主题扫描
- internal/config/site_settings.go: ThemeName()
- internal/service/site_setting_service.go: ThemeReloader + UpdateTheme
- internal/controller/admin/: ISP 扩展 + GetThemes/UpdateTheme/ThemePage
- internal/controller/auth_controller.go: 准则路径动态化
- internal/router/: InjectThemeReloader + 新路由
- templates/: 外观主题页面 + JS + CSS + 子导航
2026-06-30 17:43:08 +08:00

190 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"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"
"metazone.cc/mce/internal/scheduler"
"metazone.cc/mce/internal/theme"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
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)
// 数据库
db, err := gorm.Open(postgres.Open(cfg.Database.DSN()), &gorm.Config{})
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}, &model.Announcement{}, &model.Category{}, &model.Tag{}, &model.PostTag{}); err != nil {
log.Fatalf("数据库迁移失败: %v", err)
}
// 初始化公户行id=1, balance=0如已存在则忽略
if err := db.FirstOrCreate(&model.CommunityFund{ID: 1, Balance: 0}).Error; err != nil {
log.Fatalf("初始化公户失败: %v", err)
}
// 邮箱唯一索引迁移:从全表唯一改为部分唯一(仅 deleted_at IS NULL 的行)
// 支持软删除用户邮箱复用
migrations := []string{
`DROP INDEX IF EXISTS idx_users_email`,
`CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_active ON users (email) WHERE deleted_at IS NULL`,
}
for _, m := range migrations {
if err := db.Exec(m).Error; err != nil {
log.Fatalf("数据库迁移失败 (%s): %v", m, err)
}
}
log.Println("数据库连接成功")
// Gin 模式
gin.SetMode(cfg.Server.Mode)
r := gin.Default()
// 站点设置管理器DB 持久化 + 内存缓存 — 必须在模板加载前初始化)
siteSettings, err := config.NewSiteSettings(db)
if err != nil {
log.Fatalf("初始化站点设置失败: %v", err)
}
// 读取当前主题名称
themeName := siteSettings.ThemeName()
log.Printf("当前主题: %s", themeName)
// 计算静态资源哈希
common.ComputeAssetHashes("templates/" + themeName)
// 模板加载(前端主题 + 管理后台)
tmpl, err := loadThemeTemplates(themeName)
if err != nil {
log.Fatalf("加载模板失败: %v", err)
}
r.SetHTMLTemplate(tmpl)
// 静态资源 — 前台使用动态主题目录
r.GET("/static/*filepath", func(c *gin.Context) {
serveDynamicStatic(c, "templates", siteSettings, c.Param("filepath"))
})
r.Static("/admin/static", "./templates/admin/static")
r.Static("/shared/static", "./templates/shared/static")
r.Static("/uploads/avatars", "./storage/uploads/avatars")
r.Static("/uploads/posts", "./storage/uploads/posts")
// 主题重载函数(切换主题后实时生效)
themeReloader := func(newTheme string) error {
log.Printf("切换主题: %s", newTheme)
// 更新静态资源哈希
common.ComputeAssetHashes("templates/" + newTheme)
// 重新加载模板
newTmpl, err := loadThemeTemplates(newTheme)
if err != nil {
return err
}
r.SetHTMLTemplate(newTmpl)
return nil
}
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
var redisClient *redis.Client
if cfg.Redis.Enabled {
redisClient, err = common.NewRedisClient(cfg.Redis)
if err != nil {
log.Printf("Redis 不可达,启动后首次请求将自动降级,后续恢复自动切回: %v", err)
// 不置 nilgo-redis 自带重连FallbackStore 负责健康检测与恢复
} else {
log.Printf("Redis 已连接 %s:%s (DB=%d)", cfg.Redis.Host, cfg.Redis.Port, cfg.Redis.DB)
}
} else {
log.Println("Redis 未启用,使用内存存储")
}
// 启动数据库健康检测(每 5s ping失败自动降级
middleware.StartDBHealthCheck(db)
// 路由注册
router.Setup(r, db, cfg, siteSettings, redisClient)
// 注入主题重载回调到站点设置 Controller
router.InjectThemeReloader(themeReloader)
// 定时发布调度器
scheduler.StartPublishScheduler(repository.NewPostRepo(db), 1*time.Minute)
// 启动
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
if err := r.Run("0.0.0.0:" + cfg.Server.Port); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
}
// loadThemeTemplates 加载指定主题的模板(前台 + 管理后台)
func loadThemeTemplates(themeName string) (*template.Template, error) {
return theme.LoadTemplates(
theme.TemplateRoot{Dir: "templates/" + themeName + "/html", Prefix: ""},
theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
)
}
// serveDynamicStatic 根据当前站点主题动态提供静态资源
func serveDynamicStatic(c *gin.Context, baseDir string, ss *config.SiteSettings, file string) {
themeName := ss.ThemeName()
root := filepath.Join(baseDir, themeName, "static")
// 清理路径并拼接
cleanFile := filepath.Clean(file)
fullPath := filepath.Join(root, cleanFile)
// 安全检查:防止路径遍历逃逸出主题目录
absRoot, err := filepath.Abs(root)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
absPath, err := filepath.Abs(fullPath)
if err != nil || !strings.HasPrefix(absPath, absRoot+string(os.PathSeparator)) && absPath != absRoot {
c.AbortWithStatus(http.StatusNotFound)
return
}
info, err := os.Stat(fullPath)
if err != nil || info.IsDir() {
c.AbortWithStatus(http.StatusNotFound)
return
}
http.ServeFile(c.Writer, c.Request, fullPath)
}