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-" 回退方案。 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-")。非 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-。 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- 形式。 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) } }