feat: API 基建——/api JSON 接口统一契约与认证分支

- 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 ./... 全绿
This commit is contained in:
2026-08-27 19:24:43 +08:00
parent 5afc7fa9be
commit b1e9d9cae6
10 changed files with 376 additions and 121 deletions
+42
View File
@@ -0,0 +1,42 @@
# API 化改造 Todo
操作接口改为 `/api/` 前缀的 JSON APIPOST/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 项开始逐步执行)
+52
View File
@@ -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。
// 使用前必须保证请求是 JSONContent-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
}
+1 -1
View File
@@ -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")
+9 -9
View File
@@ -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
}
+1 -1
View File
@@ -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="([^"]+)"`)
+12
View File
@@ -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": "请求参数格式不正确。",
},
}
+132 -110
View File
@@ -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 数据库文件,
+38
View File
@@ -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)
}
}
}
+41
View File
@@ -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
+48
View File
@@ -176,6 +176,54 @@
</div>
</footer>
<script>
// ---- Shared API helpers/api/* JSON 接口统一入口) ----
window.blogAPI = function (method, url, body) {
var meta = document.querySelector('meta[name="csrf-token"]');
var csrf = meta ? meta.getAttribute('content') : '';
var opts = {
method: method,
headers: {
'X-CSRF-Token': csrf,
'Accept': 'application/json'
},
credentials: 'same-origin'
};
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
return fetch(url, opts).then(function (r) {
return r.json().catch(function () {
return { ok: false, code: 'api_error', error: r.statusText };
});
});
};
// 将表单序列化为 JSON 数据对象:
// - 文本/select/textarea 从 FormData 取值(checkboxes 在下述循环覆盖)
// - checkbox 一律转 bool(未选中也发送 false,匹配服务端 JSON 绑定)
// - 提交按钮(如文章表单的草稿/发布 name=status)取自 submitter
window.blogForm = function (form, submitter) {
var data = {};
new FormData(form).forEach(function (v, k) {
if (k === '_csrf') return;
if (!(k in data)) data[k] = v;
});
Array.prototype.forEach.call(form.querySelectorAll('input[type="checkbox"]'), function (el) {
if (el.name) data[el.name] = el.checked;
});
if (submitter && submitter.name) {
data[submitter.name] = submitter.value;
}
return data;
};
// 在指定错误 div 中显示 API 错误文案。
window.blogShowError = function (divId, msg) {
var el = document.getElementById(divId);
if (el) { el.textContent = msg; el.classList.remove('hidden'); }
};
function toggleDropdown() {
var menu = document.getElementById('dropdownMenu');
menu.classList.toggle('hidden');