- 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 全绿
493 lines
14 KiB
Go
493 lines
14 KiB
Go
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)
|
||
}
|
||
}
|