Files
go_blog/handlers/article.go
T
kevin 5b227b71ef feat: 文章 CRUD 接口 JSON 化——/api/admin|my/articles
- article.go:articleForm 加 json tags,新增 parseArticleFormJSON(绑定+
  去白);ArticleCreate/ArticleUpdate/ArticleDelete 增加 redirectPath 参数,
  校验失败改 APIError 400、DB 错误 500,成功 {ok,redirect}(原 renderForm
  分支移除);id 改用 parseUintParam 数值化
- my_articles.go:MyArticleCreate 复用 ArticleCreate(db, /my/articles);
  MyArticleUpdate/MyArticleDelete 保留 author_id 所有权约束(404
  article_not_found);my 更新不触碰标签(表单无该字段,与旧行为一致)
- main.go:admin/my 文章旧 POST 路由移除,改 POST/PUT/DELETE
  /api/admin/articles[/:id] 与 /api/my/articles[/:id]
- 模板:article_create.html/my_article_form.html 表单改 blogAPI 提交
  (easyMDE.value() 同步正文、editor e.submitter 分流草稿/发布),错误内联
  articleError/myArticleError;article_list.html/my_articles.html 删除改
  blogDelete 委托(base.html 新公共函数,DELETE + reload 保筛选状态)
- main_test 冒烟补文章 CRUD 断言;go build/vet/test 全绿
2026-08-27 19:40:02 +08:00

