package service import ( "errors" "fmt" "strings" "sync" "time" "unicode/utf8" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // 代码片段语言白名单(与前端 highlight.js 注册表对齐) var snippetLanguages = map[string]struct{}{ "plaintext": {}, "bash": {}, "c": {}, "cpp": {}, "csharp": {}, "css": {}, "dart": {}, "diff": {}, "dockerfile": {}, "go": {}, "graphql": {}, "html": {}, "ini": {}, "java": {}, "javascript": {}, "json": {}, "jsonc": {}, "jsx": {}, "kotlin": {}, "lua": {}, "makefile": {}, "markdown": {}, "nginx": {}, "php": {}, "powershell": {}, "prisma": {}, "python": {}, "r": {}, "regex": {}, "ruby": {}, "rust": {}, "scss": {}, "shell": {}, "sql": {}, "swift": {}, "toml": {}, "tsx": {}, "typescript": {}, "vue": {}, "xml": {}, "yaml": {}, } const ( snippetTitleMaxRunes = 120 snippetDescMaxBytes = 8 << 10 // 8KB snippetCodeMaxBytes = 64 << 10 // 64KB snippetCommentMaxLen = 2000 snippetCopyThrottle = 10 * time.Second ) var ( ErrSnippetNotFound = errors.New("代码片段不存在") ErrSnippetForbidden = errors.New("无权操作该代码片段") ErrSnippetBadInput = errors.New("代码片段参数无效") ) // SnippetLanguages 返回支持的语言白名单(排序固定,便于前端渲染) func SnippetLanguages() []string { out := make([]string, 0, len(snippetLanguages)) for k := range snippetLanguages { out = append(out, k) } // 固定排序:plaintext 置顶,其余按字典序 plaintext := []string{"plaintext"} var rest []string for _, v := range out { if v != "plaintext" { rest = append(rest, v) } } for i := 0; i < len(rest)-1; i++ { for j := i + 1; j < len(rest); j++ { if rest[i] > rest[j] { rest[i], rest[j] = rest[j], rest[i] } } } return append(plaintext, rest...) } // normalizeSnippetLanguage 空值回落 plaintext,不在白名单则报错 func normalizeSnippetLanguage(lang string) (string, error) { lang = strings.ToLower(strings.TrimSpace(lang)) if lang == "" { return "plaintext", nil } if _, ok := snippetLanguages[lang]; !ok { return "", ErrSnippetBadInput } return lang, nil } // SnippetInput 创建/更新代码片段入参 type SnippetInput struct { Title string `json:"title"` Language string `json:"language"` Description string `json:"description"` Code string `json:"code"` Visibility string `json:"visibility"` } // badInput 输入校验错误(wrap ErrSnippetBadInput,handler 据此返回 400) func badInput(msg string) error { return fmt.Errorf("%w: %s", ErrSnippetBadInput, msg) } func (in *SnippetInput) normalize() error { in.Title = strings.TrimSpace(in.Title) if in.Title == "" { return badInput("标题不能为空") } if utf8.RuneCountInString(in.Title) > snippetTitleMaxRunes { return badInput("标题不能超过 120 字") } lang, err := normalizeSnippetLanguage(in.Language) if err != nil { return badInput("不支持的语言") } in.Language = lang if strings.TrimSpace(in.Code) == "" { return badInput("代码不能为空") } if len(in.Code) > snippetCodeMaxBytes { return badInput("代码不能超过 64KB") } in.Description = strings.TrimSpace(in.Description) if len(in.Description) > snippetDescMaxBytes { return badInput("描述不能超过 8KB") } in.Visibility = model.NormalizeSnippetVisibility(in.Visibility) return nil } // SnippetOwner 片段所有者摘要 type SnippetOwner struct { ID uint `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` } // SnippetForkedFrom 源片段摘要 type SnippetForkedFrom struct { ID uint `json:"id"` Title string `json:"title"` } // SnippetDetail 片段详情(含所有者与源片段摘要) type SnippetDetail struct { model.CodeSnippet User *SnippetOwner `json:"user,omitempty"` ForkedFrom *SnippetForkedFrom `json:"forked_from,omitempty"` } // SnippetListItem 广场/我的列表项(不含 code 全文,仅前 300 字符作预览) type SnippetListItem struct { ID uint `json:"id"` Title string `json:"title"` Language string `json:"language"` Description string `json:"description"` Preview string `json:"preview"` // code 前 300 字符 Visibility string `json:"visibility"` UserID uint `json:"user_id"` CopyCount int `json:"copy_count"` ViewCount int `json:"view_count"` CommentCount int `json:"comment_count"` ForkCount int `json:"fork_count"` ForkedFromID *uint `json:"forked_from_id,omitempty"` User *SnippetOwner `json:"user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // SnippetCommentNode 片段评论节点(主评带 replies,软删 tombstone 占位) type SnippetCommentNode struct { model.SnippetComment Floor int `json:"floor"` // 主评楼层号(含软删) Deleted bool `json:"deleted"` // 软删标记 Edited bool `json:"edited"` // 相对创建已编辑 User *SnippetOwner `json:"user,omitempty"` Replies []SnippetCommentNode `json:"replies,omitempty"` } // SnippetService 代码片段服务 type SnippetService struct { db *gorm.DB copyMu sync.Mutex copyRecords map[string]time.Time // key=userID 或 IP,10s 内只计一次 statsMu sync.Mutex statsCache *SnippetStats statsAt time.Time } func NewSnippetService(db *gorm.DB) *SnippetService { return &SnippetService{db: db, copyRecords: make(map[string]time.Time)} } // escapeILike 转义 ILIKE 特殊字符 % 和 _ func escapeILike(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `%`, `\%`) s = strings.ReplaceAll(s, `_`, `\_`) return s } // SnippetLangStat 语言分布项 type SnippetLangStat struct { Language string `json:"language"` Count int64 `json:"count"` } // SnippetStats 片段广场聚合统计(仅公开片段) type SnippetStats struct { TotalSnippets int64 `json:"total_snippets"` TotalCopies int64 `json:"total_copies"` TotalViews int64 `json:"total_views"` TotalComments int64 `json:"total_comments"` TotalForks int64 `json:"total_forks"` Contributors int64 `json:"contributors"` TopLanguages []SnippetLangStat `json:"top_languages"` } const snippetStatsTTL = 30 * time.Second // Stats 广场聚合统计(30s 内存缓存) func (s *SnippetService) Stats() (*SnippetStats, error) { s.statsMu.Lock() if s.statsCache != nil && time.Since(s.statsAt) < snippetStatsTTL { cached := s.statsCache s.statsMu.Unlock() return cached, nil } s.statsMu.Unlock() pub := func() *gorm.DB { return s.db.Model(&model.CodeSnippet{}).Where("visibility = ?", model.SnippetVisibilityPublic) } var agg struct { Total int64 Copies int64 Views int64 Comments int64 Forks int64 } if err := pub(). Select("COUNT(*) AS total, COALESCE(SUM(copy_count),0) AS copies, COALESCE(SUM(view_count),0) AS views, COALESCE(SUM(comment_count),0) AS comments, COALESCE(SUM(fork_count),0) AS forks"). Scan(&agg).Error; err != nil { return nil, err } var contributors int64 if err := pub().Distinct("user_id").Count(&contributors).Error; err != nil { return nil, err } langs := make([]SnippetLangStat, 0, 6) if err := pub(). Select("language, COUNT(*) AS count"). Group("language"). Order("count DESC, language ASC"). Limit(6). Scan(&langs).Error; err != nil { return nil, err } st := &SnippetStats{ TotalSnippets: agg.Total, TotalCopies: agg.Copies, TotalViews: agg.Views, TotalComments: agg.Comments, TotalForks: agg.Forks, Contributors: contributors, TopLanguages: langs, } s.statsMu.Lock() s.statsCache = st s.statsAt = time.Now() s.statsMu.Unlock() return st, nil } // toListItem 模型转列表项(截断 code 生成 preview) func toSnippetListItem(s model.CodeSnippet, owner *SnippetOwner) SnippetListItem { preview := s.Code if len(preview) > 300 { preview = preview[:300] } return SnippetListItem{ ID: s.ID, Title: s.Title, Language: s.Language, Description: s.Description, Preview: preview, Visibility: s.Visibility, UserID: s.UserID, CopyCount: s.CopyCount, ViewCount: s.ViewCount, CommentCount: s.CommentCount, ForkCount: s.ForkCount, ForkedFromID: s.ForkedFromID, User: owner, CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt, } } // ownerMap 批量取所有者摘要(避免 N+1) func (s *SnippetService) ownerMap(ids []uint) map[uint]*SnippetOwner { if len(ids) == 0 { return map[uint]*SnippetOwner{} } uniq := make([]uint, 0, len(ids)) seen := map[uint]bool{} for _, id := range ids { if !seen[id] { seen[id] = true uniq = append(uniq, id) } } var users []model.User if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", uniq).Find(&users).Error; err != nil { return map[uint]*SnippetOwner{} } m := make(map[uint]*SnippetOwner, len(users)) for i := range users { u := &users[i] m[u.ID] = &SnippetOwner{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar} } return m } // Create 创建代码片段 func (s *SnippetService) Create(in *SnippetInput, userID uint) (*model.CodeSnippet, error) { if err := in.normalize(); err != nil { return nil, err } snippet := &model.CodeSnippet{ Title: in.Title, Language: in.Language, Description: in.Description, Code: in.Code, Visibility: in.Visibility, UserID: userID, } if err := s.db.Create(snippet).Error; err != nil { return nil, err } return snippet, nil } // Update 更新代码片段(仅 owner 或 staff;编辑前落 SnippetVersion) func (s *SnippetService) Update(id uint, in *SnippetInput, editorID uint, isStaff bool) (*model.CodeSnippet, error) { var snippet model.CodeSnippet if err := s.db.First(&snippet, id).Error; err != nil { return nil, ErrSnippetNotFound } if snippet.UserID != editorID && !isStaff { return nil, ErrSnippetForbidden } if err := in.normalize(); err != nil { return nil, err } // 判断是否有实际字段变化 changed := snippet.Title != in.Title || snippet.Language != in.Language || snippet.Description != in.Description || snippet.Code != in.Code || snippet.Visibility != in.Visibility if changed { ver := model.SnippetVersion{ SnippetID: snippet.ID, EditorID: editorID, OldTitle: snippet.Title, OldLanguage: snippet.Language, OldDescription: snippet.Description, OldCode: snippet.Code, } if err := s.db.Create(&ver).Error; err != nil { return nil, err } } snippet.Title = in.Title snippet.Language = in.Language snippet.Description = in.Description snippet.Code = in.Code oldVisibility := snippet.Visibility snippet.Visibility = in.Visibility err := s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&snippet).Updates(map[string]interface{}{ "title": snippet.Title, "language": snippet.Language, "description": snippet.Description, "code": snippet.Code, "visibility": snippet.Visibility, }).Error; err != nil { return err } // fork 副本在私有/公开之间切换时,同步源片段的公开 fork 计数 if snippet.ForkedFromID != nil && oldVisibility != snippet.Visibility { expr := "GREATEST(fork_count - 1, 0)" if snippet.Visibility == model.SnippetVisibilityPublic { expr = "fork_count + 1" } if err := tx.Model(&model.CodeSnippet{}).Where("id = ?", *snippet.ForkedFromID). UpdateColumn("fork_count", gorm.Expr(expr)).Error; err != nil { return err } } return nil }) if err != nil { return nil, err } return &snippet, nil } // GetPublic 获取片段详情(public 或 owner/staff 可见) func (s *SnippetService) GetPublic(id uint, viewerID uint, isStaff bool) (*SnippetDetail, error) { var snippet model.CodeSnippet if err := s.db.First(&snippet, id).Error; err != nil { return nil, ErrSnippetNotFound } if snippet.Visibility != model.SnippetVisibilityPublic && snippet.UserID != viewerID && !isStaff { return nil, ErrSnippetNotFound } // ViewCount++(失败不影响主流程) s.db.Model(&snippet).UpdateColumn("view_count", gorm.Expr("view_count + 1")) ownerMap := s.ownerMap([]uint{snippet.UserID}) detail := &SnippetDetail{ CodeSnippet: snippet, User: ownerMap[snippet.UserID], } if snippet.ForkedFromID != nil { var src model.CodeSnippet if err := s.db.Select("id, title").First(&src, *snippet.ForkedFromID).Error; err == nil { detail.ForkedFrom = &SnippetForkedFrom{ID: src.ID, Title: src.Title} } } return detail, nil } // ListPublic 公开广场列表(仅 public 未删) func (s *SnippetService) ListPublic(q, lang, sort string, page, size int) ([]SnippetListItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 12 } db := s.db.Model(&model.CodeSnippet{}).Where("visibility = ?", model.SnippetVisibilityPublic) if q = strings.TrimSpace(q); q != "" { like := "%" + escapeILike(q) + "%" db = db.Where("title ILIKE ? OR description ILIKE ? OR code ILIKE ?", like, like, like) } if lang != "" { if _, ok := snippetLanguages[lang]; ok { db = db.Where("language = ?", lang) } } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } switch sort { case "copy": db = db.Order("copy_count DESC, id DESC") case "comment": db = db.Order("comment_count DESC, id DESC") case "fork": db = db.Order("fork_count DESC, id DESC") default: db = db.Order("created_at DESC, id DESC") } var snippets []model.CodeSnippet if err := db.Offset((page - 1) * size).Limit(size).Find(&snippets).Error; err != nil { return nil, 0, err } ownerIDs := make([]uint, 0, len(snippets)) for _, sn := range snippets { ownerIDs = append(ownerIDs, sn.UserID) } owners := s.ownerMap(ownerIDs) items := make([]SnippetListItem, 0, len(snippets)) for _, sn := range snippets { items = append(items, toSnippetListItem(sn, owners[sn.UserID])) } return items, total, nil } // ListMine 我的片段列表(含私有) func (s *SnippetService) ListMine(userID uint, q string, page, size int) ([]SnippetListItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 12 } db := s.db.Model(&model.CodeSnippet{}).Where("user_id = ?", userID) if q = strings.TrimSpace(q); q != "" { like := "%" + escapeILike(q) + "%" db = db.Where("title ILIKE ? OR description ILIKE ? OR code ILIKE ?", like, like, like) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var snippets []model.CodeSnippet if err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&snippets).Error; err != nil { return nil, 0, err } ownerIDs := make([]uint, 0, len(snippets)) for _, sn := range snippets { ownerIDs = append(ownerIDs, sn.UserID) } owners := s.ownerMap(ownerIDs) items := make([]SnippetListItem, 0, len(snippets)) for _, sn := range snippets { items = append(items, toSnippetListItem(sn, owners[sn.UserID])) } return items, total, nil } // Fork 复制公开片段为自己的私有片段 func (s *SnippetService) Fork(id uint, userID uint) (*model.CodeSnippet, error) { var src model.CodeSnippet if err := s.db.First(&src, id).Error; err != nil { return nil, ErrSnippetNotFound } if src.Visibility != model.SnippetVisibilityPublic { return nil, ErrSnippetForbidden } fork := &model.CodeSnippet{ Title: src.Title, Language: src.Language, Description: src.Description, Code: src.Code, Visibility: model.SnippetVisibilityPrivate, UserID: userID, ForkedFromID: &src.ID, } // 源标题本身已受限长约束,无需再次截断 // 副本初始为私有,不计入源片段的公开 fork 数; // 作者将其改为公开时(见 Update)再计数。 if err := s.db.Create(fork).Error; err != nil { return nil, err } return fork, nil } // RecordCopy 复制计数(按 key 节流 10s) func (s *SnippetService) RecordCopy(id uint, key string) error { s.copyMu.Lock() now := time.Now() if t, ok := s.copyRecords[key]; ok && now.Sub(t) < snippetCopyThrottle { s.copyMu.Unlock() return nil } s.copyRecords[key] = now // 清理过期记录(简单 LRU,防止内存膨胀) if len(s.copyRecords) > 10000 { for k, v := range s.copyRecords { if now.Sub(v) > time.Minute { delete(s.copyRecords, k) } } } s.copyMu.Unlock() var snippet model.CodeSnippet if err := s.db.First(&snippet, id).Error; err != nil { return ErrSnippetNotFound } return s.db.Model(&snippet).UpdateColumn("copy_count", gorm.Expr("copy_count + 1")).Error } // Delete 软删片段(owner 或 staff) func (s *SnippetService) Delete(id uint, actorID uint, isStaff bool) error { var snippet model.CodeSnippet if err := s.db.First(&snippet, id).Error; err != nil { return ErrSnippetNotFound } if snippet.UserID != actorID && !isStaff { return ErrSnippetForbidden } return s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Delete(&snippet).Error; err != nil { return err } // 删除公开的 fork 副本时,回溯源片段的公开 fork 计数; // 私有副本从未计入,无需回溯。 if snippet.ForkedFromID != nil && snippet.Visibility == model.SnippetVisibilityPublic { if err := tx.Model(&model.CodeSnippet{}).Where("id = ?", *snippet.ForkedFromID). UpdateColumn("fork_count", gorm.Expr("GREATEST(fork_count - 1, 0)")).Error; err != nil { return err } } return nil }) } // ListHistory 版本历史(倒序分页) func (s *SnippetService) ListHistory(snippetID uint, page, size int) ([]model.SnippetVersion, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 10 } var total int64 if err := s.db.Model(&model.SnippetVersion{}).Where("snippet_id = ?", snippetID).Count(&total).Error; err != nil { return nil, 0, err } var versions []model.SnippetVersion if err := s.db.Where("snippet_id = ?", snippetID). Order("created_at DESC, id DESC"). Offset((page - 1) * size).Limit(size). Find(&versions).Error; err != nil { return nil, 0, err } return versions, total, nil } // ListForks 公开 fork 列表(一层) func (s *SnippetService) ListForks(id uint, page, size int) ([]SnippetListItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 12 } db := s.db.Model(&model.CodeSnippet{}). Where("forked_from_id = ? AND visibility = ?", id, model.SnippetVisibilityPublic) var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var snippets []model.CodeSnippet if err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&snippets).Error; err != nil { return nil, 0, err } ownerIDs := make([]uint, 0, len(snippets)) for _, sn := range snippets { ownerIDs = append(ownerIDs, sn.UserID) } owners := s.ownerMap(ownerIDs) items := make([]SnippetListItem, 0, len(snippets)) for _, sn := range snippets { items = append(items, toSnippetListItem(sn, owners[sn.UserID])) } return items, total, nil } // snippetFloorNumber 主评楼层号(含软删,按 created_at/id 升序) func (s *SnippetService) snippetFloorNumber(snippetID, commentID uint) int { var c model.SnippetComment if err := s.db.Unscoped().Select("id", "snippet_id", "parent_id", "root_id", "created_at"). Where("id = ? AND snippet_id = ?", commentID, snippetID).First(&c).Error; err != nil { return 0 } floorID := c.ID floorCreated := c.CreatedAt if c.ParentID != nil { rootID := c.ID if c.RootID != nil { rootID = *c.RootID } var root model.SnippetComment if err := s.db.Unscoped().Select("id", "created_at"). Where("id = ? AND snippet_id = ? AND parent_id IS NULL", rootID, snippetID).First(&root).Error; err != nil { return 0 } floorID = root.ID floorCreated = root.CreatedAt } var ahead int64 s.db.Unscoped().Model(&model.SnippetComment{}). Where("snippet_id = ? AND parent_id IS NULL AND (created_at < ? OR (created_at = ? AND id < ?))", snippetID, floorCreated, floorCreated, floorID). Count(&ahead) return int(ahead) + 1 } // toSnippetCommentNode 转评论节点(软删 tombstone 清空正文) func toSnippetCommentNode(c model.SnippetComment, user *SnippetOwner, replies []SnippetCommentNode, floor int) SnippetCommentNode { edited := c.UpdatedAt.Sub(c.CreatedAt) > time.Minute if c.DeletedAt.Valid { c.Content = "" return SnippetCommentNode{ SnippetComment: c, Floor: floor, Deleted: true, Edited: edited, User: user, Replies: replies, } } return SnippetCommentNode{ SnippetComment: c, Floor: floor, Deleted: false, Edited: edited, User: user, Replies: replies, } } // ListComments 片段评论列表(主评分页,含回复) func (s *SnippetService) ListComments(snippetID uint, page, size int) ([]SnippetCommentNode, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } // tombstone 语义:软删评论保留占位壳(内容清空,前端显示"该评论已删除"), // 其子回复仍挂在壳下;因此列表/计数均使用 Unscoped 包含软删行。 base := s.db.Unscoped().Model(&model.SnippetComment{}).Where("snippet_id = ?", snippetID) var total int64 if err := base.Count(&total).Error; err != nil { return nil, 0, err } var roots []model.SnippetComment if err := s.db.Unscoped().Where("snippet_id = ? AND parent_id IS NULL", snippetID). Order("created_at ASC, id ASC"). Offset((page - 1) * size).Limit(size). Find(&roots).Error; err != nil { return nil, 0, err } if len(roots) == 0 { return []SnippetCommentNode{}, total, nil } rootIDs := make([]uint, len(roots)) for i, r := range roots { rootIDs[i] = r.ID } var replies []model.SnippetComment if err := s.db.Unscoped().Where("snippet_id = ? AND root_id IN ?", snippetID, rootIDs). Order("created_at ASC, id ASC").Find(&replies).Error; err != nil { return nil, 0, err } children := make(map[uint][]model.SnippetComment, len(replies)) for _, r := range replies { if r.ParentID != nil { children[*r.ParentID] = append(children[*r.ParentID], r) } } allRows := make([]model.SnippetComment, 0, len(roots)+len(replies)) allRows = append(allRows, roots...) allRows = append(allRows, replies...) userIDs := make([]uint, 0, len(allRows)) for _, c := range allRows { userIDs = append(userIDs, c.UserID) } users := s.ownerMap(userIDs) var build func(pid uint) []SnippetCommentNode build = func(pid uint) []SnippetCommentNode { out := []SnippetCommentNode{} for _, c := range children[pid] { out = append(out, toSnippetCommentNode(c, users[c.UserID], build(c.ID), 0)) } return out } result := make([]SnippetCommentNode, 0, len(roots)) for _, r := range roots { floor := s.snippetFloorNumber(snippetID, r.ID) result = append(result, toSnippetCommentNode(r, users[r.UserID], build(r.ID), floor)) } return result, total, nil } // CreateComment 创建片段评论 func (s *SnippetService) CreateComment(userID, snippetID uint, content string, parentID *uint) (*model.SnippetComment, error) { content = strings.TrimSpace(content) if content == "" { return nil, badInput("评论内容不能为空") } if utf8.RuneCountInString(content) > snippetCommentMaxLen { return nil, badInput("评论不能超过 2000 字") } var snippet model.CodeSnippet if err := s.db.First(&snippet, snippetID).Error; err != nil { return nil, ErrSnippetNotFound } if snippet.Visibility != model.SnippetVisibilityPublic { return nil, ErrSnippetNotFound } comment := &model.SnippetComment{ SnippetID: snippetID, UserID: userID, Content: content, } if parentID != nil { var parent model.SnippetComment if err := s.db.Where("id = ? AND snippet_id = ? AND deleted_at IS NULL", *parentID, snippetID).First(&parent).Error; err != nil { return nil, badInput("回复的评论不存在") } if parent.Depth >= maxReplyDepth { return nil, badInput("回复层级过深") } rootID := parent.ID if parent.RootID != nil { rootID = *parent.RootID } comment.ParentID = parentID comment.RootID = &rootID comment.Depth = parent.Depth + 1 } err := s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(comment).Error; err != nil { return err } return tx.Model(&snippet).UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error }) if err != nil { return nil, err } return comment, nil } // UpdateComment 更新评论(仅本人) func (s *SnippetService) UpdateComment(commentID, userID uint, content string) (*model.SnippetComment, error) { content = strings.TrimSpace(content) if content == "" { return nil, badInput("评论内容不能为空") } if utf8.RuneCountInString(content) > snippetCommentMaxLen { return nil, badInput("评论不能超过 2000 字") } var comment model.SnippetComment if err := s.db.First(&comment, commentID).Error; err != nil { return nil, ErrCommentNotFound } if comment.UserID != userID { return nil, ErrCommentForbidden } if comment.Content == content { return nil, ErrCommentNotChanged } old := comment.Content if err := s.db.Transaction(func(tx *gorm.DB) error { hist := model.SnippetCommentEditHistory{ CommentID: comment.ID, EditorID: userID, OldContent: old, } if err := tx.Create(&hist).Error; err != nil { return err } return tx.Model(&comment).Update("content", content).Error }); err != nil { return nil, err } if err := s.db.First(&comment, comment.ID).Error; err != nil { return nil, err } return &comment, nil } // DeleteComment 软删评论(本人或 staff;tombstone:DeleteType/DeletedBy) // 保留占位壳与楼层计数:评论数不减,列表中显示"该评论已删除",子回复仍挂其下。 func (s *SnippetService) DeleteComment(commentID, userID uint, isStaff bool) error { var comment model.SnippetComment if err := s.db.First(&comment, commentID).Error; err != nil { return ErrCommentNotFound } if comment.UserID != userID && !isStaff { return ErrCommentForbidden } deleteType := "self" if comment.UserID != userID { deleteType = "admin" } if err := s.db.Model(&comment).Updates(map[string]interface{}{ "delete_type": deleteType, "deleted_by": userID, }).Error; err != nil { return err } return s.db.Delete(&comment).Error } // SnippetCommentHistoryItem 片段评论历史项 type SnippetCommentHistoryItem struct { ID uint `json:"id"` Editor *SnippetOwner `json:"editor"` OldContent string `json:"old_content"` CreatedAt time.Time `json:"created_at"` } // ListCommentHistory 片段评论编辑历史(登录即可见;含软删评论;倒序分页) func (s *SnippetService) ListCommentHistory(actor *Actor, commentID uint, page, size int) ([]SnippetCommentHistoryItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 10 } var comment model.SnippetComment if err := s.db.Unscoped().Select("id").First(&comment, commentID).Error; err != nil { return nil, 0, ErrCommentNotFound } if actor == nil { return nil, 0, ErrCommentForbidden } q := s.db.Model(&model.SnippetCommentEditHistory{}).Where("comment_id = ?", commentID) var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err } var rows []model.SnippetCommentEditHistory if err := s.db.Where("comment_id = ?", commentID). Order("created_at DESC"). Offset((page - 1) * size).Limit(size). Find(&rows).Error; err != nil { return nil, 0, err } editorIDs := make([]uint, 0, len(rows)) seen := map[uint]struct{}{} for _, r := range rows { if _, ok := seen[r.EditorID]; ok { continue } seen[r.EditorID] = struct{}{} editorIDs = append(editorIDs, r.EditorID) } editors := s.ownerMap(editorIDs) items := make([]SnippetCommentHistoryItem, 0, len(rows)) for _, r := range rows { ed := editors[r.EditorID] if ed == nil { ed = &SnippetOwner{ID: r.EditorID, Username: "已注销", Nickname: "已注销"} } items = append(items, SnippetCommentHistoryItem{ ID: r.ID, Editor: ed, OldContent: r.OldContent, CreatedAt: r.CreatedAt, }) } return items, total, nil } // AdminListSnippetItem 管理端片段列表项(含 code) type AdminListSnippetItem struct { model.CodeSnippet User *SnippetOwner `json:"user,omitempty"` } // AdminList 管理端全量列表(含 code) func (s *SnippetService) AdminList(q, visibility string, page, size int) ([]AdminListSnippetItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 20 } db := s.db.Model(&model.CodeSnippet{}) if q = strings.TrimSpace(q); q != "" { like := "%" + escapeILike(q) + "%" db = db.Where("title ILIKE ? OR description ILIKE ? OR code ILIKE ?", like, like, like) } if visibility != "" { db = db.Where("visibility = ?", model.NormalizeSnippetVisibility(visibility)) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var snippets []model.CodeSnippet if err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&snippets).Error; err != nil { return nil, 0, err } ownerIDs := make([]uint, 0, len(snippets)) for _, sn := range snippets { ownerIDs = append(ownerIDs, sn.UserID) } owners := s.ownerMap(ownerIDs) items := make([]AdminListSnippetItem, 0, len(snippets)) for _, sn := range snippets { items = append(items, AdminListSnippetItem{CodeSnippet: sn, User: owners[sn.UserID]}) } return items, total, nil } // AdminSetVisibility 管理端强制修改可见性 func (s *SnippetService) AdminSetVisibility(id uint, visibility string) error { visibility = model.NormalizeSnippetVisibility(visibility) result := s.db.Model(&model.CodeSnippet{}).Where("id = ?", id).Update("visibility", visibility) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrSnippetNotFound } return nil } // AdminDelete 管理端软删(DeleteType=admin) func (s *SnippetService) AdminDelete(id uint, adminID uint) error { var snippet model.CodeSnippet if err := s.db.First(&snippet, id).Error; err != nil { return ErrSnippetNotFound } return s.db.Delete(&snippet).Error }