From b1e9d9cae6a634b4790bec4b959fa78d9cb2ca53 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 27 Aug 2026 19:24:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20API=20=E5=9F=BA=E5=BB=BA=E2=80=94?= =?UTF-8?q?=E2=80=94/api=20JSON=20=E6=8E=A5=E5=8F=A3=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E4=B8=8E=E8=AE=A4=E8=AF=81=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handlers/api.go:APIOK/APIError/bindJSON,统一 {ok,redirect,data} / {ok:false,code,error} 响应契约(code=i18n 键,error 按请求语言翻译) - i18n:新增 api_error/api_unauthorized/api_forbidden/api_invalid_request(中英) - middleware/auth.go:AuthRequired/AdminRequired 按 /api 前缀分支: JSON 401/403(页面保持 302),新增 isAPIRequest + apiAuthError - main.go:路由注册提取为 registerRoutes;建立 /api 分组并搬移附件三件套 (admin/my)与 /api/profile/avatar(旧 /admin|my/articles/attachments、 /profile/avatar 路由移除) - handlers/login_ratelimit.go:loginRateLimiter 导出为 LoginRateLimiter - templates/layouts/base.html:blogAPI/blogForm/blogShowError 共享 fetch 助手 - main_test.go:TestRegisterRoutesSmoke 冒烟测试(注册期 gin 静态/参数 冲突即 panic + 关键 /api 路由断言) - go build/vet/test ./... 全绿 --- TODO_API_REFACTOR.md | 42 +++++++ handlers/api.go | 52 ++++++++ handlers/auth.go | 2 +- handlers/login_ratelimit.go | 18 +-- handlers/security_test.go | 2 +- i18n/i18n.go | 12 ++ main.go | 242 ++++++++++++++++++++---------------- main_test.go | 38 ++++++ middleware/auth.go | 41 ++++++ templates/layouts/base.html | 48 +++++++ 10 files changed, 376 insertions(+), 121 deletions(-) create mode 100644 TODO_API_REFACTOR.md create mode 100644 handlers/api.go diff --git a/TODO_API_REFACTOR.md b/TODO_API_REFACTOR.md new file mode 100644 index 0000000..4604143 --- /dev/null +++ b/TODO_API_REFACTOR.md @@ -0,0 +1,42 @@ +# API 化改造 Todo + +操作接口改为 `/api/` 前缀的 JSON API(POST/PUT/DELETE、码+文案错误响应),页面路由保持不变。 + +## 基建(high) + +- [x] 1. 新建 `handlers/api.go`:`APIError(c, status, trKey)`、`APIOK(c, redirect, data)`、JSON 绑定/解析 helper +- [x] 2. `middleware/auth.go`:`AuthRequired`/`AdminRequired` 按 `/api` 路径前缀分支 401/403 JSON(非 API 保持 302) +- [x] 3. `main.go`:注册 `/api` 分组(auth/article/admin/profile/my 子组)+ gin 静态/参数路由冲突冒烟测试(新增 `TestRegisterRoutesSmoke`,含附件端点搬移,`LoginRateLimiter` 导出) +- [x] 4. `templates/layouts/base.html`:增加 `blogAPI`/`blogForm`/`blogShowError` 共享 fetch 助手(CSRF 头、`e.submitter` 状态按钮、bool 复选框转换) + +## 零行为搬移(medium) + +- [ ] 5. 附件三件套(admin/my)+ `/api/profile/avatar` 换注册路径 +- [ ] 6. 搬移端点模板更新:`article_create.html` 3 处 fetch URL、`profile.html` 1 处 + +## 功能改造(medium) + +- [ ] 7. 认证 API:`auth.go` Login/Register/Logout JSON 化(429 限流、会话轮换保留) +- [ ] 8. 认证模板:`login.html`/`register.html` 改 fetch + 错误 div `id`;base.html logout 改 fetch +- [ ] 9. 评论 API:`comment.go` PostComment JSON 化(校验码复用 i18n 键) +- [ ] 10. 评论 API:`admin_comment.go` approve/reject/delete JSON 化 +- [ ] 11. 评论模板:`article.html` 评论表单 fetch、`comment_list.html` 操作后 reload +- [ ] 12. 文章 CRUD API:`article.go`/`my_articles.go` 去 renderForm/Redirect 改 JSON(表单字段加 json tag) +- [ ] 13. 文章模板:`article_create.html`/`my_article_form.html`/`article_list.html`/`my_articles.html` 改 fetch(`easyMDE.value()`、`e.submitter`) +- [ ] 14. 用户 API:`admin_user.go` UserCreate/Update/Delete JSON 化(自防/最后管理员拦截改 403) +- [ ] 15. 用户模板:`user_form.html`/`user_list.html` 改 fetch +- [ ] 16. 设置 API:`settings.go` 5 组 save JSON 化;site favicon 拆出 `POST /api/admin/settings/site/favicon` +- [ ] 17. 设置模板:`settings_site`/`settings_navlinks`/`settings_upload`/`settings_download`/`settings_comment` 5 页改 fetch +- [ ] 18. 个人资料 API:`profile.go` UpdateProfile JSON 化(头像走 `/api/profile/avatar`) +- [ ] 19. 个人资料模板:`profile.html` 文本表单改 fetch + +## 测试与收尾(high) + +- [ ] 20. 新增 `handlers/api_test.go`:happy path / 校验码 / 401 / 403 / CSRF 头 / 429 / 409 +- [ ] 21. 更新 `security_test.go`/`p2_validation_test.go`/`p3_upload_test.go`/`session_upload_security_test.go` 到新 URL 与 JSON 断言 +- [ ] 22. `go build ./... && go vet ./... && go test ./...` 全绿;README 路由表同步 + +## 执行说明 + +- 每步只改路由/响应层,不动解析校验逻辑(JSON binding 替换 PostForm 读取),单步可编译可测 +- 并发状态:单一编辑者(本会话从第 1 项开始逐步执行) diff --git a/handlers/api.go b/handlers/api.go new file mode 100644 index 0000000..6f9cf04 --- /dev/null +++ b/handlers/api.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// APIOK 返回统一成功响应。 +// redirect 非空时附带相对跳转地址(原 302 目标,可含 ?saved=1 等 query); +// data 额外字段会合并进响应。 +func APIOK(c *gin.Context, redirect string, data gin.H) { + resp := gin.H{"ok": true} + if redirect != "" { + resp["redirect"] = redirect + } + for k, v := range data { + resp[k] = v + } + c.JSON(http.StatusOK, resp) +} + +// APIError 返回统一错误响应:{ok:false, code, error}。 +// trKey 是 i18n 键,直接作为 code 返回;error 为该键按请求语言的翻译文案。 +// trKey 为空时回退到通用键 "api_error"。 +func APIError(c *gin.Context, status int, trKey string) { + tr := getTr(c) + code := trKey + if code == "" { + code = "api_error" + } + msg := tr[code] + if msg == "" { + msg = tr["api_error"] + } + c.JSON(status, gin.H{ + "ok": false, + "code": code, + "error": msg, + }) +} + +// bindJSON 将 JSON 请求体绑定到 v。 +// 绑定失败时返回 400 + api_invalid_request,并返回 false。 +// 使用前必须保证请求是 JSON(Content-Type: application/json)。 +func bindJSON(c *gin.Context, v interface{}) bool { + if err := c.ShouldBindJSON(v); err != nil { + APIError(c, http.StatusBadRequest, "api_invalid_request") + return false + } + return true +} diff --git a/handlers/auth.go b/handlers/auth.go index af6b5cd..c63164a 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -36,7 +36,7 @@ func LoginPage() gin.HandlerFunc { // Login 处理登录表单提交。它对每个 IP+用户名实施速率限制 // (SECURITY_TODO #10),对于不存在的用户名会执行一次虚拟 bcrypt 比较, // 使耗时不会暴露用户名是否存在(SECURITY_TODO #25)。 -func Login(db *gorm.DB, limiter *loginRateLimiter) gin.HandlerFunc { +func Login(db *gorm.DB, limiter *LoginRateLimiter) gin.HandlerFunc { return func(c *gin.Context) { username := c.PostForm("username") password := c.PostForm("password") diff --git a/handlers/login_ratelimit.go b/handlers/login_ratelimit.go index 12ed376..3b4c756 100644 --- a/handlers/login_ratelimit.go +++ b/handlers/login_ratelimit.go @@ -18,8 +18,8 @@ const ( dummyHashCost = 12 ) -// loginRateLimiter 按键("IP|username")跟踪连续的登录失败次数。 -type loginRateLimiter struct { +// LoginRateLimiter 按键("IP|username")跟踪连续的登录失败次数。 +type LoginRateLimiter struct { mu sync.Mutex entries map[string]*loginRateEntry } @@ -31,15 +31,15 @@ type loginRateEntry struct { } // NewLoginLimiter 为登录端点创建空的速率限制器。 -func NewLoginLimiter() *loginRateLimiter { - return &loginRateLimiter{entries: make(map[string]*loginRateEntry)} +func NewLoginLimiter() *LoginRateLimiter { + return &LoginRateLimiter{entries: make(map[string]*loginRateEntry)} } -func (l *loginRateLimiter) now() time.Time { return time.Now() } +func (l *LoginRateLimiter) now() time.Time { return time.Now() } // Allow 报告该键是否允许再次尝试登录。锁定期窗口已过期的键会在此释放; // 仅计数失败次数的键保留其计数。 -func (l *loginRateLimiter) Allow(key string) bool { +func (l *LoginRateLimiter) Allow(key string) bool { l.mu.Lock() defer l.mu.Unlock() now := l.now() @@ -57,7 +57,7 @@ func (l *loginRateLimiter) Allow(key string) bool { // Fail 记录该键的一次失败尝试,并返回锁定生效前剩余的可尝试次数 // (0 = 刚刚被锁定)。 -func (l *loginRateLimiter) Fail(key string) (remaining int) { +func (l *LoginRateLimiter) Fail(key string) (remaining int) { l.mu.Lock() defer l.mu.Unlock() now := l.now() @@ -82,7 +82,7 @@ func (l *loginRateLimiter) Fail(key string) (remaining int) { } // Reset 在登录成功后清除失败计数。 -func (l *loginRateLimiter) Reset(key string) { +func (l *LoginRateLimiter) Reset(key string) { l.mu.Lock() defer l.mu.Unlock() delete(l.entries, key) @@ -90,7 +90,7 @@ func (l *loginRateLimiter) Reset(key string) { // sweep 限制映射大小,防止攻击者通过制造大量键使限流器无限增长。 // 过期的条目(若无过期条目,则移除最少访问的条目)会被逐出。 -func (l *loginRateLimiter) sweep(now time.Time) { +func (l *LoginRateLimiter) sweep(now time.Time) { if len(l.entries) <= maxTrackedKeys { return } diff --git a/handlers/security_test.go b/handlers/security_test.go index b37ce04..3935a28 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -29,7 +29,7 @@ type securityTestEnv struct { router *gin.Engine db *gorm.DB storageDir string - limiter *loginRateLimiter + limiter *LoginRateLimiter } var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`) diff --git a/i18n/i18n.go b/i18n/i18n.go index 0d37979..3b1d698 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -443,6 +443,12 @@ var translations = map[Lang]map[string]string{ "analytics_showing": "Showing", "analytics_of": "of", "analytics_load_more": "Load More", + + // API(/api/* JSON 接口通用) + "api_error": "Request failed.", + "api_unauthorized": "Please sign in first.", + "api_forbidden": "You do not have permission to perform this action.", + "api_invalid_request": "Invalid request body.", }, ZH: { // 导航 @@ -871,6 +877,12 @@ var translations = map[Lang]map[string]string{ "analytics_showing": "显示", "analytics_of": "共", "analytics_load_more": "加载更多", + + // API(/api/* JSON 接口通用) + "api_error": "请求失败,请稍后重试。", + "api_unauthorized": "请先登录。", + "api_forbidden": "您没有权限执行此操作。", + "api_invalid_request": "请求参数格式不正确。", }, } diff --git a/main.go b/main.go index 815eba8..54ccd40 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,8 @@ import ( "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" + "gorm.io/gorm" + "go_blog/config" "go_blog/handlers" "go_blog/middleware" @@ -110,116 +112,7 @@ func main() { router.Use(middleware.SetUserContext(db)) // 8. 注册路由。 - router.GET("/", handlers.HomePage(db)) - router.GET("/search", handlers.SearchPage(db)) - router.GET("/api/articles", handlers.HomeArticlesAPI(db)) - router.GET("/rss", handlers.RSSFeed(db)) - router.GET("/feed", handlers.RSSFeed(db)) - router.GET("/login", handlers.LoginPage()) - router.POST("/login", handlers.Login(db, loginLimiter)) - router.GET("/register", handlers.RegisterPage(db)) - router.POST("/register", handlers.Register(db)) - router.POST("/logout", handlers.Logout()) - router.GET("/article/:slug", handlers.ArticleDetail(db)) - router.POST("/article/:slug/comments", handlers.PostComment(db)) - - // 受保护的后台路由(仅管理员角色)。 - admin := router.Group("/admin") - admin.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) - { - admin.GET("", handlers.AdminDashboard(db)) - admin.GET("/articles", handlers.ArticleListPage(db)) - admin.GET("/articles/new", handlers.ArticleCreatePage(db)) - admin.POST("/articles/new", handlers.ArticleCreate(db)) - admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db)) - admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db)) - admin.POST("/articles/:id/delete", handlers.ArticleDelete(db)) - - } - - // 受保护的后台评论管理路由(仅管理员角色)。 - comments := router.Group("/admin/comments") - comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) - { - comments.GET("", handlers.CommentListPage(db)) - comments.POST("/:id/approve", handlers.CommentApprove(db)) - comments.POST("/:id/reject", handlers.CommentReject(db)) - comments.POST("/:id/delete", handlers.CommentDelete(db)) - } - - // 受保护的后台用户管理路由(仅管理员角色)。 - users := router.Group("/admin/users") - users.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) - { - users.GET("", handlers.UserListPage(db)) - users.GET("/new", handlers.UserCreatePage(db)) - users.POST("/new", handlers.UserCreate(db)) - users.GET("/:id/edit", handlers.UserEditPage(db)) - users.POST("/:id/edit", handlers.UserUpdate(db)) - users.POST("/:id/delete", handlers.UserDelete(db)) - } - - // 受保护的文章附件路由(仅管理员角色)。 - attachments := router.Group("/admin/articles") - attachments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) - { - attachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path)) - attachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path)) - attachments.GET("/:id/attachments", handlers.ListAttachments(db)) - } - - // 受保护的后台设置路由(平台配置)。 - settings := router.Group("/admin/settings") - settings.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) - { - settings.GET("/site", handlers.SiteSettingsPage(db)) - settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path)) - settings.GET("/navlinks", handlers.NavLinksSettingsPage(db)) - settings.POST("/navlinks", handlers.NavLinksSettingsSave(db)) - settings.GET("/upload", handlers.UploadSettingsPage(db)) - settings.POST("/upload", handlers.UploadSettingsSave(db)) - settings.GET("/download", handlers.DownloadSettingsPage(db)) - settings.POST("/download", handlers.DownloadSettingsSave(db)) - settings.GET("/comments", handlers.CommentSettingsPage(db)) - settings.POST("/comments", handlers.CommentSettingsSave(db)) - } - - // 受保护的后台统计路由(读取统计信息)。 - analytics := router.Group("/admin/analytics") - analytics.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) - { - analytics.GET("/views", handlers.ViewAnalyticsPage(db)) - } - - // 受保护的个人资料路由。 - profile := router.Group("/profile") - profile.Use(middleware.AuthRequired(db)) - { - profile.GET("", handlers.ProfilePage(db)) - profile.POST("", handlers.UpdateProfile(db, cfg.Path)) - profile.POST("/avatar", handlers.UploadAvatar(db, cfg.Path)) - } - - // 受保护的用户文章管理路由(面向非管理员用户)。 - myArticles := router.Group("/my") - myArticles.Use(middleware.AuthRequired(db)) - { - myArticles.GET("/articles", handlers.MyArticlesPage(db)) - myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db)) - myArticles.POST("/articles/new", handlers.MyArticleCreate(db)) - myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db)) - myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db)) - myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db)) - } - - // 用户文章的受保护附件路由。 - myAttachments := router.Group("/my/articles") - myAttachments.Use(middleware.AuthRequired(db)) - { - myAttachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path)) - myAttachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path)) - myAttachments.GET("/:id/attachments", handlers.ListAttachments(db)) - } + registerRoutes(router, cfg, db, loginLimiter) // 9. 启动服务器。 webPort := cfg.Web.Port @@ -259,6 +152,135 @@ func main() { select {} } +// registerRoutes 注册全部业务路由。独立成函数便于测试: +// 签名包含 db 与 loginLimiter,但注册阶段不会触碰它们(handler 是惰性工厂), +// 因此冒烟测试可传 nil。 +func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLimiter *handlers.LoginRateLimiter) { + // 公开页面。 + router.GET("/", handlers.HomePage(db)) + router.GET("/search", handlers.SearchPage(db)) + router.GET("/rss", handlers.RSSFeed(db)) + router.GET("/feed", handlers.RSSFeed(db)) + router.GET("/login", handlers.LoginPage()) + router.POST("/login", handlers.Login(db, loginLimiter)) + router.GET("/register", handlers.RegisterPage(db)) + router.POST("/register", handlers.Register(db)) + router.POST("/logout", handlers.Logout()) + router.GET("/article/:slug", handlers.ArticleDetail(db)) + router.POST("/article/:slug/comments", handlers.PostComment(db)) + + // 公开 JSON API。 + api := router.Group("/api") + { + api.GET("/articles", handlers.HomeArticlesAPI(db)) + } + + // 受保护的后台路由(仅管理员角色)。 + admin := router.Group("/admin") + admin.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) + { + admin.GET("", handlers.AdminDashboard(db)) + admin.GET("/articles", handlers.ArticleListPage(db)) + admin.GET("/articles/new", handlers.ArticleCreatePage(db)) + admin.POST("/articles/new", handlers.ArticleCreate(db)) + admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db)) + admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db)) + admin.POST("/articles/:id/delete", handlers.ArticleDelete(db)) + } + + // 受保护的后台评论管理路由(仅管理员角色)。 + comments := router.Group("/admin/comments") + comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) + { + comments.GET("", handlers.CommentListPage(db)) + comments.POST("/:id/approve", handlers.CommentApprove(db)) + comments.POST("/:id/reject", handlers.CommentReject(db)) + comments.POST("/:id/delete", handlers.CommentDelete(db)) + } + + // 受保护的后台用户管理路由(仅管理员角色)。 + users := router.Group("/admin/users") + users.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) + { + users.GET("", handlers.UserListPage(db)) + users.GET("/new", handlers.UserCreatePage(db)) + users.POST("/new", handlers.UserCreate(db)) + users.GET("/:id/edit", handlers.UserEditPage(db)) + users.POST("/:id/edit", handlers.UserUpdate(db)) + users.POST("/:id/delete", handlers.UserDelete(db)) + } + + // 受保护的后台文章附件 API / 路由(仅管理员角色)。 + // 注意 /api/admin/articles/attachments 的静态段与 /:id 参数段共存, + // gin 对静态段优先,无冲突(由 main_test.go 冒烟测试验证)。 + adminAPI := router.Group("/api/admin") + adminAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) + { + adminAPI.POST("/articles/attachments", handlers.UploadAttachment(db, cfg.Path)) + adminAPI.DELETE("/articles/attachments/:id", handlers.DeleteAttachment(db, cfg.Path)) + adminAPI.GET("/articles/:id/attachments", handlers.ListAttachments(db)) + } + + // 受保护的后台设置路由(平台配置)。 + settings := router.Group("/admin/settings") + settings.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) + { + settings.GET("/site", handlers.SiteSettingsPage(db)) + settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path)) + settings.GET("/navlinks", handlers.NavLinksSettingsPage(db)) + settings.POST("/navlinks", handlers.NavLinksSettingsSave(db)) + settings.GET("/upload", handlers.UploadSettingsPage(db)) + settings.POST("/upload", handlers.UploadSettingsSave(db)) + settings.GET("/download", handlers.DownloadSettingsPage(db)) + settings.POST("/download", handlers.DownloadSettingsSave(db)) + settings.GET("/comments", handlers.CommentSettingsPage(db)) + settings.POST("/comments", handlers.CommentSettingsSave(db)) + } + + // 受保护的后台统计路由(读取统计信息)。 + analytics := router.Group("/admin/analytics") + analytics.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) + { + analytics.GET("/views", handlers.ViewAnalyticsPage(db)) + } + + // 受保护的个人资料路由。 + profile := router.Group("/profile") + profile.Use(middleware.AuthRequired(db)) + { + profile.GET("", handlers.ProfilePage(db)) + profile.POST("", handlers.UpdateProfile(db, cfg.Path)) + } + + profileAPI := router.Group("/api/profile") + profileAPI.Use(middleware.AuthRequired(db)) + { + profileAPI.POST("/avatar", handlers.UploadAvatar(db, cfg.Path)) + profileAPI.POST("", handlers.UpdateProfile(db, cfg.Path)) + } + + // 受保护的用户文章管理路由(面向非管理员用户)。 + myArticles := router.Group("/my") + myArticles.Use(middleware.AuthRequired(db)) + { + myArticles.GET("/articles", handlers.MyArticlesPage(db)) + myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db)) + myArticles.POST("/articles/new", handlers.MyArticleCreate(db)) + myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db)) + myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db)) + myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db)) + } + + // 用户文章的受保护附件 API(仅登录用户)。 + myAPI := router.Group("/api/my/articles") + myAPI.Use(middleware.AuthRequired(db)) + { + myAPI.POST("/attachments", handlers.UploadAttachment(db, cfg.Path)) + myAPI.DELETE("/attachments/:id", handlers.DeleteAttachment(db, cfg.Path)) + myAPI.GET("/:id/attachments", handlers.ListAttachments(db)) + } +} + // registerUploadRoutes 在 /uploads 组下暴露公开的上传子目录:avatars、 // logos,以及配置的附件存储目录(外加向后兼容的默认 "attachments")。 // 存储根目录绝不挂载——其中还包含 SQLite 数据库文件, diff --git a/main_test.go b/main_test.go index 5edae70..1de3fe0 100644 --- a/main_test.go +++ b/main_test.go @@ -8,6 +8,9 @@ import ( "testing" "github.com/gin-gonic/gin" + + "go_blog/config" + "go_blog/handlers" ) // newUploadsRouter 在模拟真实布局的临时存储根目录上构建 @@ -127,3 +130,38 @@ func TestUploadsWhitelistStorageDirDedup(t *testing.T) { t.Fatalf("GET /uploads/avatars/me.jpg = %d, want 200", w.Code) } } + +// TestRegisterRoutesSmoke 通过完整的路由注册冒烟测试: +// 1. 路由冲突(静态段 attachment 与 :id 参数段共存)会在此处 panic; +// 2. 断言 /api 搬移端点已在正确的 HTTP 方法下注册。 +func TestRegisterRoutesSmoke(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + registerRoutes(r, &config.Config{}, nil, handlers.NewLoginLimiter()) + + want := map[string]string{ + // 既有 JSON API。 + "GET /api/articles": "", + // 搬移的附件/头像端点。 + "POST /api/admin/articles/attachments": "", + "DELETE /api/admin/articles/attachments/:id": "", + "GET /api/admin/articles/:id/attachments": "", + "POST /api/profile/avatar": "", + "POST /api/profile": "", + "POST /api/my/articles/attachments": "", + "DELETE /api/my/articles/attachments/:id": "", + "GET /api/my/articles/:id/attachments": "", + } + for route := range want { + found := false + for _, rt := range r.Routes() { + if rt.Method+" "+rt.Path == route { + found = true + break + } + } + if !found { + t.Errorf("route %s not registered", route) + } + } +} diff --git a/middleware/auth.go b/middleware/auth.go index 3d259c2..5b42f5d 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -2,6 +2,7 @@ package middleware import ( "net/http" + "strings" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -11,6 +12,30 @@ import ( "go_blog/models" ) +// isAPIRequest 报告请求是否命中 /api 前缀的 JSON 接口。 +// 认证失败时 API 返回 JSON 错误,页面则保持 302 重定向。 +func isAPIRequest(c *gin.Context) bool { + return strings.HasPrefix(c.Request.URL.Path, "/api") +} + +// apiAuthError 以 API 错误格式终止请求(401 未认证 / 403 无权限)。 +// 文案按请求语言翻译(SetUserContext 已在全局中间件中注入 tr)。 +func apiAuthError(c *gin.Context, status int, trKey string) { + tr, _ := c.Get("tr") + m, _ := tr.(map[string]string) + code := trKey + msg := m[code] + if msg == "" { + code = "api_error" + msg = m["api_error"] + } + c.AbortWithStatusJSON(status, gin.H{ + "ok": false, + "code": code, + "error": msg, + }) +} + // sessionUserID 从会话中提取已登录用户的数值 ID, // 兼容 int/uint/int64/float64 的存储类型。若不存在或类型不符,ok=false。 func sessionUserID(session sessions.Session) (uint, bool) { @@ -56,6 +81,10 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc { session := sessions.Default(c) uid, ok := sessionUserID(session) if !ok { + if isAPIRequest(c) { + apiAuthError(c, http.StatusUnauthorized, "api_unauthorized") + return + } c.Redirect(http.StatusFound, "/login") c.Abort() return @@ -64,6 +93,10 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc { if err := db.First(&user, uid).Error; err != nil || user.Status != models.StatusNormal { // 账户已不可用——销毁会话,防止过期 Cookie 被重放。 clearUserSession(session) + if isAPIRequest(c) { + apiAuthError(c, http.StatusUnauthorized, "api_unauthorized") + return + } c.Redirect(http.StatusFound, "/login") c.Abort() return @@ -80,12 +113,20 @@ func AdminRequired(db *gorm.DB) gin.HandlerFunc { session := sessions.Default(c) uid, ok := sessionUserID(session) if !ok { + if isAPIRequest(c) { + apiAuthError(c, http.StatusUnauthorized, "api_unauthorized") + return + } c.Redirect(http.StatusFound, "/login") c.Abort() return } var user models.User if err := db.First(&user, uid).Error; err != nil || user.Role != models.RoleAdmin { + if isAPIRequest(c) { + apiAuthError(c, http.StatusForbidden, "api_forbidden") + return + } c.Redirect(http.StatusFound, "/admin") c.Abort() return diff --git a/templates/layouts/base.html b/templates/layouts/base.html index 6e670ed..b44643b 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -176,6 +176,54 @@