Files
jiang13-bbs/backend/service/snippet.go

1023 lines
30 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 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
}