handlers区分backend和frontend,需要确认。

This commit is contained in:
2026-08-12 17:57:31 +08:00
parent 60869ec214
commit 42c6f2450d
20 changed files with 242 additions and 137 deletions
+108
View File
@@ -0,0 +1,108 @@
package backend
import (
"encoding/json"
"html/template"
"log/slog"
"net/http"
"sync"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
)
// adminTemplatePath 后台单页模板(含 {{define}} 分块,按权限渲染)
const adminTemplatePath = "./static/admin/index.html"
// AdminPageData 后台页面模板数据
type AdminPageData struct {
Menus []AdminMenuGroup // 当前角色可见菜单(登录接口同源)
PageSet map[string]bool // 菜单中的页面标识集合,模板据此渲染对应页面框架
DisplayName string // 侧边栏显示的用户名
CurrentUser template.JS // JSON 序列化的当前用户信息(id/username/nickname/role),注入前端
}
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 后台管理页面:校验登录态后按角色用模板渲染菜单与页面框架
// 依赖 OptionalAuth 中间件注入 userID/role 等上下文
func AdminView(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.Redirect(http.StatusFound, "/admin/login")
return
}
// 校验用户当前状态(禁用账号不允许进入后台)
var user models.User
if err := database.DB.First(&user, 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
}
// 将用户基本信息注入前端,避免依赖 localStorage
currentUserJSON, _ := json.Marshal(map[string]interface{}{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"role": user.Role,
})
data := AdminPageData{
Menus: menus,
PageSet: menuPageSet(menus),
DisplayName: displayName,
CurrentUser: template.JS(currentUserJSON),
}
c.Header("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(c.Writer, "admin_index", data); err != nil {
slog.Error("渲染后台页面失败", "error", err)
}
}
+256
View File
@@ -0,0 +1,256 @@
package backend
import (
"log/slog"
"net/http"
"time"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
var jwtSecret = []byte("your-secret-key-change-in-production")
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"`
Password string `json:"password" binding:"required"`
}
// 注册请求
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"`
Email string `json:"email" binding:"required,email"`
}
// JWT Claims
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// 登录
func Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if err := database.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
if user.Status == 0 || !user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁用"})
return
}
// 使用 User 模型的 CheckPassword 方法
if !user.CheckPassword(req.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
// 记录登录信息
if err := user.RecordLogin(database.DB); err != nil {
slog.Warn("记录登录信息失败", "user_id", user.ID, "error", err)
}
// 生成 JWT
claims := Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成令牌失败"})
return
}
// 同时写入 Cookie,供服务端渲染后台页面时识别登录态
c.SetCookie("token", tokenString, 7*24*3600, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{
"token": tokenString,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"email": user.Email,
"role": user.Role,
"avatar": user.Avatar,
"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")
var user models.User
if err := database.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"data": user})
}
// 更新用户信息
func UpdateUser(c *gin.Context) {
userID, _ := c.Get("userID")
var user models.User
if err := database.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
var req struct {
Nickname string `json:"nickname"`
Email string `json:"email"`
Avatar string `json:"avatar"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]interface{}{}
if req.Nickname != "" {
updates["nickname"] = req.Nickname
}
if req.Email != "" {
updates["email"] = req.Email
}
if req.Avatar != "" {
updates["avatar"] = req.Avatar
}
if err := database.DB.Model(&user).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
return
}
c.JSON(http.StatusOK, gin.H{"data": user})
}
// 修改密码
func ChangePassword(c *gin.Context) {
userID, _ := c.Get("userID")
var user models.User
if err := database.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
var req struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 使用 User 模型的 CheckPassword 方法
if !user.CheckPassword(req.OldPassword) {
c.JSON(http.StatusBadRequest, gin.H{"error": "原密码错误"})
return
}
// 使用 User 模型的 SetPassword 方法
if err := user.SetPassword(req.NewPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
if err := database.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "修改密码失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
}
+125
View File
@@ -0,0 +1,125 @@
package backend
import (
"net/http"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
)
// 创建分类请求
type CreateCategoryRequest struct {
Name string `json:"name" binding:"required"`
Slug string `json:"slug"`
Description string `json:"description"`
ParentID *uint `json:"parent_id"`
}
// 获取分类列表
func GetCategories(c *gin.Context) {
var categories []models.Category
database.DB.Order("id ASC").Find(&categories)
c.JSON(http.StatusOK, gin.H{"data": categories})
}
// 获取单个分类
func GetCategory(c *gin.Context) {
id := c.Param("id")
var category models.Category
if err := database.DB.First(&category, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"data": category})
}
// 创建分类
func CreateCategory(c *gin.Context) {
var req CreateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
slug := req.Slug
if slug == "" {
slug = generateSlug(req.Name)
}
category := models.Category{
Name: req.Name,
Slug: slug,
Description: req.Description,
ParentID: req.ParentID,
}
if err := database.DB.Create(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建分类失败"})
return
}
c.JSON(http.StatusCreated, gin.H{"data": category})
}
// 更新分类
func UpdateCategory(c *gin.Context) {
id := c.Param("id")
var category models.Category
if err := database.DB.First(&category, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"})
return
}
var req CreateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]interface{}{
"name": req.Name,
"description": req.Description,
"parent_id": req.ParentID,
}
if req.Slug != "" {
updates["slug"] = req.Slug
}
if err := database.DB.Model(&category).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新分类失败"})
return
}
c.JSON(http.StatusOK, gin.H{"data": category})
}
// 删除分类
func DeleteCategory(c *gin.Context) {
id := c.Param("id")
var category models.Category
if err := database.DB.First(&category, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"})
return
}
// 检查是否有文章使用此分类
var count int64
database.DB.Model(&models.Post{}).Where("category_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "该分类下还有文章,无法删除"})
return
}
if err := database.DB.Delete(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除分类失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
+184
View File
@@ -0,0 +1,184 @@
package backend
import (
"net/http"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 创建评论请求
type CreateCommentRequest struct {
PostID uint `json:"post_id" binding:"required"`
ParentID *uint `json:"parent_id"`
Author string `json:"author" binding:"required"`
Email string `json:"email" binding:"required,email"`
Website string `json:"website"`
Content string `json:"content" binding:"required"`
}
// 获取评论列表
func GetComments(c *gin.Context) {
postID := c.Query("post_id")
status := c.Query("status")
db := database.DB.Model(&models.Comment{}).Preload("User").
Preload("Post", func(db *gorm.DB) *gorm.DB {
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)
}
if status != "" {
db = db.Where("status = ?", status)
}
var comments []models.Comment
db.Order("created_at DESC").Find(&comments)
// Comment.Post 在 JSON 中被隐藏,额外附加文章标题和别名供前端展示
type commentWithPost struct {
models.Comment
PostTitle string `json:"post_title"`
PostSlug string `json:"post_slug"`
}
list := make([]commentWithPost, 0, len(comments))
for _, cm := range comments {
list = append(list, commentWithPost{cm, cm.Post.Title, cm.Post.Slug})
}
c.JSON(http.StatusOK, gin.H{"data": list})
}
// 创建评论
func CreateComment(c *gin.Context) {
var req CreateCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查文章是否存在
var post models.Post
if err := database.DB.First(&post, req.PostID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
return
}
comment := models.Comment{
PostID: req.PostID,
ParentID: req.ParentID,
Author: req.Author,
Email: req.Email,
Website: req.Website,
Content: req.Content,
IP: c.ClientIP(),
Status: "pending", // 默认待审核
}
// 如果用户已登录
if userID, exists := c.Get("userID"); exists {
uid := userID.(uint)
comment.UserID = &uid
comment.Status = "approved" // 登录用户评论自动通过
}
if err := database.DB.Create(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发表评论失败"})
return
}
c.JSON(http.StatusCreated, gin.H{"data": comment})
}
// loadAndVerifyComment 加载评论并校验权限,成功返回评论,失败直接写响应并返回 nil
func loadAndVerifyComment(c *gin.Context) *models.Comment {
id := c.Param("id")
var comment models.Comment
if err := database.DB.First(&comment, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
return nil
}
if !canManageComment(c, &comment) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该评论"})
return nil
}
return &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) {
comment := loadAndVerifyComment(c)
if comment == nil {
return
}
comment.Status = "approved"
if err := database.DB.Save(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "审核失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "审核通过"})
}
// 标记为垃圾评论
func MarkSpamComment(c *gin.Context) {
comment := loadAndVerifyComment(c)
if comment == nil {
return
}
comment.Status = "spam"
if err := database.DB.Save(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已标记为垃圾评论"})
}
// 删除评论
func DeleteComment(c *gin.Context) {
comment := loadAndVerifyComment(c)
if comment == nil {
return
}
if err := database.DB.Delete(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除评论失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
+43
View File
@@ -0,0 +1,43 @@
package backend
import (
"net/http"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
)
// GetDashboardStats 返回仪表盘统计数字(按角色过滤:管理员统计全部,普通用户统计自己的)
func GetDashboardStats(c *gin.Context) {
// 文章数:管理员统计全部,普通用户统计自己的(与文章管理页可见范围一致)
postQuery := database.DB.Model(&models.Post{})
if !isAdmin(c) {
userID, _ := c.Get("userID")
postQuery = postQuery.Where("author_id = ?", userID)
}
var postCount int64
postQuery.Count(&postCount)
var categoryCount, tagCount int64
database.DB.Model(&models.Category{}).Count(&categoryCount)
database.DB.Model(&models.Tag{}).Count(&tagCount)
// 待审核评论数:管理员统计全部,普通用户统计自己文章上的
commentQuery := database.DB.Model(&models.Comment{}).Where("status = ?", "pending")
if !isAdmin(c) {
userID, _ := c.Get("userID")
commentQuery = commentQuery.Where("post_id IN (?)",
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
}
var pendingCommentCount int64
commentQuery.Count(&pendingCommentCount)
c.JSON(http.StatusOK, gin.H{
"posts": postCount,
"categories": categoryCount,
"tags": tagCount,
"pending_comments": pendingCommentCount,
})
}
+145
View File
@@ -0,0 +1,145 @@
package backend
import (
"net/http"
"strconv"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
)
// 获取页面列表
func GetPages(c *gin.Context) {
var pages []models.Page
database.DB.Where("status = ?", "published").Order("`order` ASC").Find(&pages)
c.JSON(http.StatusOK, gin.H{"data": pages})
}
// 获取单个页面
func GetPage(c *gin.Context) {
id := c.Param("id")
var page models.Page
query := database.DB
if _, err := strconv.Atoi(id); err == nil {
query = query.Where("id = ?", id)
} else {
query = query.Where("slug = ?", id)
}
// 非管理员只能查看已发布页面
if !isAdmin(c) {
query = query.Where("status = ?", "published")
}
if err := query.First(&page).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"data": page})
}
// 创建页面
func CreatePage(c *gin.Context) {
var req struct {
Title string `json:"title" binding:"required"`
Slug string `json:"slug"`
Content string `json:"content" binding:"required"`
Status string `json:"status"`
Order int `json:"order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("userID")
slug := req.Slug
if slug == "" {
slug = generateSlug(req.Title)
}
page := models.Page{
Title: req.Title,
Slug: slug,
Content: req.Content,
AuthorID: userID.(uint),
Status: req.Status,
Order: req.Order,
}
if err := database.DB.Create(&page).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建页面失败"})
return
}
c.JSON(http.StatusCreated, gin.H{"data": page})
}
// 更新页面
func UpdatePage(c *gin.Context) {
id := c.Param("id")
var page models.Page
if err := database.DB.First(&page, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
var req struct {
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content"`
Status string `json:"status"`
Order int `json:"order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]interface{}{}
if req.Title != "" {
updates["title"] = req.Title
}
if req.Slug != "" {
updates["slug"] = req.Slug
}
if req.Content != "" {
updates["content"] = req.Content
}
if req.Status != "" {
updates["status"] = req.Status
}
updates["order"] = req.Order
if err := database.DB.Model(&page).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新页面失败"})
return
}
c.JSON(http.StatusOK, gin.H{"data": page})
}
// 删除页面
func DeletePage(c *gin.Context) {
id := c.Param("id")
var page models.Page
if err := database.DB.First(&page, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
if err := database.DB.Delete(&page).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除页面失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
+334
View File
@@ -0,0 +1,334 @@
package backend
import (
"net/http"
"strconv"
"strings"
"time"
"goblog/database"
"goblog/models"
"goblog/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 文章列表请求参数
type PostListQuery struct {
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=10"`
CategoryID uint `form:"category_id"`
TagID uint `form:"tag_id"`
Status string `form:"status"`
Keyword string `form:"keyword"`
}
// 创建文章请求
type CreatePostRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Summary string `json:"summary"`
Cover string `json:"cover"`
CategoryID uint `json:"category_id" binding:"required"`
Tags []string `json:"tags"`
Status string `json:"status"`
IsTop bool `json:"is_top"`
}
// 更新文章请求
type UpdatePostRequest struct {
Title string `json:"title"`
Content string `json:"content"`
Summary string `json:"summary"`
Cover string `json:"cover"`
CategoryID uint `json:"category_id"`
Tags []string `json:"tags"`
Status string `json:"status"`
IsTop bool `json:"is_top"`
}
// Markdown 预览请求
type MarkdownPreviewRequest struct {
Content string `json:"content"`
}
// PreviewMarkdown 将 Markdown 渲染为 HTML(后台编辑器预览用)
func PreviewMarkdown(c *gin.Context) {
var req MarkdownPreviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"html": string(utils.RenderMarkdown(req.Content))})
}
// 生成 slug
func generateSlug(title string) string {
slug := strings.ToLower(title)
slug = strings.ReplaceAll(slug, " ", "-")
slug = strings.ReplaceAll(slug, "_", "-")
// 简化处理,实际项目中可能需要更完善的 slug 生成
return slug
}
// findOrCreateTags 根据名称列表查找或创建标签
func findOrCreateTags(tagNames []string) []models.Tag {
var tags []models.Tag
for _, tagName := range tagNames {
var tag models.Tag
database.DB.FirstOrCreate(&tag, models.Tag{
Name: tagName,
Slug: generateSlug(tagName),
})
tags = append(tags, tag)
}
return tags
}
// 获取文章列表
func GetPosts(c *gin.Context) {
var query PostListQuery
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 分页参数校验
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 || query.PageSize > 100 {
query.PageSize = 10
}
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author")
// 管理员可查看全部;登录用户可看到自己的全部文章(含草稿);未登录只能看已发布
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")
}
if query.CategoryID > 0 {
db = db.Where("category_id = ?", query.CategoryID)
}
if query.TagID > 0 {
db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id").
Where("post_tags.tag_id = ?", query.TagID)
}
if query.Keyword != "" {
db = db.Where("title LIKE ? OR content LIKE ?", "%"+query.Keyword+"%", "%"+query.Keyword+"%")
}
var total int64
db.Count(&total)
var posts []models.Post
offset := (query.Page - 1) * query.PageSize
db.Order("is_top DESC, published_at DESC, created_at DESC").
Offset(offset).Limit(query.PageSize).Find(&posts)
c.JSON(http.StatusOK, gin.H{
"data": posts,
"total": total,
"page": query.Page,
"size": query.PageSize,
})
}
// 获取单篇文章
func GetPost(c *gin.Context) {
id := c.Param("id")
var post models.Post
query := database.DB.Preload("Category").Preload("Tags").Preload("Author").Preload("Comments", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ? AND parent_id IS NULL", "approved").Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "approved")
})
})
// 尝试按 ID 或 slug 查找
if _, err := strconv.Atoi(id); err == nil {
query = query.Where("id = ?", id)
} else {
query = query.Where("slug = ?", id)
}
// 非管理员只能查看已发布文章,但可以查看自己的草稿(编辑用)
if !isAdmin(c) {
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
return
}
// 增加浏览量
database.DB.Model(&post).UpdateColumn("views", gorm.Expr("views + 1"))
c.JSON(http.StatusOK, gin.H{"data": post})
}
// 创建文章
func CreatePost(c *gin.Context) {
var req CreatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("userID")
post := models.Post{
Title: req.Title,
Slug: generateSlug(req.Title),
Content: req.Content,
Summary: req.Summary,
Cover: req.Cover,
AuthorID: userID.(uint),
CategoryID: req.CategoryID,
Status: req.Status,
IsTop: req.IsTop && isAdmin(c), // 非管理员不允许置顶
}
if req.Status == "published" {
now := time.Now()
post.PublishedAt = &now
}
// 处理标签
if len(req.Tags) > 0 {
post.Tags = findOrCreateTags(req.Tags)
}
if err := database.DB.Create(&post).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文章失败"})
return
}
c.JSON(http.StatusCreated, gin.H{"data": post})
}
// 更新文章
func UpdatePost(c *gin.Context) {
id := c.Param("id")
var post models.Post
if err := database.DB.First(&post, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
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()})
return
}
updates := map[string]interface{}{}
if req.Title != "" {
updates["title"] = req.Title
updates["slug"] = generateSlug(req.Title)
}
if req.Content != "" {
updates["content"] = req.Content
}
if req.Summary != "" {
updates["summary"] = req.Summary
}
if req.Cover != "" {
updates["cover"] = req.Cover
}
if req.CategoryID > 0 {
updates["category_id"] = req.CategoryID
}
if req.Status != "" {
updates["status"] = req.Status
if req.Status == "published" && post.Status != "published" {
now := time.Now()
updates["published_at"] = &now
}
}
// 如果文章是已发布状态但没有发布时间,设置发布时间
if post.Status == "published" && post.PublishedAt == nil {
now := time.Now()
updates["published_at"] = &now
}
// 非管理员不允许置顶
if isAdmin(c) {
updates["is_top"] = req.IsTop
}
// 处理标签
if len(req.Tags) > 0 {
tags := findOrCreateTags(req.Tags)
if err := database.DB.Model(&post).Association("Tags").Replace(tags); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新文章标签失败"})
return
}
}
if err := database.DB.Model(&post).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新文章失败"})
return
}
c.JSON(http.StatusOK, gin.H{"data": post})
}
// 删除文章
func DeletePost(c *gin.Context) {
id := c.Param("id")
var post models.Post
if err := database.DB.First(&post, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文章不存在"})
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
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
// 判断是否为管理员
func isAdmin(c *gin.Context) bool {
role, exists := c.Get("role")
return exists && role == "admin"
}
+72
View File
@@ -0,0 +1,72 @@
package backend
import (
"goblog/config"
"goblog/database"
"goblog/models"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// GetSettings 获取站点基础设置(管理员)
func GetSettings(c *gin.Context) {
cfg := config.GetConfig()
opts, err := models.GetOptionsByUser(database.DB, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取设置失败"})
return
}
// 默认值:站点信息取配置文件,评论默认显示、每页 10 条
settings := map[string]string{
models.OptionSiteName: cfg.App.Name,
models.OptionSiteDesc: cfg.App.Description,
models.OptionShowComments: "1",
models.OptionCommentPageSize: "10",
}
for name := range settings {
if v, ok := opts[name]; ok && v != "" {
settings[name] = v
}
}
c.JSON(http.StatusOK, gin.H{"data": settings})
}
// UpdateSettingsRequest 更新站点基础设置请求
type UpdateSettingsRequest struct {
SiteName string `json:"site_name" binding:"required"`
SiteDesc string `json:"site_desc"`
ShowComments bool `json:"show_comments"`
CommentsPerPage int `json:"comments_per_page" binding:"required,min=1,max=100"`
}
// UpdateSettings 保存站点基础设置(管理员)
func UpdateSettings(c *gin.Context) {
var req UpdateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
return
}
showComments := "0"
if req.ShowComments {
showComments = "1"
}
options := map[string]string{
models.OptionSiteName: req.SiteName,
models.OptionSiteDesc: req.SiteDesc,
models.OptionShowComments: showComments,
models.OptionCommentPageSize: strconv.Itoa(req.CommentsPerPage),
}
if err := models.BatchSetOptions(database.DB, 0, options); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存设置失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "设置已保存"})
}
+112
View File
@@ -0,0 +1,112 @@
package backend
import (
"net/http"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
)
// 获取标签列表
func GetTags(c *gin.Context) {
var tags []models.Tag
database.DB.Order("post_count DESC").Find(&tags)
c.JSON(http.StatusOK, gin.H{"data": tags})
}
// 获取单个标签
func GetTag(c *gin.Context) {
id := c.Param("id")
var tag models.Tag
if err := database.DB.First(&tag, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"data": tag})
}
// 创建标签
func CreateTag(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Slug string `json:"slug"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
slug := req.Slug
if slug == "" {
slug = generateSlug(req.Name)
}
tag := models.Tag{
Name: req.Name,
Slug: slug,
}
if err := database.DB.Create(&tag).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建标签失败"})
return
}
c.JSON(http.StatusCreated, gin.H{"data": tag})
}
// 更新标签
func UpdateTag(c *gin.Context) {
id := c.Param("id")
var tag models.Tag
if err := database.DB.First(&tag, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"})
return
}
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]interface{}{}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Slug != "" {
updates["slug"] = req.Slug
}
if err := database.DB.Model(&tag).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新标签失败"})
return
}
c.JSON(http.StatusOK, gin.H{"data": tag})
}
// 删除标签
func DeleteTag(c *gin.Context) {
id := c.Param("id")
var tag models.Tag
if err := database.DB.First(&tag, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"})
return
}
if err := database.DB.Delete(&tag).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除标签失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
+315
View File
@@ -0,0 +1,315 @@
package backend
import (
"net/http"
"os"
"path/filepath"
"goblog/database"
"goblog/models"
"goblog/utils"
"github.com/gin-gonic/gin"
)
// 获取可用主题列表
func GetThemes(c *gin.Context) {
themes := []string{}
// 读取 templates 目录
entries, err := os.ReadDir("templates")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "无法读取主题目录",
})
return
}
for _, entry := range entries {
if entry.IsDir() {
// 检查是否是有效主题(包含 base.html)
basePath := filepath.Join("templates", entry.Name(), "base.html")
if _, err := os.Stat(basePath); err == nil {
themes = append(themes, entry.Name())
}
}
}
c.JSON(http.StatusOK, gin.H{
"themes": themes,
})
}
// 获取当前主题
func GetCurrentTheme(c *gin.Context) {
currentTheme := "default"
// 使用新的 Option 查询方法
theme, err := models.GetOptionValue(database.DB, "theme", 0)
if err == nil && theme != "" {
currentTheme = theme
}
c.JSON(http.StatusOK, gin.H{
"theme": currentTheme,
})
}
// 切换主题
func SwitchTheme(c *gin.Context) {
type Request struct {
Theme string `json:"theme" binding:"required"`
}
var req Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "请提供主题名称",
})
return
}
// 验证主题是否存在
themePath := filepath.Join("templates", req.Theme)
if _, err := os.Stat(themePath); os.IsNotExist(err) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "主题不存在",
})
return
}
// 验证主题是否有效
basePath := filepath.Join(themePath, "base.html")
if _, err := os.Stat(basePath); os.IsNotExist(err) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "主题文件不完整",
})
return
}
// 更新数据库中的主题设置
err := models.SetOptionValue(database.DB, "theme", 0, req.Theme)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存主题设置失败"})
return
}
// 重新加载模板(立即生效)
renderer := utils.GetGlobalRenderer()
if renderer != nil {
renderer.Reload(req.Theme)
}
c.JSON(http.StatusOK, gin.H{
"message": "主题切换成功",
"theme": req.Theme,
})
}
// 创建新主题
func CreateTheme(c *gin.Context) {
type Request struct {
Name string `json:"name" binding:"required"`
Template string `json:"template"`
}
var req Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "请提供主题名称",
})
return
}
// 验证主题名称(只能包含字母、数字和下划线)
validName := true
for _, ch := range req.Name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') {
validName = false
break
}
}
if !validName || req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "主题名称只能包含字母、数字和下划线",
})
return
}
// 检查主题是否已存在
themePath := filepath.Join("templates", req.Name)
if _, err := os.Stat(themePath); err == nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "主题已存在",
})
return
}
// 创建主题目录
if err := os.MkdirAll(themePath, 0700); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "创建主题目录失败",
})
return
}
// 创建新主题时必须选择已有主题作为模板
if req.Template == "" || req.Template == "blank" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "必须选择一个已有主题作为模板",
})
return
}
// 验证所选模板是否存在并复制模板文件
templateSrc := filepath.Join("templates", req.Template)
if _, err := os.Stat(templateSrc); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "所选模板不存在",
})
return
}
// 复制模板文件
files := []string{"base.html", "index.html", "post.html", "page.html"}
for _, file := range files {
srcFile := filepath.Join(templateSrc, file)
dstFile := filepath.Join(themePath, file)
if data, err := os.ReadFile(srcFile); err == nil {
os.WriteFile(dstFile, data, 0644)
}
}
// 复制模板的 CSS 文件
templateCSS := filepath.Join("static", "css", req.Template+".css")
if data, err := os.ReadFile(templateCSS); err == nil {
dstCSS := filepath.Join("static", "css", req.Name+".css")
os.MkdirAll(filepath.Dir(dstCSS), 0755)
os.WriteFile(dstCSS, data, 0644)
}
c.JSON(http.StatusOK, gin.H{
"message": "主题创建成功",
"theme": req.Name,
})
}
// 获取主题文件列表
func GetThemeFiles(c *gin.Context) {
themeName := c.Param("theme")
themePath := filepath.Join("templates", themeName)
if _, err := os.Stat(themePath); os.IsNotExist(err) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "主题不存在",
})
return
}
files := []string{}
// 读取模板文件
entries, err := os.ReadDir(themePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "读取文件列表失败",
})
return
}
for _, entry := range entries {
if !entry.IsDir() {
ext := filepath.Ext(entry.Name())
if ext == ".html" || ext == ".css" || ext == ".js" {
files = append(files, entry.Name())
}
}
}
// 检查是否有 CSS 文件
cssPath := filepath.Join("static", "css", themeName+".css")
if _, err := os.Stat(cssPath); err == nil {
files = append(files, themeName+".css")
}
c.JSON(http.StatusOK, gin.H{
"files": files,
})
}
// resolveThemeFilePath 根据主题名和文件名解析实际文件路径
func resolveThemeFilePath(themeName, filename string) string {
if filename == themeName+".css" {
return filepath.Join("static", "css", filename)
}
return filepath.Join("templates", themeName, filename)
}
// 获取主题文件内容
func GetThemeFile(c *gin.Context) {
themeName := c.Param("theme")
filename := c.Param("file")
filePath := resolveThemeFilePath(themeName, filename)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "文件不存在",
})
return
}
content, err := os.ReadFile(filePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "读取文件失败",
})
return
}
c.JSON(http.StatusOK, gin.H{
"content": string(content),
})
}
// 保存主题文件
func SaveThemeFile(c *gin.Context) {
themeName := c.Param("theme")
filename := c.Param("file")
type Request struct {
Content string `json:"content"`
}
var req Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "请提供文件内容",
})
return
}
filePath := resolveThemeFilePath(themeName, filename)
// 保存文件
if err := os.WriteFile(filePath, []byte(req.Content), 0600); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "保存文件失败",
})
return
}
// 如果是模板文件且是当前使用的主题,重新加载
if filename != themeName+".css" {
currentTheme, err := models.GetOptionValue(database.DB, "theme", 0)
if err == nil && currentTheme == themeName {
renderer := utils.GetGlobalRenderer()
if renderer != nil {
renderer.Reload(themeName)
}
}
}
c.JSON(http.StatusOK, gin.H{
"message": "文件保存成功",
})
}
+217
View File
@@ -0,0 +1,217 @@
package backend
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
// uploadDir 上传文件保存目录
uploadDir = "./static/uploads"
// uploadURLPrefix 上传文件对外访问的 URL 前缀
uploadURLPrefix = "/static/uploads/"
// maxUploadSize 单个文件最大大小(5MB)
maxUploadSize = 5 << 20
)
// allowedImageExts 允许上传的图片扩展名
var allowedImageExts = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
}
// allowedImageMIMEs 允许上传的图片真实 MIME 类型
var allowedImageMIMEs = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
}
// UploadedFile 上传文件信息
type UploadedFile struct {
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
ModTime string `json:"mod_time"`
}
// sanitizeBaseName 清理文件名(去除扩展名和不安全字符)
func sanitizeBaseName(filename string) string {
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
base = strings.ReplaceAll(base, " ", "-")
var b strings.Builder
for _, r := range base {
if r == '-' || r == '_' ||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
res := b.String()
if res == "" {
res = "image"
}
if len(res) > 40 {
res = res[:40]
}
return res
}
// UploadImage 上传图片(校验格式与大小)
func UploadImage(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"})
return
}
// 校验文件大小
if file.Size > maxUploadSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("文件大小超过限制(最大 %d MB)", maxUploadSize>>20),
})
return
}
// 校验扩展名
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedImageExts[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的图片格式,仅支持 jpg/jpeg/png/gif/webp"})
return
}
// 打开文件并检测真实 MIME 类型(防止伪造扩展名)
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
return
}
defer src.Close()
head := make([]byte, 512)
n, _ := src.Read(head)
contentType := http.DetectContentType(head[:n])
if !allowedImageMIMEs[contentType] {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件内容不是有效的图片"})
return
}
// 重置读取位置,准备写入
if _, err := src.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
return
}
// 确保上传目录存在
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"})
return
}
// 生成唯一文件名:时间戳_原始名.扩展名
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitizeBaseName(file.Filename), ext)
dstPath := filepath.Join(uploadDir, filename)
dst, err := os.Create(dstPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"url": uploadURLPrefix + filename,
"name": filename,
"size": file.Size,
})
}
// ListUploads 列出已上传的图片
func ListUploads(c *gin.Context) {
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"})
return
}
entries, err := os.ReadDir(uploadDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"})
return
}
files := make([]UploadedFile, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if !allowedImageExts[ext] {
continue
}
info, err := e.Info()
if err != nil {
continue
}
files = append(files, UploadedFile{
Name: e.Name(),
URL: uploadURLPrefix + e.Name(),
Size: info.Size(),
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
})
}
// 按修改时间倒序(最新的在前)
sort.Slice(files, func(i, j int) bool {
return files[i].ModTime > files[j].ModTime
})
c.JSON(http.StatusOK, gin.H{"data": files})
}
// DeleteUpload 删除已上传的图片
func DeleteUpload(c *gin.Context) {
name := c.Param("name")
// 防止路径穿越
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件名"})
return
}
ext := strings.ToLower(filepath.Ext(name))
if !allowedImageExts[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件类型"})
return
}
path := filepath.Join(uploadDir, name)
if _, err := os.Stat(path); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
if err := os.Remove(path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
+255
View File
@@ -0,0 +1,255 @@
package backend
import (
"net/http"
"strconv"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
)
// 创建用户请求
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"`
Email string `json:"email" binding:"required,email"`
Role string `json:"role"`
Status *int `json:"status"`
}
// 更新用户请求
type AdminUpdateUserRequest struct {
Nickname string `json:"nickname"`
Email string `json:"email"`
Role string `json:"role"`
Status *int `json:"status"`
}
// 重置密码请求
type ResetPasswordRequest struct {
NewPassword string `json:"new_password" binding:"required,min=6"`
}
// 获取用户列表(管理员)
func GetUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
keyword := c.Query("keyword")
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
query := database.DB.Model(&models.User{})
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", like, like, like)
}
var total int64
query.Count(&total)
var users []models.User
if err := query.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": users,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// 创建用户(管理员)
func CreateUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查用户名是否已存在
var count int64
database.DB.Model(&models.User{}).Where("username = ?", req.Username).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"})
return
}
// 检查邮箱是否已存在
database.DB.Model(&models.User{}).Where("email = ?", req.Email).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱已被注册"})
return
}
role := req.Role
if role != "admin" {
role = "user"
}
status := 1
if req.Status != nil {
status = *req.Status
}
user := models.User{
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
Role: role,
Status: status,
IsActive: true,
}
if err := user.SetPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
if err := database.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"})
return
}
c.JSON(http.StatusCreated, gin.H{"data": user})
}
// 更新用户信息(管理员)
func AdminUpdateUser(c *gin.Context) {
id := c.Param("id")
operatorID, _ := c.Get("userID")
var user models.User
if err := database.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
var req AdminUpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
isSelf := operatorID == user.ID
updates := map[string]interface{}{
"nickname": req.Nickname,
}
if req.Email != "" && req.Email != user.Email {
// 检查邮箱是否已被其他用户使用
var count int64
database.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, user.ID).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱已被其他用户使用"})
return
}
updates["email"] = req.Email
}
if req.Role != "" && req.Role != user.Role {
if isSelf {
c.JSON(http.StatusBadRequest, gin.H{"error": "不能修改自己的角色"})
return
}
if req.Role != "admin" && req.Role != "user" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的角色"})
return
}
updates["role"] = req.Role
}
if req.Status != nil && *req.Status != user.Status {
if isSelf {
c.JSON(http.StatusBadRequest, gin.H{"error": "不能禁用自己的账号"})
return
}
updates["status"] = *req.Status
}
if err := database.DB.Model(&user).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新用户失败"})
return
}
c.JSON(http.StatusOK, gin.H{"data": user})
}
// 重置用户密码(管理员)
func AdminResetPassword(c *gin.Context) {
id := c.Param("id")
var user models.User
if err := database.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
var req ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := user.SetPassword(req.NewPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
if err := database.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "密码重置成功"})
}
// 删除用户(管理员)
func DeleteUser(c *gin.Context) {
id := c.Param("id")
operatorID, _ := c.Get("userID")
var user models.User
if err := database.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
if operatorID == user.ID {
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除自己的账号"})
return
}
// 检查该用户是否有文章
var count int64
database.DB.Model(&models.Post{}).Where("author_id = ?", user.ID).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "该用户名下还有文章,无法删除"})
return
}
// 检查该用户是否有页面
database.DB.Model(&models.Page{}).Where("author_id = ?", user.ID).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "该用户名下还有页面,无法删除"})
return
}
if err := database.DB.Delete(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}