feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
424
backend/handler/snippet.go
Normal 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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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:系统提示不作为会话预览,
|
||||
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。
|
||||
|
||||
@@ -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 顶部导航菜单项:
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
// 空数组 = 全部隐藏
|
||||
|
||||
@@ -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
1022
backend/service/snippet.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user