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