- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文 - 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等 - 代码、字符串字面量、日志消息保持英文原文,零逻辑改动 - go build/vet 通过,go test -count=1 ./... 全绿
481 lines
13 KiB
Go
481 lines
13 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 保存解析后的文章表单字段,由创建和编辑处理器
|
|
// 及其校验错误回填路径共用。
|
|
type articleForm struct {
|
|
Title string
|
|
Slug string
|
|
Summary string
|
|
Content string
|
|
Cover string
|
|
StatusStr string
|
|
IsTop bool
|
|
PublishedAt string // datetime-local 格式:"2006-01-02T15:04"
|
|
Tags string // 逗号分隔的标签名
|
|
Action string // 表单提交 URL
|
|
TitleText string // 页面标题文字(创建还是编辑)
|
|
ArticleID uint // 已有文章 ID(编辑页面);创建时为 0
|
|
SessionToken string // 待处理附件的归属令牌(创建页面)
|
|
}
|
|
|
|
// parseArticleForm 从请求中读取并去除空白后的文章表单字段。
|
|
func parseArticleForm(c *gin.Context) articleForm {
|
|
return articleForm{
|
|
Title: strings.TrimSpace(c.PostForm("title")),
|
|
Slug: strings.TrimSpace(c.PostForm("slug")),
|
|
Summary: strings.TrimSpace(c.PostForm("summary")),
|
|
Content: strings.TrimSpace(c.PostForm("content")),
|
|
Cover: strings.TrimSpace(c.PostForm("cover")),
|
|
StatusStr: c.PostForm("status"),
|
|
IsTop: c.PostForm("is_top") == "1",
|
|
PublishedAt: strings.TrimSpace(c.PostForm("published_at")),
|
|
Tags: strings.TrimSpace(c.PostForm("tags")),
|
|
}
|
|
}
|
|
|
|
// 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 请求。
|
|
func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
tr := getTr(c)
|
|
f := parseArticleForm(c)
|
|
f.Action = "/admin/articles/new"
|
|
f.TitleText = tr["article_create_title"]
|
|
f.SessionToken = strings.TrimSpace(c.PostForm("session_token"))
|
|
|
|
// 校验必填字段。
|
|
if f.Title == "" {
|
|
renderArticleForm(c, db, f, tr["article_title_required"])
|
|
return
|
|
}
|
|
if f.Content == "" {
|
|
renderArticleForm(c, db, f, tr["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 {
|
|
c.Redirect(http.StatusFound, "/login")
|
|
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 {
|
|
renderArticleForm(c, db, f, tr["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)
|
|
}
|
|
|
|
// 成功:重定向到管理后台。
|
|
c.Redirect(http.StatusFound, "/admin")
|
|
}
|
|
}
|
|
|
|
// 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 处理更新现有文章的 POST 请求。
|
|
func ArticleUpdate(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
|
|
}
|
|
|
|
f := parseArticleForm(c)
|
|
f.Action = "/admin/articles/" + id + "/edit"
|
|
f.TitleText = tr["article_edit_title"]
|
|
|
|
if f.Title == "" {
|
|
renderArticleForm(c, db, f, tr["article_title_required"])
|
|
return
|
|
}
|
|
if f.Content == "" {
|
|
renderArticleForm(c, db, f, tr["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 {
|
|
renderArticleForm(c, db, f, tr["article_error"])
|
|
return
|
|
}
|
|
|
|
// 同步文章标签
|
|
tagNames := parseTags(f.Tags)
|
|
if err := syncArticleTags(db, &article, tagNames); err != nil {
|
|
// 记录错误但不影响文章更新
|
|
}
|
|
|
|
c.Redirect(http.StatusFound, "/admin/articles")
|
|
}
|
|
}
|
|
|
|
// ArticleDelete 软删除文章(GORM 填充 DeletedAt)并重定向回管理列表。
|
|
func ArticleDelete(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
id := c.Param("id")
|
|
db.Delete(&models.Article{}, "id = ?", id)
|
|
c.Redirect(http.StatusFound, "/admin/articles")
|
|
}
|
|
}
|