Files
jiang13-forum/handler/api.go
freefire ba60a9b1c4 feat: 调整 Feed 排序为新评论/新帖子/推荐帖
默认按新评论;推荐排序优先 featured,并将用户可见「精华」文案改为「推荐」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 01:59:01 +08:00

1231 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service"
"github.com/gin-gonic/gin"
)
// APIMe 当前登录用户
func (h *Handlers) APIMe(c *gin.Context) {
uid := h.currentUserID(c)
if uid == 0 {
c.JSON(http.StatusOK, gin.H{"user": nil})
return
}
user, err := h.User.GetByID(uid)
if err != nil {
// 账号已删或不存在:清掉失效 cookie与未登录态一致
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"user": nil})
return
}
if h.Badge != nil {
_ = h.Badge.EvaluateAuto(user.ID)
}
view := user.ToSelf()
if h.Badge != nil {
if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil {
view.Badges = service.BadgeViews(badges, 0)
}
}
c.JSON(http.StatusOK, gin.H{
"user": view,
})
}
// APIBoards 板块列表(含帖子数)
func (h *Handlers) APIBoards(c *gin.Context) {
boards, err := h.Board.ListWithStats()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if boards == nil {
boards = []service.BoardWithStats{}
}
c.JSON(http.StatusOK, gin.H{"boards": boards})
}
// APIHealth 健康检查(容器探活 / 负载均衡)
func (h *Handlers) APIHealth(c *gin.Context) {
if err := model.PingDB(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unavailable",
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// APIStats 论坛概览统计
func (h *Handlers) APIStats(c *gin.Context) {
var userCount, postCount, boardCount, commentCount int64
model.DB.Model(&model.User{}).Count(&userCount)
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount)
model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&commentCount)
c.JSON(http.StatusOK, gin.H{
"users": userCount,
"posts": postCount,
"boards": boardCount,
"comments": commentCount,
})
}
// APIForumLimits 前台可见的论坛限制配置
func (h *Handlers) APIForumLimits(c *gin.Context) {
c.JSON(http.StatusOK, h.Settings.PublicLimits())
}
// APIAdminCreateBoard 管理员创建板块JSON
func (h *Handlers) APIAdminCreateBoard(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Icon string `json:"icon"`
ColorIndex int `json:"color_index"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
board, err := h.Board.Create(req.Name, req.Description, req.Icon, req.ColorIndex, req.SortOrder)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "板块已创建", "board": board})
}
// APIAdminUpdateBoard 管理员更新板块
func (h *Handlers) APIAdminUpdateBoard(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Icon string `json:"icon"`
ColorIndex int `json:"color_index"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Board.Update(uint(id), req.Name, req.Description, req.Icon, req.ColorIndex, req.SortOrder); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
board, _ := h.Board.GetByID(uint(id))
c.JSON(http.StatusOK, gin.H{"message": "板块已更新", "board": board})
}
// APIAdminDeleteBoard 管理员删除板块
func (h *Handlers) APIAdminDeleteBoard(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Board.Delete(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "板块已删除"})
}
// APIAdminDashboard 管理后台概览
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{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount)
model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&commentCount)
pendingPosts, _ := h.Post.PendingPostCount()
pendingComments, _ := h.Comment.PendingCommentCount()
pendingReports, _ := h.Report.PendingCount()
pendingFriendLinks, _ := h.FriendLinkApply.PendingCount()
recentPosts, _, _ := h.Post.List(service.PostListQuery{
Page: 1, Size: 8, ViewerIsAdmin: true, Status: "all",
})
if recentPosts == nil {
recentPosts = []model.Post{}
}
traffic := service.DashboardTraffic{}
if h.Monitor != nil {
traffic = h.Monitor.DashboardTraffic()
} else {
traffic.Enabled = h.Settings.MonitorEnabled()
}
c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount,
"comments": commentCount,
"pending_posts": pendingPosts,
"pending_comments": pendingComments,
"pending_reports": pendingReports,
"pending_friend_links": pendingFriendLinks,
"recent_posts": recentPosts,
"traffic": traffic,
})
}
// APIAdminPosts 管理员帖子列表
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"))
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
}
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),
"pending_count": pending,
"status": status,
})
}
// APIAdminLockPost 锁定/解锁帖子编辑
func (h *Handlers) APIAdminLockPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Locked bool `json:"locked"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Post.SetEditLocked(uint(id), req.Locked); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已解锁编辑"
if req.Locked {
msg = "已锁定编辑"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "edit_locked": req.Locked})
}
// APIAdminCommentsLockPost 锁定/解锁讨论(禁止新评论)
func (h *Handlers) APIAdminCommentsLockPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Locked bool `json:"locked"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Post.SetCommentsLocked(uint(id), req.Locked); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已开放讨论"
if req.Locked {
msg = "已锁定讨论"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "comments_locked": req.Locked})
}
// APIAdminPinPost 全局置顶/取消全局置顶JSON
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Pinned bool `json:"pinned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Post.SetPinned(uint(id), req.Pinned); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已取消全局置顶"
if req.Pinned {
msg = "已全局置顶"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned})
}
// APIAdminBoardPinPost 板块内置顶/取消板块内置顶JSON
func (h *Handlers) APIAdminBoardPinPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
BoardPinned bool `json:"board_pinned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Post.SetBoardPinned(uint(id), req.BoardPinned); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已取消板块置顶"
if req.BoardPinned {
msg = "已板块置顶"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "board_pinned": req.BoardPinned})
}
// 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": "帖子已移入回收站"})
}
// 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"))
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
}
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),
"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
}
if h.Notify != nil {
if comment, err := h.Comment.GetByID(uint(id)); err == nil {
comment.Status = model.ContentStatusPublished
h.Notify.AsyncNotifyCommentPublished(comment)
h.Notify.AsyncNotifyCommentMentions(comment)
}
}
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)
if err := h.Comment.AdminDelete(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "评论已移入回收站"})
}
// APIAdminTrashComments 评论回收站列表
func (h *Handlers) APIAdminTrashComments(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
keyword := strings.TrimSpace(c.Query("keyword"))
comments, total, err := h.Comment.ListTrash(page, size, keyword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if comments == nil {
comments = []service.TrashCommentItem{}
}
c.JSON(http.StatusOK, gin.H{
"comments": comments, "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminRestoreComment 从回收站恢复评论
func (h *Handlers) APIAdminRestoreComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Comment.Restore(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "评论已恢复"})
}
// APIAdminPurgeComment 永久删除回收站评论
func (h *Handlers) APIAdminPurgeComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Comment.Purge(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
keyword := strings.TrimSpace(c.Query("keyword"))
filter := strings.TrimSpace(c.DefaultQuery("filter", "all"))
users, total, err := h.User.ListUsers(service.UserListQuery{
Page: page, Size: size, Keyword: keyword, Filter: filter,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if users == nil {
users = []model.User{}
}
c.JSON(http.StatusOK, gin.H{
"users": model.UsersToAdmin(users), "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
"keyword": keyword,
"filter": filter,
})
}
// APIAdminBanUser 禁言/解除禁言JSON
func (h *Handlers) APIAdminBanUser(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Banned bool `json:"banned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.User.BanUser(uint(id), req.Banned); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已解除禁言"
if req.Banned {
msg = "已禁言"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "banned": req.Banned})
}
// APIAdminBackup 导出 SQLite 备份
func (h *Handlers) APIAdminBackup(c *gin.Context) {
path, err := h.Backup.ExportSQLite()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename := filepath.Base(path)
c.JSON(http.StatusOK, gin.H{
"message": "备份成功",
"filename": filename,
"download": "/api/admin/backup/download/" + filename,
})
}
// APIAdminDownloadBackup 下载备份文件
func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
name := c.Param("name")
if !strings.HasPrefix(name, "jiang13_backup_") || !strings.HasSuffix(name, ".db") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的备份文件名"})
return
}
path := filepath.Join(h.Cfg.DataDir, name)
c.FileAttachment(path, name)
}
// APIAdminSettings 系统设置信息
func (h *Handlers) APIAdminSettings(c *gin.Context) {
limits := h.Settings.Limits()
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
clients, _ := h.Settings.ListOAuthClients()
c.JSON(http.StatusOK, gin.H{
"filter_path": h.Cfg.FilterWordsPath(),
"data_dir": h.Cfg.DataDir,
"db_path": h.Cfg.DBPath(),
"port": h.Cfg.Port,
"limits": limits,
"mail": h.Settings.MailConfigPublic(),
"oidc": h.Settings.OIDCConfigPublic(),
"oauth_clients": clients,
"gitea": h.Settings.GiteaSyncConfigPublic(),
"storage": h.Settings.StorageConfigPublic(),
"branding": h.Settings.SiteBranding(),
"community": h.Settings.CommunityConfigForRequest(communityRequestOrigin(c)),
"filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent),
})
}
// APISiteBranding 前台公开的站点品牌配置
func (h *Handlers) APISiteBranding(c *gin.Context) {
brand := h.Settings.SiteBranding()
brand.SiteURL = h.publicBaseURL(c)
c.JSON(http.StatusOK, brand)
}
// APIAdminUpdateBranding 更新站点品牌文案
func (h *Handlers) APIAdminUpdateBranding(c *gin.Context) {
var req service.SiteBranding
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateSiteBranding(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "站点品牌已保存",
"branding": h.Settings.SiteBranding(),
})
}
// 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" && kind != "og_image" {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo、favicon 或 og_image"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
return
}
const maxBytes = 2 * 1024 * 1024
if file.Size > maxBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 2MB"})
return
}
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()
switch kind {
case "logo":
_ = h.Settings.SetSiteLogo(url)
h.Store.DeleteByURL(prev.Logo)
case "favicon":
_ = h.Settings.SetSiteFavicon(url)
h.Store.DeleteByURL(prev.Favicon)
case "og_image":
_ = h.Settings.SetSiteOGImage(url)
h.Store.DeleteByURL(prev.OGImage)
}
c.JSON(http.StatusOK, gin.H{
"message": "上传成功",
"url": url,
"branding": h.Settings.SiteBranding(),
})
}
// APIAdminClearBrandingAsset 清除 Logo / Favicon / 默认 OG 图
func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) {
var req struct {
Kind string `json:"kind"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
kind := strings.TrimSpace(req.Kind)
brand := h.Settings.SiteBranding()
switch kind {
case "logo":
_ = h.Settings.SetSiteLogo("")
h.Store.DeleteByURL(brand.Logo)
case "favicon":
_ = h.Settings.SetSiteFavicon("")
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 须为 logo、favicon 或 og_image"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "已清除",
"branding": h.Settings.SiteBranding(),
})
}
// APIAdminUpdateForumSettings 更新论坛设置
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
var req service.ForumLimits
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateLimits(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "设置已保存",
"limits": h.Settings.Limits(),
})
}
// APIAdminUpdateMailSettings 更新邮件 SMTP 配置
func (h *Handlers) APIAdminUpdateMailSettings(c *gin.Context) {
var req service.MailConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateMailConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "邮件设置已保存",
"mail": h.Settings.MailConfigPublic(),
})
}
// APIAdminUpdateOIDCSettings 更新 OIDC Provider 全局配置
func (h *Handlers) APIAdminUpdateOIDCSettings(c *gin.Context) {
var req service.OIDCConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateOIDCConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "OIDC 设置已保存",
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIProjects 会员公开 Gitea 项目列表(本地缓存)
func (h *Handlers) APIProjects(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("limit", c.DefaultQuery("size", "30")))
q := strings.TrimSpace(c.Query("q"))
if page < 1 {
page = 1
}
if size < 1 {
size = 30
}
if size > 100 {
size = 100
}
if h.Gitea == nil {
c.JSON(http.StatusOK, gin.H{"projects": []any{}, "total": 0, "page": page, "total_pages": 0})
return
}
list, total, err := h.Gitea.ListPublic(page, size, q)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
return
}
list = service.AttachGiteaOwners(list, h.Badge)
c.JSON(http.StatusOK, gin.H{
"projects": list,
"total": total,
"page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminUpdateGiteaSettings 更新 Gitea 同步配置
func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) {
var req service.GiteaSyncConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateGiteaSyncConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Gitea 同步设置已保存",
"gitea": h.Settings.GiteaSyncConfigPublic(),
})
}
// 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 {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrGiteaNotConfigured.Error()})
return
}
n, err := h.Gitea.SyncRepos()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("同步完成,共更新 %d 个仓库", n),
"count": n,
"gitea": h.Settings.GiteaSyncConfigPublic(),
})
}
// APIAdminListOAuthClients 列出 OAuth 应用
func (h *Handlers) APIAdminListOAuthClients(c *gin.Context) {
list, err := h.Settings.ListOAuthClients()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
return
}
c.JSON(http.StatusOK, gin.H{"clients": list})
}
// APIAdminCreateOAuthClient 创建 OAuth 应用
func (h *Handlers) APIAdminCreateOAuthClient(c *gin.Context) {
var req service.OAuthClientInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
view, err := h.Settings.CreateOAuthClient(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "应用已创建,请立即保存客户端密钥(仅显示一次)",
"client": view,
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIAdminUpdateOAuthClient 更新 OAuth 应用
func (h *Handlers) APIAdminUpdateOAuthClient(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
return
}
var req service.OAuthClientInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
view, err := h.Settings.UpdateOAuthClient(uint(id), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "应用已更新"
if view.ClientSecret != "" {
msg = "应用已更新,新密钥仅显示一次,请立即保存"
}
c.JSON(http.StatusOK, gin.H{
"message": msg,
"client": view,
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIAdminDeleteOAuthClient 删除 OAuth 应用
func (h *Handlers) APIAdminDeleteOAuthClient(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
return
}
if err := h.Settings.DeleteOAuthClient(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "应用已删除",
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIAdminTestMail 发送测试邮件
func (h *Handlers) APIAdminTestMail(c *gin.Context) {
var req struct {
To string `json:"to" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写收件邮箱"})
return
}
if err := service.ValidateEmail(req.To); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !h.Settings.MailReady() {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
return
}
siteName := h.Settings.SiteBranding().Name
err := h.Mail.Send(service.NormalizeEmail(req.To), "邮件配置测试",
fmt.Sprintf("这是一封来自%s的测试邮件说明 SMTP 配置正常。", siteName))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "测试邮件已发送"})
}
// APIAdminFilterWords 读取敏感词配置
func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
content, err := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取敏感词配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"content": content,
"word_count": service.CountFilterWords(content),
"path": h.Cfg.FilterWordsPath(),
})
}
// APIAdminUpdateFilterWords 更新敏感词配置
func (h *Handlers) APIAdminUpdateFilterWords(c *gin.Context) {
var req struct {
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := service.WriteFilterWordsFile(h.Cfg.FilterWordsPath(), req.Content, h.Filter); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存敏感词配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "敏感词已保存并生效",
"word_count": service.CountFilterWords(req.Content),
})
}
// APIPosts 帖子列表(分页)
func (h *Handlers) APIPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", strconv.Itoa(h.Settings.PageSizeDefault())))
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64)
keyword := c.Query("keyword")
tag := strings.TrimSpace(c.Query("tag"))
author := strings.TrimSpace(c.Query("author"))
titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
q := service.PostListQuery{
BoardID: uint(boardID),
UserID: uint(userID),
Page: page,
Size: size,
Keyword: keyword,
Tag: tag,
Author: author,
TitleOnly: titleOnly,
Sort: c.DefaultQuery("sort", "reply"),
ViewerID: h.currentUserID(c),
ViewerIsAdmin: h.isAdmin(c),
}
items, total, err := h.Post.ListItems(q)
if err != nil {
if isClientLimitError(err) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if items == nil {
items = []service.PostListItem{}
}
if h.Badge != nil {
users := make([]*model.User, 0, len(items))
for i := range items {
if items[i].User.ID > 0 {
users = append(users, &items[i].User)
}
}
h.Badge.AttachBadgeSummaries(users, 2)
}
c.JSON(http.StatusOK, gin.H{
"posts": items,
"total": total,
"page": page,
"size": size,
"has_more": int64(page*size) < total,
})
}
// APIPostDetail 帖子详情
func (h *Handlers) APIPostDetail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
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))
}
// 出口再消毒:兼容库内历史脏 HTML如 <style>),避免旧帖污染整页
post.Content = service.SanitizePostHTML(post.Content)
hasReplied := uid > 0 && h.Comment.HasUserReplied(uint(id), uid)
if uid == 0 {
post.Content = service.RedactMembersOnlyHTML(post.Content)
post.Content = service.RedactReplyOnlyHTML(post.Content)
} else if !isAdmin && post.UserID != uid && !hasReplied {
// 作者与管理员始终可见;其他用户需已回复
post.Content = service.RedactReplyOnlyHTML(post.Content)
}
// 积分解锁块:作者/站长全文;其他人按解锁记录 redact
if isAdmin || post.UserID == uid {
post.Content = service.RevealAllPointsOnly(post.Content)
} else {
unlocked, _ := service.ListUnlockedKeys(uid, uint(id))
post.Content = service.RedactPointsOnlyHTML(post.Content, unlocked)
}
comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c))
if h.Badge != nil {
if post.User.ID > 0 {
h.Badge.AttachBadgeSummaries([]*model.User{&post.User}, 3)
}
h.Badge.AttachBadgeSummariesOnComments(comments, 2)
}
canEdit := h.Post.CanUserEdit(post, uid, isAdmin)
editReason := ""
if !canEdit && uid > 0 {
editReason = h.Post.UserEditBlockReason(post, uid, isAdmin)
}
isEdited := post.UpdatedAt.Sub(post.CreatedAt) > time.Minute
resp := gin.H{
"post": post,
"comment_count": len(comments),
"liked": h.Post.IsLiked(uid, uint(id)),
"favorited": h.Post.IsFavorited(uid, uint(id)),
"has_replied": hasReplied,
"can_edit": canEdit,
"edit_block_reason": editReason,
"is_edited": isEdited,
"post_edit_window_hours": h.Settings.PostEditWindowHours(),
}
if post.PostType == model.PostTypePoll {
if poll, err := service.GetPollView(uint(id), uid); err == nil {
resp["poll"] = poll
}
}
if post.PostType == model.PostTypeLottery {
if lottery, err := service.GetPostLotteryView(post); err == nil && lottery != nil {
resp["lottery"] = lottery
}
}
if post.PostType == model.PostTypeBounty && post.BountyStatus == model.BountyStatusOpen && post.BountyPoints > 0 {
canRefund, blockReason := service.CanRefundBounty(post, isAdmin)
resp["bounty_can_refund"] = canRefund
resp["bounty_refund_block_reason"] = blockReason
if n, err := service.CountEligibleBountyReplies(model.DB, post.ID, post.UserID); err == nil {
resp["bounty_eligible_reply_count"] = n
}
}
c.JSON(http.StatusOK, resp)
}
// APIPostComments 楼层列表
func (h *Handlers) APIPostComments(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
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
}
if comments == nil {
comments = []model.Comment{}
}
if h.Badge != nil {
h.Badge.AttachBadgeSummariesOnComments(comments, 2)
}
c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)})
}
// APIHotPosts 近期活跃讨论(近 7 日有回复)
func (h *Handlers) APIHotPosts(c *gin.Context) {
items, err := h.Post.HotPosts(10)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"posts": items})
}
// APITags 标签云(按使用次数聚合)
func (h *Handlers) APITags(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40"))
tags, err := h.Post.PopularTags(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
// APIRecentComments 最新公开评论
func (h *Handlers) APIRecentComments(c *gin.Context) {
list, err := h.Comment.ListRecentPublic(8)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []service.RecentCommentItem{}
}
c.JSON(http.StatusOK, gin.H{"comments": list})
}
// APIRecentUsers 最新注册用户(右栏)
func (h *Handlers) APIRecentUsers(c *gin.Context) {
list, err := h.User.ListRecentRegistered(8)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []service.RecentUserItem{}
}
c.JSON(http.StatusOK, gin.H{"users": list})
}
// APIFavorites 我的收藏
func (h *Handlers) APIFavorites(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
favs, total, err := h.Post.ListFavorites(h.currentUserID(c), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if favs == nil {
favs = []model.PostFavorite{}
}
c.JSON(http.StatusOK, gin.H{"favorites": favs, "total": total, "page": page})
}
// APIPostRevisions 帖子编辑历史列表(作者或管理员)
func (h *Handlers) APIPostRevisions(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
uid := h.currentUserID(c)
isAdmin := h.isAdmin(c)
if !isAdmin && post.UserID != uid {
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"})
return
}
revs, err := h.Post.ListRevisions(uint(id))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"revisions": revs})
}
// APIPostRevisionDetail 查看某个历史版本
func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
revID, _ := strconv.ParseUint(c.Param("revId"), 10, 64)
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
uid := h.currentUserID(c)
isAdmin := h.isAdmin(c)
if !isAdmin && post.UserID != uid {
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"})
return
}
rev, err := h.Post.GetRevision(uint(id), uint(revID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"revision": rev})
}
func isClientLimitError(err error) bool {
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
errors.Is(err, service.ErrSearchKeywordTooLong)
}