feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等

This commit is contained in:
2026-10-09 18:53:38 +08:00
parent 5598e1cbb7
commit da72843c70
64 changed files with 7047 additions and 248 deletions

View File

@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/middleware"
@@ -240,11 +241,32 @@ func (h *Handlers) JoinChatRoom(c *gin.Context) {
if !ok {
return
}
_, _, err := h.Chat.Join(claims.ID, roomID)
member, joined, err := h.Chat.Join(claims.ID, roomID)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
// 首次加入则向房间广播系统提示 + 成员变更
if joined && member != nil {
displayName := h.Chat.DisplayName(claims.ID)
if displayName != "" {
sysMsg, _ := h.Chat.SendSystemMessage(roomID, displayName+" 加入了群聊")
if sysMsg != nil && sysMsg.ID > 0 {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: sysMsg,
})
}
}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{
"room_id": roomID,
"status": "joined",
"user_id": claims.ID,
},
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -300,6 +322,35 @@ func (h *Handlers) InviteChatMembers(c *gin.Context) {
Data: gin.H{"room_id": roomID, "status": "invited"},
})
}
// 被邀请者入群时向房间广播系统提示 + 成员变更
if len(added) > 0 {
var names []string
for _, uid := range added {
if n := h.Chat.DisplayName(uid); n != "" {
names = append(names, n)
}
}
if len(names) > 0 {
content := strings.Join(names, "、") + " 加入了群聊"
sysMsg, _ := h.Chat.SendSystemMessage(roomID, content)
if sysMsg != nil && sysMsg.ID > 0 {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: sysMsg,
})
}
}
for _, uid := range added {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{
"room_id": roomID,
"status": "joined",
"user_id": uid,
},
})
}
}
c.JSON(http.StatusOK, gin.H{"added": added})
}

View File

@@ -42,6 +42,7 @@ type Handlers struct {
Badge *service.BadgeService
LeaderboardSvc *service.LeaderboardService
LibrarySvc *service.LibraryService
SnippetSvc *service.SnippetService
}
// resolvePublishStatus 决定新帖/新评的初始状态:

424
backend/handler/snippet.go Normal file
View File

