后台独立登录页面,并加入权限控制。

This commit is contained in:
2026-08-11 18:04:02 +08:00
parent 808f2e7523
commit a1d57d6b44
12 changed files with 638 additions and 178 deletions
+105
View File
@@ -0,0 +1,105 @@
package handlers
import (
"html/template"
"log/slog"
"net/http"
"sync"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// adminTemplatePath 后台单页模板(含 {{define}} 分块,按权限渲染)
const adminTemplatePath = "./static/admin/index.html"
// AdminPageData 后台页面模板数据
type AdminPageData struct {
Menus []AdminMenuGroup // 当前角色可见菜单(登录接口同源)
PageSet map[string]bool // 菜单中的页面标识集合,模板据此渲染对应页面框架
DisplayName string // 侧边栏显示的用户名
}
var (
adminTmplOnce sync.Once
adminTmpl *template.Template
adminTmplErr error
)
// getAdminTemplate 懒加载后台模板(仅解析一次)
func getAdminTemplate() (*template.Template, error) {
adminTmplOnce.Do(func() {
adminTmpl, adminTmplErr = template.ParseFiles(adminTemplatePath)
if adminTmplErr != nil {
slog.Error("解析后台模板失败", "path", adminTemplatePath, "error", adminTmplErr)
}
})
return adminTmpl, adminTmplErr
}
// menuPageSet 从菜单提取页面标识集合
func menuPageSet(menus []AdminMenuGroup) map[string]bool {
set := make(map[string]bool)
for _, g := range menus {
for _, item := range g.Items {
set[item.Key] = true
}
}
return set
}
// AdminLoginView 独立登录页
func AdminLoginView(c *gin.Context) {
c.File("./static/admin/login.html")
}
// AdminView 后台管理页面:校验登录态后按角色用模板渲染菜单与页面框架
func AdminView(c *gin.Context) {
tokenString, err := c.Cookie("token")
if err != nil || tokenString == "" {
c.Redirect(http.StatusFound, "/admin/login")
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
c.Redirect(http.StatusFound, "/admin/login")
return
}
// 校验用户当前状态(禁用账号不允许进入后台)
var user models.User
if err := database.DB.First(&user, claims.UserID).Error; err != nil || user.Status == 0 || !user.IsActive {
c.Redirect(http.StatusFound, "/admin/login")
return
}
tmpl, tmplErr := getAdminTemplate()
if tmplErr != nil {
c.String(http.StatusInternalServerError, "后台模板加载失败")
return
}
// 菜单与登录接口保持同一来源,页面框架只渲染菜单内的页面
menus := GetMenusByRole(user.Role)
displayName := user.Nickname
if displayName == "" {
displayName = user.Username
}
data := AdminPageData{
Menus: menus,
PageSet: menuPageSet(menus),
DisplayName: displayName,
}
c.Header("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(c.Writer, "admin_index", data); err != nil {
slog.Error("渲染后台页面失败", "error", err)
}
}
+61
View File
@@ -18,6 +18,50 @@ func JWTSecret() []byte {
return jwtSecret
}
// AdminMenuItem 后台菜单项
type AdminMenuItem struct {
Key string `json:"key"` // 页面标识,与页面 div 的 data-page 对应
Name string `json:"name"` // 菜单显示名称
}
// AdminMenuGroup 后台菜单分组(Group 为空表示顶级菜单)
type AdminMenuGroup struct {
Group string `json:"group"`
Items []AdminMenuItem `json:"items"`
}
// GetMenusByRole 根据角色返回后台菜单
func GetMenusByRole(role string) []AdminMenuGroup {
if role == "admin" {
return []AdminMenuGroup{
{Group: "", Items: []AdminMenuItem{
{Key: "dashboard", Name: "仪表盘"},
}},
{Group: "内容管理", Items: []AdminMenuItem{
{Key: "posts", Name: "文章管理"},
{Key: "categories", Name: "分类管理"},
{Key: "tags", Name: "标签管理"},
{Key: "pages", Name: "页面管理"},
{Key: "comments", Name: "评论管理"},
}},
{Group: "系统管理", Items: []AdminMenuItem{
{Key: "users", Name: "用户管理"},
{Key: "files", Name: "文件管理"},
{Key: "themes", Name: "主题管理"},
{Key: "settings", Name: "基础设置"},
}},
}
}
// 普通用户:仪表盘 + 文章管理 + 评论管理
return []AdminMenuGroup{
{Group: "", Items: []AdminMenuItem{
{Key: "dashboard", Name: "仪表盘"},
{Key: "posts", Name: "文章管理"},
{Key: "comments", Name: "评论管理"},
}},
}
}
// 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
@@ -88,6 +132,9 @@ func Login(c *gin.Context) {
return
}
// 同时写入 Cookie,供服务端渲染后台页面时识别登录态
c.SetCookie("token", tokenString, 7*24*3600, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{
"token": tokenString,
"user": gin.H{
@@ -100,9 +147,23 @@ func Login(c *gin.Context) {
"last_login_at": user.LastLoginAt,
"login_count": user.LoginCount,
},
// 菜单由登录接口根据角色返回
"menus": GetMenusByRole(user.Role),
})
}
// 退出登录(清除服务端 Cookie)
func Logout(c *gin.Context) {
c.SetCookie("token", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "已退出登录"})
}
// WebLogout 前台页面退出登录(清除 Cookie 后回首页)
func WebLogout(c *gin.Context) {
c.SetCookie("token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/")
}
// 获取当前用户信息
func GetCurrentUser(c *gin.Context) {
userID, _ := c.Get("userID")
+38
View File
@@ -30,6 +30,14 @@ func GetComments(c *gin.Context) {
return db.Select("id", "title", "slug")
})
// 非管理员只能查看自己文章上的评论
if !isAdmin(c) {
if userID, exists := c.Get("userID"); exists {
db = db.Where("post_id IN (?)",
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
}
}
if postID != "" {
db = db.Where("post_id = ?", postID)
}
@@ -95,6 +103,21 @@ func CreateComment(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"data": comment})
}
// canManageComment 检查当前用户是否有权管理该评论(管理员始终可以,普通用户只能管理自己文章上的评论)
func canManageComment(c *gin.Context, comment *models.Comment) bool {
if isAdmin(c) {
return true
}
if userID, exists := c.Get("userID"); exists {
var post models.Post
if err := database.DB.Select("author_id").First(&post, comment.PostID).Error; err != nil {
return false
}
return post.AuthorID == userID.(uint)
}
return false
}
// 审核评论
func ApproveComment(c *gin.Context) {
id := c.Param("id")
@@ -105,6 +128,11 @@ func ApproveComment(c *gin.Context) {
return
}
if !canManageComment(c, &comment) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该评论"})
return
}
comment.Status = "approved"
if err := database.DB.Save(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "审核失败"})
@@ -124,6 +152,11 @@ func MarkSpamComment(c *gin.Context) {
return
}
if !canManageComment(c, &comment) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该评论"})
return
}
comment.Status = "spam"
if err := database.DB.Save(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
@@ -143,6 +176,11 @@ func DeleteComment(c *gin.Context) {
return
}
if !canManageComment(c, &comment) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该评论"})
return
}
if err := database.DB.Delete(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除评论失败"})
return
+39 -13
View File
@@ -82,22 +82,23 @@ func GetPosts(c *gin.Context) {
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author")
// 前端只显示已发布的文章
if !isAdmin(c) {
// 管理员可查看全部;登录用户可看到自己的全部文章(含草稿);未登录只能看已发布
if isAdmin(c) {
if query.Status != "" {
db = db.Where("status = ?", query.Status)
}
} else if userId, exists := c.Get("userID"); exists {
// 登录用户:只看自己的,不限状态(后台管理需要看到草稿)
db = db.Where("author_id = ?", userId)
} else {
// 未登录:只看已发布
db = db.Where("status = ?", "published")
} else if query.Status != "" {
db = db.Where("status = ?", query.Status)
}
if query.CategoryID > 0 {
db = db.Where("category_id = ?", query.CategoryID)
}
userId, exists := c.Get("userID")
if exists {
db = db.Where("author_id = ?", userId)
}
if query.TagID > 0 {
db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id").
Where("post_tags.tag_id = ?", query.TagID)
@@ -141,9 +142,13 @@ func GetPost(c *gin.Context) {
query = query.Where("slug = ?", id)
}
// 非管理员只能查看已发布文章
// 非管理员只能查看已发布文章,但可以查看自己的草稿(编辑用)
if !isAdmin(c) {
query = query.Where("status = ?", "published")
if userId, exists := c.Get("userID"); exists {
query = query.Where("status = ? OR author_id = ?", "published", userId)
} else {
query = query.Where("status = ?", "published")
}
}
if err := query.First(&post).Error; err != nil {
@@ -176,7 +181,7 @@ func CreatePost(c *gin.Context) {
AuthorID: userID.(uint),
CategoryID: req.CategoryID,
Status: req.Status,
IsTop: req.IsTop,
IsTop: req.IsTop && isAdmin(c), // 非管理员不允许置顶
}
if req.Status == "published" {
@@ -216,6 +221,15 @@ func UpdatePost(c *gin.Context) {
return
}
// 非管理员只能编辑自己的文章
if !isAdmin(c) {
userID, _ := c.Get("userID")
if post.AuthorID != userID.(uint) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权编辑他人的文章"})
return
}
}
var req UpdatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -251,7 +265,10 @@ func UpdatePost(c *gin.Context) {
now := time.Now()
updates["published_at"] = &now
}
updates["is_top"] = req.IsTop
// 非管理员不允许置顶
if isAdmin(c) {
updates["is_top"] = req.IsTop
}
// 处理标签
if len(req.Tags) > 0 {
@@ -288,6 +305,15 @@ func DeletePost(c *gin.Context) {
return
}
// 非管理员只能删除自己的文章
if !isAdmin(c) {
userID, _ := c.Get("userID")
if post.AuthorID != userID.(uint) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除他人的文章"})
return
}
}
if err := database.DB.Delete(&post).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文章失败"})
return
+23
View File
@@ -25,6 +25,16 @@ func loadSidebarData() (categories []models.Category, tags []models.Tag, postCou
return
}
// loginInfo 从上下文提取登录态信息(供侧边栏系统菜单区分已登录/未登录)
func loginInfo(c *gin.Context) (isLoggedIn bool, loginName string) {
if _, ok := c.Get("userID"); !ok {
return false, ""
}
name, _ := c.Get("username")
loginName, _ = name.(string)
return true, loginName
}
// 首页
type IndexData struct {
Title string
@@ -42,6 +52,9 @@ type IndexData struct {
PostCount int64
CategoryCount int64
TagCount int64
// 登录态(侧边栏系统菜单用)
IsLoggedIn bool
LoginName string
}
func IndexView(c *gin.Context) {
@@ -129,6 +142,7 @@ func IndexView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
c.HTML(http.StatusOK, "index", data)
}
@@ -156,6 +170,9 @@ type PostDetailData struct {
PostCount int64
CategoryCount int64
TagCount int64
// 登录态(侧边栏系统菜单用)
IsLoggedIn bool
LoginName string
}
func PostView(c *gin.Context) {
@@ -280,6 +297,7 @@ func PostView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
// 只有当查询成功时才赋值
if prevErr == nil {
@@ -307,6 +325,9 @@ type PageDetailData struct {
PostCount int64
CategoryCount int64
TagCount int64
// 登录态(侧边栏系统菜单用)
IsLoggedIn bool
LoginName string
}
func PageView(c *gin.Context) {
@@ -359,6 +380,7 @@ func PageView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
c.HTML(http.StatusOK, "page", data)
}
@@ -464,6 +486,7 @@ func SearchView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
c.HTML(http.StatusOK, "index", data)
}