diff --git a/LICENSE b/LICENSE index fb5d833..bece6fd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,38 +1,202 @@ -姜十三论坛 专有软件许可协议 -Proprietary Software License Agreement + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Copyright (c) 2026 姜十三. All Rights Reserved. -版权所有 (c) 2026 姜十三。保留所有权利。 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -本软件(包括但不限于源代码、目标代码、文档、图像及其他随附资料, -以下统称"本软件")由版权方(以下称"授权方")开发并所有。本软件 -以源码可见的方式发布,但这并不构成任何形式的开源授权。 + 1. Definitions. -在未获得授权方事先书面许可之前,任何个人或组织不得: + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -1. 以任何目的(包括商业、内部生产、教学培训等)使用、运行或部署 - 本软件的全部或任何部分; -2. 复制、影印、转载、镜像、收录到任何数据库或以任何方式再现本软件; -3. 修改、改编、翻译、合并、反向工程、反编译、反汇编本软件,或基于 - 本软件创作衍生作品; -4. 出售、出租、出借、分销、再许可、托管(SaaS)或以其他方式向第三方 - 提供本软件或其任何副本; -5. 删除、遮盖或更改本软件中的版权声明、商标或其他权利声明。 + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. -仅可出于个人学习、评估是否购买授权的目的浏览本仓库中的源代码;该等 -浏览不视为获得任何明示或默示的使用许可。 + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. -商业使用、内部部署或其他任何形式的使用授权,均须与授权方另行签订 -书面许可协议。 + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. -第三方开源组件按其各自附带的许可证条款授权,不受本协议约束。 + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. -本软件按"现状"(AS IS)提供,不附带任何明示或默示的担保,包括但不限 -于适销性、特定用途适用性或不侵权的担保。在任何情况下,授权方均不对因 -使用或无法使用本软件而导致的任何直接、间接、附带、特殊或后果性损害 -承担责任。 + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. -未经授权使用本软件的行为,均构成对授权方著作权及其他知识产权的侵犯, -授权方保留依法追究法律责任的权利。 + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). -授权方联系方式:aarbbs@88.com + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that the copyright owner or their authorized + representative conspicuously designates in writing as "Not a + Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with the conditions + stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides their Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The boilerplate notice should be enclosed in + appropriate comment syntax for the file format. We also + recommend that a file or class name and description of purpose be + included on the same "printed page" as the copyright notice for + easier identification within third-party archives. + + Copyright 2026 姜十三 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index c74efd0..398a714 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,128 @@ +
+
+
+
+
微信打赏 | 支付宝打赏
diff --git a/backend/handler/chat.go b/backend/handler/chat.go index 49c2010..f13d0a9 100644 --- a/backend/handler/chat.go +++ b/backend/handler/chat.go @@ -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}) } diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 6217744..a784a56 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -42,6 +42,7 @@ type Handlers struct { Badge *service.BadgeService LeaderboardSvc *service.LeaderboardService LibrarySvc *service.LibraryService + SnippetSvc *service.SnippetService } // resolvePublishStatus 决定新帖/新评的初始状态: diff --git a/backend/handler/snippet.go b/backend/handler/snippet.go new file mode 100644 index 0000000..f96b7fd --- /dev/null +++ b/backend/handler/snippet.go @@ -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)) +} diff --git a/backend/model/db.go b/backend/model/db.go index d4c5428..9c659d3 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -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) } diff --git a/backend/model/models.go b/backend/model/models.go index c623704..c927ac3 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -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"` diff --git a/backend/router/router.go b/backend/router/router.go index 5e9219f..f1e486f 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -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)) { diff --git a/backend/service/chat.go b/backend/service/chat.go index 9e24efa..5acebdc 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -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:系统提示不作为会话预览, // 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。 diff --git a/backend/service/header_nav.go b/backend/service/header_nav.go index eee8e73..dde66a7 100644 --- a/backend/service/header_nav.go +++ b/backend/service/header_nav.go @@ -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 顶部导航菜单项: // diff --git a/backend/service/header_nav_test.go b/backend/service/header_nav_test.go index 72df41b..02f5703 100644 --- a/backend/service/header_nav_test.go +++ b/backend/service/header_nav_test.go @@ -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) } // 空数组 = 全部隐藏 diff --git a/backend/service/ratelimit.go b/backend/service/ratelimit.go index dbdbdb5..5fb9aa0 100644 --- a/backend/service/ratelimit.go +++ b/backend/service/ratelimit.go @@ -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 } diff --git a/backend/service/snippet.go b/backend/service/snippet.go new file mode 100644 index 0000000..c2338dc --- /dev/null +++ b/backend/service/snippet.go @@ -0,0 +1,1022 @@ +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 +} diff --git a/docs/images/wechat.png b/docs/images/wechat.png new file mode 100644 index 0000000..91f605b Binary files /dev/null and b/docs/images/wechat.png differ diff --git a/docs/images/zfb.png b/docs/images/zfb.png new file mode 100644 index 0000000..2a31bd4 Binary files /dev/null and b/docs/images/zfb.png differ diff --git a/docs/images/书库.png b/docs/images/书库.png new file mode 100644 index 0000000..fdb74f8 Binary files /dev/null and b/docs/images/书库.png differ diff --git a/docs/images/代码片段.png b/docs/images/代码片段.png new file mode 100644 index 0000000..fc0a5f3 Binary files /dev/null and b/docs/images/代码片段.png differ diff --git a/docs/images/帖子详情.png b/docs/images/帖子详情.png new file mode 100644 index 0000000..2b9f1a6 Binary files /dev/null and b/docs/images/帖子详情.png differ diff --git a/docs/images/管理后台.png b/docs/images/管理后台.png new file mode 100644 index 0000000..bf1cc1a Binary files /dev/null and b/docs/images/管理后台.png differ diff --git a/docs/images/访问统计.png b/docs/images/访问统计.png new file mode 100644 index 0000000..3a1bb15 Binary files /dev/null and b/docs/images/访问统计.png differ diff --git a/docs/images/首页.png b/docs/images/首页.png new file mode 100644 index 0000000..158b681 Binary files /dev/null and b/docs/images/首页.png differ diff --git a/frontend/app/admin/settings/HeaderNavPanel.tsx b/frontend/app/admin/settings/HeaderNavPanel.tsx index 9ce8107..da7c41d 100644 --- a/frontend/app/admin/settings/HeaderNavPanel.tsx +++ b/frontend/app/admin/settings/HeaderNavPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowDown, ArrowUp, BookOpen, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react"; +import { ArrowDown, ArrowUp, BookOpen, CodeXml, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react"; import { useEffect, useState } from "react"; import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin"; import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; @@ -19,6 +19,7 @@ const BUILTIN_ICONS: Record{error}
: null} + + {loading && items.length === 0 ? ( +