From f1780de632918da8a275ebcbe58473709a8593de Mon Sep 17 00:00:00 2001 From: Zhang Chao Date: Fri, 14 Aug 2026 17:59:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96router=E3=80=81main=E7=AD=89?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config.go | 6 +- config/config.yaml | 1 + handlers/frontend/view.go | 8 +- main.go | 127 ++++++++++++++--- routers/router.go | 282 +++++++++++++++++++++----------------- utils/templateRenderer.go | 59 ++++---- 6 files changed, 303 insertions(+), 180 deletions(-) diff --git a/config/config.go b/config/config.go index 72f54e3..254dd06 100644 --- a/config/config.go +++ b/config/config.go @@ -19,8 +19,9 @@ type DatabaseConfig struct { } type ServerConfig struct { - Port int - Mode string + Port int + Mode string + LogLevel string // 日志级别:debug/info/warn/error,默认 info } type AppConfig struct { @@ -70,6 +71,7 @@ func Load() *Config { func setDefaults() { viper.SetDefault("server.port", 8080) viper.SetDefault("server.mode", "debug") + viper.SetDefault("server.log_level", "info") viper.SetDefault("database.driver", "sqlite") viper.SetDefault("database.dsn", "goblog.db") viper.SetDefault("app.name", "GoBlog") diff --git a/config/config.yaml b/config/config.yaml index d19dbe2..04cd6b7 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -2,6 +2,7 @@ server: port: 8080 mode: debug # debug 或 release + log_level: info # info, warn, error database: driver: sqlite # sqlite 或 mysql diff --git a/handlers/frontend/view.go b/handlers/frontend/view.go index 8915bd0..9a88c86 100644 --- a/handlers/frontend/view.go +++ b/handlers/frontend/view.go @@ -62,7 +62,7 @@ func loginInfo(c *gin.Context) (isLoggedIn bool, loginName string) { return true, loginName } -// 首页 +// 首页数据结构 type IndexData struct { Title string SiteName string @@ -84,6 +84,7 @@ type IndexData struct { LoginName string } +// 首页(供模板使用) func IndexView(c *gin.Context) { cfg := getConfig() page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) @@ -94,6 +95,7 @@ func IndexView(c *gin.Context) { categoryID, _ := strconv.Atoi(c.Query("category")) tagID, _ := strconv.Atoi(c.Query("tag")) + authorID, _ := strconv.Atoi(c.Query("author")) db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author"). Where("status = ?", "published") @@ -105,6 +107,9 @@ func IndexView(c *gin.Context) { db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id"). Where("post_tags.tag_id = ?", tagID) } + if authorID > 0 { + db = db.Where("author_id = ?", authorID) + } var total int64 db.Count(&total) @@ -169,6 +174,7 @@ type PostDetailData struct { LoginName string } +// 文章详情页(供模板使用) func PostView(c *gin.Context) { cfg := getConfig() slug := c.Param("slug") diff --git a/main.go b/main.go index 09a3a7a..0ddc517 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,24 @@ package main import ( + "context" + "errors" "log/slog" + "net/http" "os" + "os/signal" + "strconv" + "syscall" + "time" "goblog/config" "goblog/database" "goblog/models" "goblog/routers" + "goblog/utils" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) func main() { @@ -19,10 +28,8 @@ func main() { // 设置 Gin 模式 gin.SetMode(cfg.Server.Mode) - // 初始化日志系统 - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })) + // 初始化日志系统(使用 Server.LogLevel) + logger := newLogger(cfg.Server.LogLevel) slog.SetDefault(logger) slog.Info("启动博客系统", @@ -30,6 +37,8 @@ func main() { "theme", cfg.App.Theme, "db_driver", cfg.Database.Driver, "port", cfg.Server.Port, + "mode", cfg.Server.Mode, + "log_level", cfg.Server.LogLevel, ) // 初始化数据库 @@ -37,31 +46,105 @@ func main() { slog.Error("数据库初始化失败", "error", err) os.Exit(1) } + // defer database.Close() // 如果提供 - // 从数据库读取主题设置,如果不存在则使用配置文件中的值 - theme := cfg.App.Theme - var option models.Option - if err := database.DB.Where("name = ? AND user_id = ?", "theme", 0).First(&option).Error; err == nil { - theme = option.Value - slog.Info("从数据库加载主题", "theme", theme) - } else { - slog.Info("使用配置文件中的主题", "theme", theme) + // 从数据库加载主题(若不存在则使用配置值) + theme, err := loadTheme(cfg.App.Theme) + if err != nil { + slog.Error("加载主题失败", "error", err) + os.Exit(1) } - // 创建路由引擎 - r := gin.Default() + // 创建路由引擎(使用自定义 slog 中间件) + r := gin.New() + r.Use(gin.Recovery()) + r.Use(slogMiddleware(logger)) - // 配置模板渲染器 - routers.SetupTemplates(r, theme) + // 配置模板渲染器(现在 LoadTemplates 返回 error) + htmlRenderer, err := utils.LoadTemplates(theme) + if err != nil { + slog.Error("加载模板失败", "error", err, "theme", theme) + os.Exit(1) + } + utils.SetGlobalRenderer(htmlRenderer) + r.HTMLRender = htmlRenderer // 配置所有路由 routers.SetupRoutes(r) - // 启动服务器 - port := routers.GetServerPort(cfg.Server.Port) - slog.Info("服务器启动成功", "port", port, "mode", cfg.Server.Mode) - if err := r.Run(port); err != nil { - slog.Error("服务器启动失败", "error", err) - os.Exit(1) + // 启动服务器(优雅关闭) + runServer(r, cfg.Server.Port) +} + +// newLogger 根据配置字符串创建 slog.Logger +func newLogger(level string) *slog.Logger { + var lvl slog.Level + if err := lvl.UnmarshalText([]byte(level)); err != nil { + lvl = slog.LevelInfo // 默认 info + } + opts := &slog.HandlerOptions{Level: lvl} + return slog.New(slog.NewTextHandler(os.Stdout, opts)) +} + +// loadTheme 从数据库读取系统主题,若不存在则使用 fallback +func loadTheme(fallback string) (string, error) { + var option models.Option + err := database.DB.Where("name = ? AND user_id = ?", "theme", 0).First(&option).Error + if err == nil { + slog.Info("从数据库加载主题", "theme", option.Value) + return option.Value, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + slog.Info("使用配置文件中的主题", "theme", fallback) + return fallback, nil + } + return "", err // 真实错误必须返回 +} + +// slogMiddleware 自定义请求日志中间件 +func slogMiddleware(logger *slog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + + c.Next() + + logger.Info("HTTP请求", + "method", c.Request.Method, + "path", path, + "status", c.Writer.Status(), + "duration", time.Since(start), + "client_ip", c.ClientIP(), + ) + } +} + +// runServer 启动 HTTP 服务并支持优雅关闭 +func runServer(r *gin.Engine, port int) { + addr := ":" + strconv.Itoa(port) + srv := &http.Server{ + Addr: addr, + Handler: r, + } + + go func() { + slog.Info("服务器启动成功", "addr", addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("服务器启动失败", "error", err) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + slog.Info("正在关闭服务器...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + slog.Error("服务器强制关闭", "error", err) + } else { + slog.Info("服务器已优雅关闭") } } diff --git a/routers/router.go b/routers/router.go index 05a5891..b875173 100644 --- a/routers/router.go +++ b/routers/router.go @@ -1,151 +1,181 @@ package routers import ( - "strconv" - "goblog/handlers/backend" "goblog/handlers/frontend" "goblog/middleware" - "goblog/utils" "github.com/gin-gonic/gin" ) +// 常量定义 +const ( + staticPath = "/static" + adminStaticPath = "/admin/static" + adminPath = "/admin" + apiPrefix = "/api" +) + // SetupRoutes 配置所有路由 func SetupRoutes(r *gin.Engine) { - // 静态文件 - r.Static("/static", "./static") + setupStaticRoutes(r) + setupAdminPageRoutes(r) + setupFrontendRoutes(r) + setupAPIRoutes(r) +} - // 后台管理(放在前面避免冲突) - r.Static("/admin/static", "./static/admin") - // 独立登录页 - r.GET("/admin/login", backend.AdminLoginView) - // 后台首页:可选认证中间件注入用户信息,服务端按角色渲染菜单与页面框架 - r.GET("/admin", middleware.OptionalAuth(), backend.AdminView) +// setupStaticRoutes 配置静态资源路由 +func setupStaticRoutes(r *gin.Engine) { + r.Static(staticPath, "./static") + r.Static(adminStaticPath, "./static/admin") +} - // 前端页面路由(可选认证:侧边栏根据登录态显示系统菜单) +// setupAdminPageRoutes 配置后台管理页面路由(服务端渲染) +func setupAdminPageRoutes(r *gin.Engine) { + r.GET(adminPath+"/login", backend.AdminLoginView) + r.GET(adminPath, middleware.OptionalAuth(), backend.AdminView) +} + +// setupFrontendRoutes 配置前台页面路由 +func setupFrontendRoutes(r *gin.Engine) { r.GET("/", middleware.OptionalAuth(), frontend.IndexView) r.GET("/post/:slug", middleware.OptionalAuth(), frontend.PostView) r.GET("/page/:slug", middleware.OptionalAuth(), frontend.PageView) r.GET("/search", middleware.OptionalAuth(), frontend.SearchView) - r.POST("/comment", frontend.SubmitComment) - // 前台退出登录:清除 Cookie 后回首页 - r.GET("/logout", backend.WebLogout) - // API 路由组 - api := r.Group("/api") + r.POST("/comment", frontend.SubmitComment) // 前台提交评论(公开) + r.GET("/logout", backend.WebLogout) // 前台退出登录 +} + +// setupAPIRoutes 配置所有 /api 下的接口路由 +func setupAPIRoutes(r *gin.Engine) { + api := r.Group(apiPrefix) + + // 公开接口:无需认证或可选认证 + setupPublicAPIRoutes(api) + + // 需要认证的接口(JWT) + setupAuthenticatedAPIRoutes(api) + + // 管理员专属接口(JWT + 管理员角色) + setupAdminAPIRoutes(api) +} + +// setupPublicAPIRoutes 配置公开 API(无需认证或可选认证) +func setupPublicAPIRoutes(api *gin.RouterGroup) { + // 文章 + api.GET("/posts", middleware.OptionalAuth(), backend.GetPosts) + api.GET("/posts/:id", middleware.OptionalAuth(), backend.GetPost) + + // 分类 + api.GET("/categories", backend.GetCategories) + api.GET("/categories/:id", backend.GetCategory) + + // 标签 + api.GET("/tags", backend.GetTags) + api.GET("/tags/:id", backend.GetTag) + + // 页面 + api.GET("/pages", backend.GetPages) + api.GET("/pages/:id", backend.GetPage) + + // 评论列表(公开,仅显示已批准评论) + api.GET("/comments", backend.GetComments) + + // 创建评论(可选认证,若登录则关联用户) + api.POST("/comments", middleware.OptionalAuth(), backend.CreateComment) + + // 登录(公开) + api.POST("/auth/login", backend.Login) + + // 仪表盘统计(可选认证,未登录返回公开统计,登录返回更多数据) + api.GET("/dashboard/stats", middleware.OptionalAuth(), backend.GetDashboardStats) + + // 主题公开接口 + api.GET("/themes", backend.GetThemes) + api.GET("/theme", backend.GetCurrentTheme) +} + +// setupAuthenticatedAPIRoutes 配置需要登录的 API(JWT 认证) +func setupAuthenticatedAPIRoutes(api *gin.RouterGroup) { + auth := api.Group("/") + auth.Use(middleware.JWTAuth()) + + // 当前用户信息与修改 + auth.GET("/auth/me", backend.GetCurrentUser) + auth.PUT("/auth/me", backend.UpdateUser) + auth.PUT("/auth/password", backend.ChangePassword) + + // 登出 + auth.POST("/auth/logout", backend.Logout) + + // 文章管理 + auth.POST("/posts", backend.CreatePost) + auth.PUT("/posts/:id", backend.UpdatePost) + auth.DELETE("/posts/:id", backend.DeletePost) + + // Markdown 预览 + auth.POST("/markdown/preview", backend.PreviewMarkdown) + + // 文件上传 + auth.POST("/upload", backend.UploadImage) + + // 我的评论管理(管理自己文章下的评论) + auth.GET("/my/comments", backend.GetComments) + auth.PUT("/my/comments/:id/approve", backend.ApproveComment) + auth.PUT("/my/comments/:id/spam", backend.MarkSpamComment) + auth.DELETE("/my/comments/:id", backend.DeleteComment) + + // 主题管理(认证用户可切换主题、创建主题、编辑主题文件) + themeAuth := auth.Group("/") { - // 公开 API(支持可选认证,以便管理员查看所有文章) - api.GET("/posts", middleware.OptionalAuth(), backend.GetPosts) - api.GET("/dashboard/stats", middleware.OptionalAuth(), backend.GetDashboardStats) - api.GET("/posts/:id", middleware.OptionalAuth(), backend.GetPost) - api.GET("/categories", backend.GetCategories) - api.GET("/categories/:id", backend.GetCategory) - api.GET("/tags", backend.GetTags) - api.GET("/tags/:id", backend.GetTag) - api.GET("/pages", backend.GetPages) - api.GET("/pages/:id", backend.GetPage) - api.GET("/comments", backend.GetComments) - - // 需要认证的 API - api.POST("/comments", middleware.OptionalAuth(), backend.CreateComment) - - // 用户认证 - api.POST("/auth/login", backend.Login) - api.POST("/auth/logout", backend.Logout) - - // 需要登录的 API - auth := api.Group("/") - auth.Use(middleware.JWTAuth()) - { - auth.GET("/auth/me", backend.GetCurrentUser) - - // 仪表盘统计(所有登录用户可访问,handler 内部按角色过滤数据) - auth.GET("/dashboard", middleware.OptionalAuth(), backend.GetDashboardStats) - auth.PUT("/auth/me", backend.UpdateUser) - auth.PUT("/auth/password", backend.ChangePassword) - - // 文章管理(所有登录用户可新建文章,修改/删除自己的文章) - auth.POST("/posts", backend.CreatePost) - auth.PUT("/posts/:id", backend.UpdatePost) - auth.DELETE("/posts/:id", backend.DeletePost) - - // Markdown 预览(文章编辑器用) - auth.POST("/markdown/preview", backend.PreviewMarkdown) - - // 文件上传(文章编辑器封面图等) - auth.POST("/upload", backend.UploadImage) - - // 评论管理(登录用户管理自己文章的评论) - auth.GET("/my/comments", backend.GetComments) - auth.PUT("/my/comments/:id/approve", backend.ApproveComment) - auth.PUT("/my/comments/:id/spam", backend.MarkSpamComment) - auth.DELETE("/my/comments/:id", backend.DeleteComment) - } - - // 主题管理(可选认证) - api.GET("/themes", backend.GetThemes) - api.GET("/theme", backend.GetCurrentTheme) - api.POST("/theme", middleware.JWTAuth(), backend.SwitchTheme) - api.POST("/themes/create", middleware.JWTAuth(), backend.CreateTheme) - api.GET("/themes/:theme/files", middleware.JWTAuth(), backend.GetThemeFiles) - api.GET("/themes/:theme/files/:file", middleware.JWTAuth(), backend.GetThemeFile) - api.PUT("/themes/:theme/files/:file", middleware.JWTAuth(), backend.SaveThemeFile) - - // 管理员 API - admin := api.Group("/admin") - admin.Use(middleware.JWTAuth(), middleware.AdminRequired()) - { - // Markdown 预览(文章编辑器用)已移至登录组 - - // 分类管理 - admin.POST("/categories", backend.CreateCategory) - admin.PUT("/categories/:id", backend.UpdateCategory) - admin.DELETE("/categories/:id", backend.DeleteCategory) - - // 标签管理 - admin.POST("/tags", backend.CreateTag) - admin.PUT("/tags/:id", backend.UpdateTag) - admin.DELETE("/tags/:id", backend.DeleteTag) - - // 页面管理 - admin.POST("/pages", backend.CreatePage) - admin.PUT("/pages/:id", backend.UpdatePage) - admin.DELETE("/pages/:id", backend.DeletePage) - - // 评论管理 - admin.GET("/comments", backend.GetComments) - admin.PUT("/comments/:id/approve", backend.ApproveComment) - admin.PUT("/comments/:id/spam", backend.MarkSpamComment) - admin.DELETE("/comments/:id", backend.DeleteComment) - - // 用户管理 - admin.GET("/users", backend.GetUsers) - admin.POST("/users", backend.CreateUser) - admin.PUT("/users/:id", backend.AdminUpdateUser) - admin.PUT("/users/:id/password", backend.AdminResetPassword) - admin.DELETE("/users/:id", backend.DeleteUser) - - // 基础设置 - admin.GET("/settings", backend.GetSettings) - admin.PUT("/settings", backend.UpdateSettings) - - // 文件管理(图片上传) - admin.POST("/upload", backend.UploadImage) - admin.GET("/uploads", backend.ListUploads) - admin.DELETE("/uploads/:name", backend.DeleteUpload) - } + themeAuth.POST("/theme", backend.SwitchTheme) + themeAuth.POST("/themes/create", backend.CreateTheme) + themeAuth.GET("/themes/:theme/files", backend.GetThemeFiles) + themeAuth.GET("/themes/:theme/files/:file", backend.GetThemeFile) + themeAuth.PUT("/themes/:theme/files/:file", backend.SaveThemeFile) } } -// SetupTemplates 配置模板渲染器 -func SetupTemplates(r *gin.Engine, theme string) { - htmlRenderer := utils.LoadTemplates(theme) - utils.SetGlobalRenderer(htmlRenderer) - r.HTMLRender = htmlRenderer -} +// setupAdminAPIRoutes 配置管理员专属 API(需要登录 + 管理员角色) +func setupAdminAPIRoutes(api *gin.RouterGroup) { + admin := api.Group("/admin") + admin.Use(middleware.JWTAuth(), middleware.AdminRequired()) -// GetServerPort 获取服务器端口 -func GetServerPort(port int) string { - return ":" + strconv.Itoa(port) + // 分类管理 + admin.POST("/categories", backend.CreateCategory) + admin.PUT("/categories/:id", backend.UpdateCategory) + admin.DELETE("/categories/:id", backend.DeleteCategory) + + // 标签管理 + admin.POST("/tags", backend.CreateTag) + admin.PUT("/tags/:id", backend.UpdateTag) + admin.DELETE("/tags/:id", backend.DeleteTag) + + // 页面管理 + admin.POST("/pages", backend.CreatePage) + admin.PUT("/pages/:id", backend.UpdatePage) + admin.DELETE("/pages/:id", backend.DeletePage) + + // 评论管理(所有评论) + admin.GET("/comments", backend.GetComments) + admin.PUT("/comments/:id/approve", backend.ApproveComment) + admin.PUT("/comments/:id/spam", backend.MarkSpamComment) + admin.DELETE("/comments/:id", backend.DeleteComment) + + // 用户管理 + admin.GET("/users", backend.GetUsers) + admin.POST("/users", backend.CreateUser) + admin.PUT("/users/:id", backend.AdminUpdateUser) + admin.PUT("/users/:id/password", backend.AdminResetPassword) + admin.DELETE("/users/:id", backend.DeleteUser) + + // 基础设置 + admin.GET("/settings", backend.GetSettings) + admin.PUT("/settings", backend.UpdateSettings) + + // 文件管理 + admin.POST("/upload", backend.UploadImage) + admin.GET("/uploads", backend.ListUploads) + admin.DELETE("/uploads/:name", backend.DeleteUpload) } diff --git a/utils/templateRenderer.go b/utils/templateRenderer.go index 62e19ce..5f8ca6b 100644 --- a/utils/templateRenderer.go +++ b/utils/templateRenderer.go @@ -67,12 +67,14 @@ func (r *htmlRender) WriteContentType(w http.ResponseWriter) { } // LoadTemplates 加载指定主题的模板 -func LoadTemplates(theme string) *TemplateRenderer { +func LoadTemplates(theme string) (*TemplateRenderer, error) { r := &TemplateRenderer{ templates: make(map[string]*template.Template), } - r.reload(theme) - return r + if err := r.reload(theme); err != nil { + return nil, err + } + return r, nil } // Reload 重新加载主题模板(线程安全) @@ -83,34 +85,35 @@ func (r *TemplateRenderer) Reload(theme string) { } // reload 内部重载方法(需要持有锁) -func (r *TemplateRenderer) reload(theme string) { +func (r *TemplateRenderer) reload(theme string) error { templateDir := "templates/" + theme // 需要独立渲染的页面模板 pages := []string{"index", "post", "page"} - // 收集主题目录下的公共局部模板(例如 sidebar/header/footer), - // 这些模板通过 {{template "xxx" .}} 被 base.html 引用, - // 必须与页面模板一起解析,否则渲染时会因模板未定义而失败(白屏)。 + // 收集主题目录下的公共局部模板 partials := []string{} - if matches, err := filepath.Glob(templateDir + "/*.html"); err == nil { - for _, f := range matches { - base := filepath.Base(f) - if base == "base.html" { - continue - } - name := base[:len(base)-len(".html")] - isPage := false - for _, p := range pages { - if p == name { - isPage = true - break - } - } - if !isPage { - partials = append(partials, f) + matches, err := filepath.Glob(templateDir + "/*.html") + if err != nil { + return fmt.Errorf("扫描主题目录失败: %w", err) + } + + for _, f := range matches { + base := filepath.Base(f) + if base == "base.html" { + continue + } + name := base[:len(base)-len(".html")] + isPage := false + for _, p := range pages { + if p == name { + isPage = true + break } } + if !isPage { + partials = append(partials, f) + } } // 为每个页面创建独立的模板组合 @@ -125,19 +128,17 @@ func (r *TemplateRenderer) reload(theme string) { ) allFiles = append(allFiles, partials...) - // 解析模板文件 + // 解析模板文件,遇到错误立即返回 tmpl, err := template.New("").Funcs(templateFuncs()).ParseFiles(allFiles...) if err != nil { - // 如果解析失败,记录错误但不中断 - fmt.Printf("警告: 加载主题 %s 的 %s 页面模板失败: %v\n", theme, pageName, err) - continue + return fmt.Errorf("解析页面模板 %s 失败: %w", pageName, err) } newTemplates[pageName] = tmpl } - // 更新模板映射 + // 所有页面模板解析成功后才更新映射 r.templates = newTemplates - fmt.Printf("主题 %s 加载完成,共加载 %d 个页面模板\n", theme, len(newTemplates)) + return nil } // 全局模板渲染器实例