Some checks failed
CI / Lint + Build (push) Has been cancelled
新增功能:
- 主题发现模块:扫描 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 + 子导航
190 lines
6.2 KiB
Go
190 lines
6.2 KiB
Go
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)
|
||
// 不置 nil:go-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)
|
||
}
|