@@ -0,0 +1,424 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// snippetError 统一错误映射
func snippetError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrSnippetNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "代码片段不存在"})
case errors.Is(err, service.ErrSnippetForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": "无权操作该代码片段"})
case errors.Is(err, service.ErrSnippetBadInput):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
case errors.Is(err, service.ErrCommentForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": "无权操作该评论"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器错误"})
}
}
// SnippetList 公开片段广场
func (h *Handlers) SnippetList(c *gin.Context) {
q := c.Query("q")
lang := c.Query("lang")
sort := c.Query("sort")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "12"))
items, total, err := h.SnippetSvc.ListPublic(q, lang, sort, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取片段列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// SnippetLanguages 语言白名单
func (h *Handlers) SnippetLanguages(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"languages": service.SnippetLanguages()})
}
// SnippetStats 广场聚合统计
func (h *Handlers) SnippetStats(c *gin.Context) {
stats, err := h.SnippetSvc.Stats()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取统计失败"})
return
}
c.JSON(http.StatusOK, gin.H{"stats": stats})
}
// SnippetDetail 公开片段详情
func (h *Handlers) SnippetDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
var viewerID uint
if claims := middleware.CurrentUser(c); claims != nil {
viewerID = claims.ID
}
isStaff := h.isStaff(c)
detail, err := h.SnippetSvc.GetPublic(uint(id), viewerID, isStaff)
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"snippet": detail})
}
// SnippetMine 我的片段列表
func (h *Handlers) SnippetMine(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
q := c.Query("q")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "12"))
items, total, err := h.SnippetSvc.ListMine(claims.ID, q, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取片段列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// SnippetCreate 创建片段
func (h *Handlers) SnippetCreate(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
var in service.SnippetInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
snippet, err := h.SnippetSvc.Create(&in, claims.ID)
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"snippet": snippet})
}
// SnippetUpdate 更新片段
func (h *Handlers) SnippetUpdate(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
var in service.SnippetInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
snippet, err := h.SnippetSvc.Update(uint(id), &in, claims.ID, h.isStaff(c))
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"snippet": snippet})
}
// SnippetDelete 删除片段(owner 或 staff)
func (h *Handlers) SnippetDelete(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
if err := h.SnippetSvc.Delete(uint(id), claims.ID, h.isStaff(c)); err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SnippetFork Fork 公开片段
func (h *Handlers) SnippetFork(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
snippet, err := h.SnippetSvc.Fork(uint(id), claims.ID)
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"snippet": snippet})
}
// SnippetCopy 记录复制(节流由 service 内部处理)
func (h *Handlers) SnippetCopy(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
key := c.ClientIP()
if claims := middleware.CurrentUser(c); claims != nil {
key = "user:" + strconv.FormatUint(uint64(claims.ID), 10)
}
if err := h.SnippetSvc.RecordCopy(uint(id), key); err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SnippetHistory 片段编辑历史
func (h *Handlers) SnippetHistory(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
items, total, err := h.SnippetSvc.ListHistory(uint(id), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取历史失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// SnippetForks 公开 fork 列表
func (h *Handlers) SnippetForks(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "12"))
items, total, err := h.SnippetSvc.ListForks(uint(id), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取 Fork 列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// SnippetComments 片段评论列表
func (h *Handlers) SnippetComments(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
items, total, err := h.SnippetSvc.ListComments(uint(id), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取评论失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// SnippetCommentCreate 创建片段评论
func (h *Handlers) SnippetCommentCreate(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
snippetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
var in struct {
Content string `json:"content"`
ParentID *uint `json:"parent_id"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
comment, err := h.SnippetSvc.CreateComment(claims.ID, uint(snippetID), in.Content, in.ParentID)
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"comment": comment})
}
// SnippetCommentUpdate 更新片段评论
func (h *Handlers) SnippetCommentUpdate(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
var in struct {
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
comment, err := h.SnippetSvc.UpdateComment(uint(cid), claims.ID, in.Content)
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"comment": comment})
}
// SnippetCommentDelete 删除片段评论
func (h *Handlers) SnippetCommentDelete(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
if err := h.SnippetSvc.DeleteComment(uint(cid), claims.ID, h.isStaff(c)); err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SnippetCommentHistory 片段评论编辑历史(登录即可见;分页)
func (h *Handlers) SnippetCommentHistory(c *gin.Context) {
claims := middleware.CurrentUser(c)
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil || cid == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
items, total, err := h.SnippetSvc.ListCommentHistory(h.loadActor(claims.ID), uint(cid), page, size)
if err != nil {
switch {
case errors.Is(err, service.ErrCommentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"})
}
return
}
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 10
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"size": size,
})
}
// AdminSnippetList 管理端片段列表
func (h *Handlers) AdminSnippetList(c *gin.Context) {
q := c.Query("q")
visibility := c.Query("visibility")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
items, total, err := h.SnippetSvc.AdminList(q, visibility, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取片段列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// AdminSnippetDelete 管理端软删片段
func (h *Handlers) AdminSnippetDelete(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
if err := h.SnippetSvc.AdminDelete(uint(id), claims.ID); err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminSnippetSetVisibility 管理端强制修改可见性
func (h *Handlers) AdminSnippetSetVisibility(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
var in struct {
Visibility string `json:"visibility"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.SnippetSvc.AdminSetVisibility(uint(id), in.Visibility); err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// isStaff 判断当前用户是否为管理角色
func (h *Handlers) isStaff(c *gin.Context) bool {
claims := middleware.CurrentUser(c)
if claims == nil {
return false
}
return model.IsStaff(model.Role(claims.Role))
}

View File

@@ -64,6 +64,7 @@ func InitDB(dsn string) error {
&Badge{}, &UserBadge{},
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{},
&ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{},
&CodeSnippet{}, &SnippetVersion{}, &SnippetComment{}, &SnippetCommentEditHistory{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}

View File

@@ -576,6 +576,75 @@ type LibrarySection struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// CodeSnippet 代码片段:用户所有,公开进广场,私有仅自己可见
type CodeSnippet struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:120;not null" json:"title"`
Description string `gorm:"type:text" json:"description"`
Language string `gorm:"size:32;not null;index" json:"language"`
Code string `gorm:"type:text;not null" json:"code"`
Visibility string `gorm:"size:16;not null;default:private;index" json:"visibility"` // public/private
UserID uint `gorm:"index;not null" json:"user_id"`
ForkedFromID *uint `gorm:"index" json:"forked_from_id"`
CopyCount int `gorm:"not null;default:0" json:"copy_count"`
ViewCount int `gorm:"not null;default:0" json:"view_count"`
CommentCount int `gorm:"not null;default:0" json:"comment_count"`
ForkCount int `gorm:"not null;default:0" json:"fork_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SnippetVersion 编辑前旧值快照(同 PostEditHistory 思路)
type SnippetVersion struct {
ID uint `gorm:"primaryKey" json:"id"`
SnippetID uint `gorm:"index;not null" json:"snippet_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
OldTitle string `gorm:"size:120;not null;default:''" json:"old_title"`
OldLanguage string `gorm:"size:32;not null;default:''" json:"old_language"`
OldDescription string `gorm:"type:text" json:"old_description"`
OldCode string `gorm:"type:text;not null" json:"old_code"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// SnippetComment 片段评论(楼层树:ParentID 空=主评,非空=回复;RootID=所属主楼)
type SnippetComment struct {
ID uint `gorm:"primaryKey" json:"id"`
SnippetID uint `gorm:"index;not null" json:"snippet_id"`
UserID uint `gorm:"index;not null" json:"user_id,omitempty"`
ParentID *uint `gorm:"index" json:"parent_id"`
RootID *uint `gorm:"index" json:"root_id"`
Depth int `gorm:"not null;default:0" json:"depth"`
Content string `gorm:"type:text;not null" json:"content"`
DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"`
DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SnippetCommentEditHistory 片段评论修订快照(每次编辑前的旧正文;登录用户可见)
type SnippetCommentEditHistory struct {
ID uint `gorm:"primaryKey" json:"id"`
CommentID uint `gorm:"index;not null" json:"comment_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
OldContent string `gorm:"type:text;not null" json:"old_content"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
const (
SnippetVisibilityPublic = "public"
SnippetVisibilityPrivate = "private"
)
// NormalizeSnippetVisibility 归一化可见性:空或未知值一律回落 private
func NormalizeSnippetVisibility(v string) string {
if v == SnippetVisibilityPublic {
return SnippetVisibilityPublic
}
return SnippetVisibilityPrivate
}
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
type SiteSetting struct {
Key string `gorm:"primaryKey;size:64" json:"key"`

View File

@@ -104,6 +104,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).
WithSetting(settingSvc).
WithUploadsDir(filepath.Join(cfg.DataDir, "uploads"))
snippetSvc := service.NewSnippetService(model.DB)
adminUserSvc := service.NewAdminUserService(model.DB)
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
moderationSvc := service.NewModerationService(model.DB, notifSvc)
@@ -165,6 +166,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Badge: badgeSvc,
LeaderboardSvc: leaderboardSvc,
LibrarySvc: librarySvc,
SnippetSvc: snippetSvc,
}
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
notifSvc.OnNotifyNew = func(userID uint) {
@@ -229,6 +231,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/library", h.LibraryList)
pubAPI.GET("/library/:slug", h.LibraryDetail)
pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload)
// 代码片段(公开广场/详情/评论/Fork 列表)
pubAPI.GET("/snippets", h.SnippetList)
pubAPI.GET("/snippets/languages", h.SnippetLanguages)
pubAPI.GET("/snippets/stats", h.SnippetStats)
pubAPI.GET("/snippets/:id", h.SnippetDetail)
pubAPI.GET("/snippets/:id/comments", h.SnippetComments)
pubAPI.GET("/snippets/:id/forks", h.SnippetForks)
pubAPI.GET("/site-state", h.SiteState)
// 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存)
pubAPI.GET("/img", h.ImageVariant)
@@ -308,6 +317,19 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 时间线:从 Git releases 页导入更新日志
api.POST("/timeline/from-releases", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromReleases)
// 代码片段(登录区)
api.POST("/snippets", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateSnippet)), h.SnippetCreate)
api.GET("/snippets/mine", h.SnippetMine)
api.PUT("/snippets/:id", h.SnippetUpdate)
api.DELETE("/snippets/:id", h.SnippetDelete)
api.POST("/snippets/:id/fork", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateSnippet)), h.SnippetFork)
api.POST("/snippets/:id/copy", h.SnippetCopy)
api.GET("/snippets/:id/history", h.SnippetHistory)
api.POST("/snippets/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.SnippetCommentCreate)
api.PUT("/snippets/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.SnippetCommentUpdate)
api.DELETE("/snippets/:id/comments/:cid", h.SnippetCommentDelete)
api.GET("/snippets/:id/comments/:cid/history", h.SnippetCommentHistory)
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
{
@@ -508,6 +530,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages)
}
// 代码片段管理(任意 staff)
staffAPI.GET("/snippets", h.AdminSnippetList)
staffAPI.DELETE("/snippets/:id", h.AdminSnippetDelete)
staffAPI.PUT("/snippets/:id/visibility", h.AdminSnippetSetVisibility)
// 板块管理(仅站长):增删改、排序、版主授权
boardsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermBoards))
{

View File

@@ -767,6 +767,18 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
return s.removeMember(roomID, targetID)
}
// DisplayName 取用户群聊展示名(昵称优先,退回用户名);用户不存在/封禁时返回空串
func (s *ChatService) DisplayName(userID uint) string {
var u model.User
if err := s.db.Select("id", "nickname", "username").First(&u, userID).Error; err != nil {
return ""
}
if u.Nickname != "" {
return u.Nickname
}
return u.Username
}
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。
// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览,
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。

View File

@@ -24,7 +24,7 @@ const (
// HeaderNavBuiltinKeys 内置菜单 key 白名单(顺序即默认顺序);
// 与前端 lib/headerNav.ts 的 HEADER_NAV_BUILTIN_KEYS 保持一致。
var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library"}
var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library", "snippets"}
// HeaderNavItem 顶部导航菜单项:
//

View File

@@ -25,18 +25,19 @@ func newHeaderNavTestService(t *testing.T) *SettingService {
func TestHeaderNavDefaultWhenMissing(t *testing.T) {
s := newHeaderNavTestService(t)
got := s.HeaderNav()
if len(got) != 3 ||
if len(got) != 4 ||
got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) ||
got[1] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "leaderboard"}) ||
got[2] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "library"}) {
got[2] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "library"}) ||
got[3] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "snippets"}) {
t.Fatalf("missing row should fall back to builtin defaults, got %+v", got)
}
pub, err := s.Public()
if err != nil {
t.Fatal(err)
}
if len(pub.HeaderNav) != 3 {
t.Fatalf("Public().HeaderNav len = %d, want 3", len(pub.HeaderNav))
if len(pub.HeaderNav) != 4 {
t.Fatalf("Public().HeaderNav len = %d, want 4", len(pub.HeaderNav))
}
}
@@ -133,7 +134,7 @@ func TestNormalizeHeaderNavDedupesBuiltins(t *testing.T) {
func TestParseHeaderNavJSONTolerant(t *testing.T) {
// 坏 JSON 回落默认
if got := parseHeaderNavJSON("{bad"); len(got) != 3 {
if got := parseHeaderNavJSON("{bad"); len(got) != 4 {
t.Fatalf("bad json should fall back to defaults, got %+v", got)
}
// 空数组 = 全部隐藏

View File

@@ -85,6 +85,7 @@ const (
RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP)
RateCaptcha = "captcha" // 验证码刷新 30/分钟
RateTelemetry = "telemetry" // 页面浏览埋点 120/分钟(按 IP)
RateSnippet = "snippet" // 代码片段创建/Fork 10/分钟
)
// DefaultRateLimiter 创建默认速率限制器
@@ -102,5 +103,6 @@ func DefaultRateLimiter() *RateLimiter {
rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟
rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟
rl.SetLimit(RateTelemetry, 120) // 埋点 120/分钟
rl.SetLimit(RateSnippet, 10) // 代码片段 10/分钟
return rl
}

1022
backend/service/snippet.go Normal file

File diff suppressed because it is too large Load Diff