移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:37:11 +08:00
parent 060b7707cb
commit 48db333272
121 changed files with 11147 additions and 3225 deletions

View File

@@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
@@ -52,7 +51,7 @@ func (h *Handlers) APIBoards(c *gin.Context) {
func (h *Handlers) APIStats(c *gin.Context) {
var userCount, postCount, boardCount int64
model.DB.Model(&model.User{}).Count(&userCount)
model.DB.Model(&model.Post{}).Count(&postCount)
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount)
c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount,
@@ -121,10 +120,12 @@ func (h *Handlers) APIAdminDeleteBoard(c *gin.Context) {
func (h *Handlers) APIAdminDashboard(c *gin.Context) {
var userCount, postCount, boardCount, commentCount int64
model.DB.Model(&model.User{}).Count(&userCount)
model.DB.Model(&model.Post{}).Count(&postCount)
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount)
model.DB.Model(&model.Comment{}).Count(&commentCount)
recentPosts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 8})
model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&commentCount)
recentPosts, _, _ := h.Post.List(service.PostListQuery{
Page: 1, Size: 8, ViewerIsAdmin: true, Status: "all",
})
if recentPosts == nil {
recentPosts = []model.Post{}
}
@@ -140,7 +141,11 @@ func (h *Handlers) APIAdminPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
keyword := strings.TrimSpace(c.Query("keyword"))
posts, total, err := h.Post.ListItems(service.PostListQuery{Page: page, Size: size, Keyword: keyword})
status := strings.TrimSpace(c.DefaultQuery("status", "all"))
posts, total, err := h.Post.ListItems(service.PostListQuery{
Page: page, Size: size, Keyword: keyword,
ViewerIsAdmin: true, Status: status,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -148,9 +153,12 @@ func (h *Handlers) APIAdminPosts(c *gin.Context) {
if posts == nil {
posts = []service.PostListItem{}
}
pending, _ := h.Post.PendingPostCount()
c.JSON(http.StatusOK, gin.H{
"posts": posts, "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
"total_pages": calcTotalPages(total, size),
"pending_count": pending,
"status": status,
})
}
@@ -196,21 +204,82 @@ func (h *Handlers) APIAdminPinPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned})
}
// APIAdminDeletePost 管理员删除帖子
// APIAdminFeaturePost 设为精华/取消精华JSON
func (h *Handlers) APIAdminFeaturePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Featured bool `json:"featured"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Post.SetFeatured(uint(id), req.Featured); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已取消精华"
if req.Featured {
msg = "已设为精华"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "featured": req.Featured})
}
// APIAdminDeletePost 管理员软删除帖子(进入回收站)
func (h *Handlers) APIAdminDeletePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Post.Delete(0, uint(id), true); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "帖子已删除"})
c.JSON(http.StatusOK, gin.H{"message": "帖子已移入回收站"})
}
// APIAdminTrashPosts 回收站帖子列表
func (h *Handlers) APIAdminTrashPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
keyword := strings.TrimSpace(c.Query("keyword"))
posts, total, err := h.Post.ListTrash(page, size, keyword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if posts == nil {
posts = []service.TrashPostItem{}
}
c.JSON(http.StatusOK, gin.H{
"posts": posts, "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminRestorePost 从回收站恢复帖子
func (h *Handlers) APIAdminRestorePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Post.Restore(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "帖子已恢复"})
}
// APIAdminPurgePost 永久删除回收站帖子
func (h *Handlers) APIAdminPurgePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Post.Purge(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "帖子已永久删除"})
}
// APIAdminComments 管理员评论列表
func (h *Handlers) APIAdminComments(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
comments, total, err := h.Comment.ListRecent(page, size)
status := strings.TrimSpace(c.DefaultQuery("status", "all"))
comments, total, err := h.Comment.ListRecent(page, size, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -218,12 +287,63 @@ func (h *Handlers) APIAdminComments(c *gin.Context) {
if comments == nil {
comments = []model.Comment{}
}
pending, _ := h.Comment.PendingCommentCount()
c.JSON(http.StatusOK, gin.H{
"comments": comments, "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
"total_pages": calcTotalPages(total, size),
"pending_count": pending,
"status": status,
})
}
// APIAdminApproveComment 通过评论审核
func (h *Handlers) APIAdminApproveComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Comment.SetStatus(uint(id), model.ContentStatusPublished); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "评论已通过审核", "status": model.ContentStatusPublished})
}
// APIAdminRejectComment 拒绝评论并私信通知
func (h *Handlers) APIAdminRejectComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Reason string `json:"reason"`
}
_ = c.ShouldBindJSON(&req)
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = "不符合社区规范"
}
comment, err := h.Comment.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.Comment.SetStatus(uint(id), model.ContentStatusRejected); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if comment.UserID > 0 {
title := comment.Post.Title
if title == "" {
title = "未知帖子"
}
pid := comment.PostID
_, _ = h.Message.SendSystem(
comment.UserID,
"评论未通过审核",
service.FormatCommentRejectContent(title, comment.PostID, comment.Floor, reason),
model.MessageKindReject,
&pid,
nil,
)
}
c.JSON(http.StatusOK, gin.H{"message": "已拒绝该评论并通知作者", "status": model.ContentStatusRejected})
}
// APIAdminDeleteComment 管理员删除评论
func (h *Handlers) APIAdminDeleteComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -234,6 +354,17 @@ func (h *Handlers) APIAdminDeleteComment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
}
// APIAdminCommentRevisions 管理员查看评论编辑历史
func (h *Handlers) APIAdminCommentRevisions(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
revs, err := h.Comment.ListRevisions(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"revisions": revs})
}
// APIAdminUsers 管理员用户列表
func (h *Handlers) APIAdminUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
@@ -314,6 +445,7 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) {
"oidc": h.Settings.OIDCConfigPublic(),
"oauth_clients": clients,
"gitea": h.Settings.GiteaSyncConfigPublic(),
"storage": h.Settings.StorageConfigPublic(),
"branding": h.Settings.SiteBranding(),
"filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent),
@@ -342,11 +474,11 @@ func (h *Handlers) APIAdminUpdateBranding(c *gin.Context) {
})
}
// APIAdminUploadBrandingAsset 上传 Logo Faviconform: file + kind=logo|favicon
// APIAdminUploadBrandingAsset 上传 Logo / Favicon / 默认 OG 图form: file + kind=logo|favicon|og_image
func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) {
kind := strings.TrimSpace(c.PostForm("kind"))
if kind != "logo" && kind != "favicon" {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logofavicon"})
if kind != "logo" && kind != "favicon" && kind != "og_image" {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logofavicon 或 og_image"})
return
}
file, err := c.FormFile("file")
@@ -359,18 +491,22 @@ func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 2MB"})
return
}
url, err := service.SaveUploadedImage(file, h.Cfg.SiteUploadDir(), "/uploads/site", kind)
url, err := service.SaveUploadedImage(h.Store, file, service.UploadCategorySite, kind)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prev := h.Settings.SiteBranding()
if kind == "logo" {
switch kind {
case "logo":
_ = h.Settings.SetSiteLogo(url)
h.removeSiteUploadIfLocal(prev.Logo)
} else {
h.Store.DeleteByURL(prev.Logo)
case "favicon":
_ = h.Settings.SetSiteFavicon(url)
h.removeSiteUploadIfLocal(prev.Favicon)
h.Store.DeleteByURL(prev.Favicon)
case "og_image":
_ = h.Settings.SetSiteOGImage(url)
h.Store.DeleteByURL(prev.OGImage)
}
c.JSON(http.StatusOK, gin.H{
"message": "上传成功",
@@ -379,7 +515,7 @@ func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) {
})
}
// APIAdminClearBrandingAsset 清除 Logo Favicon
// APIAdminClearBrandingAsset 清除 Logo / Favicon / 默认 OG 图
func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) {
var req struct {
Kind string `json:"kind"`
@@ -393,12 +529,15 @@ func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) {
switch kind {
case "logo":
_ = h.Settings.SetSiteLogo("")
h.removeSiteUploadIfLocal(brand.Logo)
h.Store.DeleteByURL(brand.Logo)
case "favicon":
_ = h.Settings.SetSiteFavicon("")
h.removeSiteUploadIfLocal(brand.Favicon)
h.Store.DeleteByURL(brand.Favicon)
case "og_image":
_ = h.Settings.SetSiteOGImage("")
h.Store.DeleteByURL(brand.OGImage)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logofavicon"})
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logofavicon 或 og_image"})
return
}
c.JSON(http.StatusOK, gin.H{
@@ -505,6 +644,27 @@ func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) {
})
}
// APIAdminUpdateStorageSettings 更新上传存储(本地 / S3 兼容),保存后立即热切换
func (h *Handlers) APIAdminUpdateStorageSettings(c *gin.Context) {
var req service.StorageConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateStorageConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.Store.ReloadFromSettings(h.Settings); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "配置已保存,但初始化存储失败:" + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "存储设置已保存",
"storage": h.Settings.StorageConfigPublic(),
})
}
// APIAdminSyncGitea 立即同步 Gitea 公开仓库
func (h *Handlers) APIAdminSyncGitea(c *gin.Context) {
if h.Gitea == nil {
@@ -666,12 +826,14 @@ func (h *Handlers) APIPosts(c *gin.Context) {
keyword := c.Query("keyword")
q := service.PostListQuery{
BoardID: uint(boardID),
UserID: uint(userID),
Page: page,
Size: size,
Keyword: keyword,
Sort: c.DefaultQuery("sort", "latest"),
BoardID: uint(boardID),
UserID: uint(userID),
Page: page,
Size: size,
Keyword: keyword,
Sort: c.DefaultQuery("sort", "latest"),
ViewerID: h.currentUserID(c),
ViewerIsAdmin: h.isAdmin(c),
}
items, total, err := h.Post.ListItems(q)
if err != nil {
@@ -702,15 +864,19 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
if c.Query("skip_view") != "1" {
uid := h.currentUserID(c)
isAdmin := h.isAdmin(c)
if !service.CanViewPost(post, uid, isAdmin) {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
if c.Query("skip_view") != "1" && post.Status == model.ContentStatusPublished {
h.Post.RecordView(uint(id))
}
uid := h.currentUserID(c)
if uid == 0 {
post.Content = service.RedactMembersOnlyHTML(post.Content)
}
comments, _ := h.Comment.ListByPost(uint(id), uid, h.isAdmin(c), post.UserID, h.parseGuestCommentIDs(c))
isAdmin := h.isAdmin(c)
comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c))
canEdit := h.Post.CanUserEdit(post, uid, isAdmin)
editReason := ""
if !canEdit && uid > 0 {
@@ -737,7 +903,13 @@ func (h *Handlers) APIPostComments(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
comments, err := h.Comment.ListByPost(uint(id), h.currentUserID(c), h.isAdmin(c), post.UserID, h.parseGuestCommentIDs(c))
uid := h.currentUserID(c)
isAdmin := h.isAdmin(c)
if !service.CanViewPost(post, uid, isAdmin) {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
comments, err := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -842,20 +1014,6 @@ func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"revision": rev})
}
// removeSiteUploadIfLocal 删除本站 uploads/site 下的旧资源文件
func (h *Handlers) removeSiteUploadIfLocal(urlPath string) {
urlPath = strings.TrimSpace(urlPath)
const prefix = "/uploads/site/"
if !strings.HasPrefix(urlPath, prefix) {
return
}
name := filepath.Base(urlPath)
if name == "" || name == "." || name == ".." {
return
}
_ = os.Remove(filepath.Join(h.Cfg.SiteUploadDir(), name))
}
func isClientLimitError(err error) bool {
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
errors.Is(err, service.ErrSearchKeywordTooLong)