493 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"go_blog/models"
"gorm.io/gorm"
)
// slugSepRe 匹配连续的非单词字符(任何非字母或数字的字符);
// 这些字符会被替换为单个连字符。
var slugSepRe = regexp.MustCompile(`[^\p{L}\p{N}]+`)
// slugDashRe 匹配连续连字符,用于折叠。
var slugDashRe = regexp.MustCompile(`-{2,}`)
// slugAsciiRe 匹配仅由 URL 安全的 ASCII 字母、数字和连字符组成的 slug。
// 包含其他字符的 slug(如 CJK,或类似土耳其无点 i 的 Unicode 小写
// 癖好)在 URL 中不稳定,会被丢弃,转而使用 "post-<id>" 回退方案。
var slugAsciiRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
// randomToken 返回 32 字节十六进制令牌,用于在文章保存前于创建页面上
// 持有待处理的附件归属。
func randomToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// 极罕见;回退到基于时间的值。
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(b)
}
// generateSlug 将标题转换为 URL 友好的 ASCII slug。
// 当标题无法产生任何 URL 安全的 ASCII 字符时返回 ""(调用方必须回退,
// 例如使用 "post-<id>")。非 ASCII 字母会被有意丢弃而非保留,
// 因为 URL slug 中的原始 CJK 字符不稳定。
func generateSlug(title string) string {
s := strings.ToLower(strings.TrimSpace(title))
s = slugSepRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
s = slugDashRe.ReplaceAllString(s, "-")
if !slugAsciiRe.MatchString(s) {
return ""
}
return s
}
// fallbackSlug 在由标题派生的 slug 为空时返回非空 slug
// (例如标题只包含标点/空白,或全部被剔除)。可用时使用文章 ID,
// 否则使用短令牌。
func fallbackSlug(id uint) string {
if id > 0 {
return fmt.Sprintf("post-%d", id)
}
return "post-" + randomToken()[:8]
}
// articleForm 是文章创建/更新接口的 JSON 请求体。
// Action/TitleText/ArticleID 仅由页面渲染路径使用,不参与 JSON 绑定。
type articleForm struct {
Title string `json:"title"`
Slug string `json:"slug"`
Summary string `json:"summary"`
Content string `json:"content"`
Cover string `json:"cover"`
StatusStr string `json:"status"` // "0" 草稿、"1" 已发布
IsTop bool `json:"is_top"`
PublishedAt string `json:"published_at"` // datetime-local"2006-01-02T15:04"
Tags string `json:"tags"` // 逗号分隔的标签名
SessionToken string `json:"session_token"`
Action string // 表单提交 URL(页面渲染用)
TitleText string // 页面标题文字(创建还是编辑)
ArticleID uint // 已有文章 ID(编辑页面);创建时为 0
}
// parseArticleFormJSON 绑定 JSON 请求体并去除空白后的文章字段。
// 绑定失败时已写入 400 响应并返回 ok=false。
func parseArticleFormJSON(c *gin.Context) (articleForm, bool) {
var f articleForm
if !bindJSON(c, &f) {
return f, false
}
f.Title = strings.TrimSpace(f.Title)
f.Slug = strings.TrimSpace(f.Slug)
f.Summary = strings.TrimSpace(f.Summary)
f.Content = strings.TrimSpace(f.Content)
f.Cover = strings.TrimSpace(f.Cover)
f.PublishedAt = strings.TrimSpace(f.PublishedAt)
f.Tags = strings.TrimSpace(f.Tags)
f.SessionToken = strings.TrimSpace(f.SessionToken)
return f, true
}
// applyFormToData 将表单字段值写入模板数据映射,使渲染时表单被重新填充
// (初次加载或校验错误)。
func applyFormToData(data gin.H, f articleForm) {
data["FormTitle"] = f.Title
data["FormSlug"] = f.Slug
data["FormSummary"] = f.Summary
data["FormContent"] = f.Content
data["FormCover"] = f.Cover
data["FormStatus"] = f.StatusStr
data["FormIsTop"] = f.IsTop
data["FormPublishedAt"] = f.PublishedAt
data["FormTags"] = f.Tags
data["FormAction"] = f.Action
data["FormTitleText"] = f.TitleText
data["FormArticleID"] = f.ArticleID
data["SessionToken"] = f.SessionToken
}
// renderArticleForm 使用给定的表单值和可选的错误消息渲染共享的文章表单模板。
func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
data := DefaultData(c)
data["Title"] = f.TitleText
if errMsg != "" {
data["Error"] = errMsg
}
applyFormToData(data, f)
c.HTML(http.StatusOK, "article_create", data)
}
// sessionAuthorID 从会话中提取已登录用户的 ID,兼容 int/uint/int64/float64
// 存储类型。不存在时返回 ok=false。
func sessionAuthorID(c *gin.Context) (uint, bool) {
session := sessions.Default(c)
userID := session.Get("user_id")
if userID == nil {
return 0, false
}
switch v := userID.(type) {
case uint:
return v, true
case int:
return uint(v), true
case int64:
return uint(v), true
case float64:
return uint(v), true
default:
return 0, false
}
}
// statusFromForm 解析状态字符串("0" 草稿、"1" 已发布),
// 返回模型状态常量,默认为草稿。
func statusFromForm(statusStr string) int {
if statusStr == "1" {
return models.ArticlePublished
}
return models.ArticleDraft
}
// parsePublishedAt 将表单中的 datetime-local 格式("2006-01-02T15:04")解析为
// time.Time 指针。字符串为空或非法时返回 nil。
func parsePublishedAt(publishedAtStr string) *time.Time {
if publishedAtStr == "" {
return nil
}
// datetime-local 格式:"2006-01-02T15:04"
t, err := time.ParseInLocation("2006-01-02T15:04", publishedAtStr, time.Local)
if err != nil {
return nil
}
return &t
}
// formatPublishedAt 将 time.Time 指针格式化为表单所需的 datetime-local 格式。
// 指针为 nil 时返回空字符串。
func formatPublishedAt(t *time.Time) string {
if t == nil {
return ""
}
return t.Local().Format("2006-01-02T15:04")
}
// parseTags 按逗号拆分标签字符串并返回标签名。
func parseTags(tagStr string) []string {
if tagStr == "" {
return []string{}
}
parts := strings.Split(tagStr, ",")
var tags []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
tags = append(tags, trimmed)
}
}
return tags
}
// syncArticleTags 将标签与文章关联(查找或创建标签)。
func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) error {
// 清除现有标签
if err := db.Model(article).Association("Tags").Clear(); err != nil {
return err
}
// 若没有标签,则完成
if len(tagNames) == 0 {
return nil
}
// 查找或创建每个标签并关联到文章
var tags []models.Tag
for _, name := range tagNames {
tag, err := models.FindOrCreateTag(db, name, name)
if err != nil {
return err
}
if tag != nil {
tags = append(tags, *tag)
}
}
// 将标签关联到文章
if len(tags) > 0 {
if err := db.Model(article).Association("Tags").Append(tags); err != nil {
return err
}
}
// 更新标签计数
for _, tag := range tags {
models.UpdateTagCount(db, tag.ID)
}
return nil
}
// formatArticleTags 将文章标签转换为逗号分隔的字符串以填充表单。
func formatArticleTags(tags []models.Tag) string {
if len(tags) == 0 {
return ""
}
var names []string
for _, tag := range tags {
names = append(names, tag.NameZh)
}
return strings.Join(names, ", ")
}
// ArticleCreatePage 渲染文章创建表单。
func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
renderArticleForm(c, db, articleForm{
Action: "/admin/articles/new",
TitleText: tr["article_create_title"],
StatusStr: "0",
SessionToken: randomToken(),
}, "")
}
}
// ArticleCreate 处理创建新文章的 POST 请求(admin 与 my 共用)。
// redirectPath 是成功跳转目标(admin 用 /admin,普通用户用 /my/articles)。
func ArticleCreate(db *gorm.DB, redirectPath string) gin.HandlerFunc {
return func(c *gin.Context) {
f, ok := parseArticleFormJSON(c)
if !ok {
return
}
// 校验必填字段。
if f.Title == "" {
APIError(c, http.StatusBadRequest, "article_title_required")
return
}
if f.Content == "" {
APIError(c, http.StatusBadRequest, "article_content_required")
return
}
// 为空时自动生成 slug。
if f.Slug == "" {
f.Slug = generateSlug(f.Title)
// 标题没有可用字符(例如只有标点)。先使用临时的基于令牌的
// slug;插入后再完善为 post-<id>。
if f.Slug == "" {
f.Slug = fallbackSlug(0)
}
}
status := statusFromForm(f.StatusStr)
authorID, ok := sessionAuthorID(c)
if !ok {
APIError(c, http.StatusUnauthorized, "api_unauthorized")
return
}
var publishedAt *time.Time
if f.PublishedAt != "" {
// 用户提供了自定义发布时间
publishedAt = parsePublishedAt(f.PublishedAt)
} else if status == models.ArticlePublished {
// 未提供自定义时间而发布时,自动盖上当前时间
now := time.Now()
publishedAt = &now
}
article := models.Article{
AuthorID: authorID,
Title: f.Title,
Slug: f.Slug,
Summary: f.Summary,
Content: f.Content,
Cover: f.Cover,
Status: status,
IsTop: f.IsTop,
PublishedAt: publishedAt,
}
if err := db.Create(&article).Error; err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
// 将基于令牌的占位 slug 完善为可读的 post-<id> 形式。
if strings.HasPrefix(f.Slug, "post-") && len(f.Slug) > 9 {
if newSlug := fallbackSlug(article.ID); newSlug != "" {
db.Model(&article).Update("slug", newSlug)
}
}
// 同步文章标签
tagNames := parseTags(f.Tags)
if err := syncArticleTags(db, &article, tagNames); err != nil {
// 记录错误但不影响文章创建
// 文章已经创建,标签是可选内容
}
// 绑定创建期间上传的任何附件(方案 A:由 session_token 持有、
// article_id=0 的待处理行)。
if f.SessionToken != "" {
_ = BindPendingAttachments(db, f.SessionToken, article.ID)
}
// 成功:跳转到文章列表。
APIOK(c, redirectPath, gin.H{"article_id": article.ID})
}
}
// ArticleListPage 渲染后台文章管理列表。
func ArticleListPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var articles []models.Article
db.Order("created_at DESC").Find(&articles)
data := DefaultData(c)
data["Title"] = tr["article_list_title"]
data["Articles"] = articles
c.HTML(http.StatusOK, "article_list", data)
}
}
// ArticleEditPage 渲染预填现有文章的共享表单。
func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
id := c.Param("id")
var article models.Article
if err := db.First(&article, "id = ?", id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/articles")
return
}
// 预加载文章标签
db.Model(&article).Association("Tags").Find(&article.Tags)
renderArticleForm(c, db, articleForm{
Title: article.Title,
Slug: article.Slug,
Summary: article.Summary,
Content: article.Content,
Cover: article.Cover,
StatusStr: strconv.Itoa(article.Status),
IsTop: article.IsTop,
PublishedAt: formatPublishedAt(article.PublishedAt),
Tags: formatArticleTags(article.Tags),
Action: "/admin/articles/" + id + "/edit",
TitleText: tr["article_edit_title"],
ArticleID: article.ID,
}, "")
}
}
// ArticleUpdate 处理更新现有文章的请求(admin 与 my 共用)。
// redirectPath 是成功跳转目标(admin 用 /admin/articles,普通用户用 /my/articles)。
func ArticleUpdate(db *gorm.DB, redirectPath string) gin.HandlerFunc {
return func(c *gin.Context) {
id := parseUintParam(c, "id")
if id == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
var article models.Article
if err := db.First(&article, id).Error; err != nil {
APIError(c, http.StatusNotFound, "article_not_found")
return
}
f, ok := parseArticleFormJSON(c)
if !ok {
return
}
if f.Title == "" {
APIError(c, http.StatusBadRequest, "article_title_required")
return
}
if f.Content == "" {
APIError(c, http.StatusBadRequest, "article_content_required")
return
}
if f.Slug == "" {
f.Slug = generateSlug(f.Title)
if f.Slug == "" {
f.Slug = fallbackSlug(article.ID)
}
}
newStatus := statusFromForm(f.StatusStr)
// 处理 published_at:若提供了表单值则使用,否则在首次发布时自动盖章。
var publishedAt *time.Time
if f.PublishedAt != "" {
// 用户提供了自定义发布时间
publishedAt = parsePublishedAt(f.PublishedAt)
} else {
// 文章首次发布时自动盖上发布时间。
wasPublished := article.Status == models.ArticlePublished
publishedAt = article.PublishedAt
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
now := time.Now()
publishedAt = &now
}
}
updates := map[string]interface{}{
"Title": f.Title,
"Slug": f.Slug,
"Summary": f.Summary,
"Content": f.Content,
"Cover": f.Cover,
"Status": newStatus,
"IsTop": f.IsTop,
"PublishedAt": publishedAt,
}
if err := db.Model(&article).Updates(updates).Error; err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
// 同步文章标签
tagNames := parseTags(f.Tags)
if err := syncArticleTags(db, &article, tagNames); err != nil {
// 记录错误但不影响文章更新
}
APIOK(c, redirectPath, nil)
}
}
// ArticleDelete 软删除文章(GORM 填充 DeletedAt)。
func ArticleDelete(db *gorm.DB, redirectPath string) gin.HandlerFunc {
return func(c *gin.Context) {
id := parseUintParam(c, "id")
if id == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
db.Delete(&models.Article{}, id)
APIOK(c, redirectPath, nil)
}
}