Files
jiang13-forum/routers/web/admin.go
freefire c32c81d1ca feat: SSR 评论回复与 @ 提及通知
发评/审通接入 NotifyService,系统通知会话支持 kind 筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 21:51:52 +08:00

679 lines
18 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 web
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// AdminChrome 后台布局公共字段
type AdminChrome struct {
PageChrome
NavActive string
}
func (d Deps) adminChrome(ctx *webctx.Context, title, nav string) AdminChrome {
site := d.Settings.SiteBranding().Name
if title == "" {
title = "管理后台 · " + site
} else {
title = title + " · " + site
}
base := d.chrome(ctx, title, "", "")
return AdminChrome{PageChrome: base, NavActive: nav}
}
type adminDashData struct {
AdminChrome
UserCount int64
PostCount int64
PendingPosts int64
PendingComments int64
PendingReports int64
BoardCount int64
}
// AdminDashboard 概览
func (d Deps) AdminDashboard(c *gin.Context) {
ctx := d.ctx(c)
var users, posts, boards int64
_ = models.DB.Model(&models.User{}).Count(&users).Error
_ = models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&posts).Error
_ = models.DB.Model(&models.Board{}).Count(&boards).Error
pendingPosts, _ := d.Post.PendingPostCount()
pendingComments, _ := d.Comment.PendingCommentCount()
var pendingReports int64
if d.Report != nil {
pendingReports, _ = d.Report.PendingCount()
}
ctx.HTML(http.StatusOK, "admin/dashboard", adminDashData{
AdminChrome: d.adminChrome(ctx, "仪表盘", "dashboard"),
UserCount: users,
PostCount: posts,
PendingPosts: pendingPosts,
PendingComments: pendingComments,
PendingReports: pendingReports,
BoardCount: boards,
})
}
type adminBoardRow struct {
ID uint
Name string
Description string
Icon string
ColorIndex int
SortOrder int
PostCount int
}
type adminBoardsData struct {
AdminChrome
Boards []adminBoardRow
Form adminBoardForm
}
type adminBoardForm struct {
ID uint
Name string
Description string
Icon string
ColorIndex int
SortOrder int
}
// AdminBoardsGet 板块列表
func (d Deps) AdminBoardsGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminBoards(ctx, "", adminBoardForm{})
}
func (d Deps) renderAdminBoards(ctx *webctx.Context, errMsg string, form adminBoardForm) {
list, _ := d.Board.ListWithStats()
rows := make([]adminBoardRow, 0, len(list))
for _, b := range list {
rows = append(rows, adminBoardRow{
ID: b.ID, Name: b.Name, Description: b.Description,
Icon: b.Icon, ColorIndex: b.ColorIndex, SortOrder: b.SortOrder,
PostCount: b.PostCount,
})
}
data := adminBoardsData{
AdminChrome: d.adminChrome(ctx, "板块", "boards"),
Boards: rows,
Form: form,
}
data.Error = errMsg
ctx.HTML(http.StatusOK, "admin/boards", data)
}
// AdminBoardCreate 新建板块
func (d Deps) AdminBoardCreate(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
return
}
form := adminBoardFormFrom(c)
if strings.TrimSpace(form.Name) == "" {
d.renderAdminBoards(ctx, "请填写板块名称", form)
return
}
if _, err := d.Board.Create(form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
d.renderAdminBoards(ctx, err.Error(), form)
return
}
ctx.SetFlash("板块已创建")
ctx.Redirect("/admin/boards")
}
// AdminBoardUpdate 更新板块
func (d Deps) AdminBoardUpdate(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
form := adminBoardFormFrom(c)
form.ID = uint(id)
if strings.TrimSpace(form.Name) == "" {
d.renderAdminBoards(ctx, "请填写板块名称", form)
return
}
if err := d.Board.Update(uint(id), form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
d.renderAdminBoards(ctx, err.Error(), form)
return
}
ctx.SetFlash("板块已更新")
ctx.Redirect("/admin/boards")
}
// AdminBoardDelete 删除板块
func (d Deps) AdminBoardDelete(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/boards")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.Board.Delete(uint(id)); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/boards")
return
}
ctx.SetFlash("板块已删除")
ctx.Redirect("/admin/boards")
}
func adminBoardFormFrom(c *gin.Context) adminBoardForm {
color, _ := strconv.Atoi(c.PostForm("color_index"))
sort, _ := strconv.Atoi(c.PostForm("sort_order"))
return adminBoardForm{
Name: strings.TrimSpace(c.PostForm("name")),
Description: strings.TrimSpace(c.PostForm("description")),
Icon: strings.TrimSpace(c.PostForm("icon")),
ColorIndex: color,
SortOrder: sort,
}
}
type adminModPostRow struct {
ID uint
Title string
AuthorName string
BoardName string
CreatedAt string
}
type adminModCommentRow struct {
ID uint
PostID uint
PostTitle string
Floor int
AuthorName string
Excerpt string
CreatedAt string
}
type adminModData struct {
AdminChrome
Posts []adminModPostRow
Comments []adminModCommentRow
}
// AdminModerationGet 待审帖/评
func (d Deps) AdminModerationGet(c *gin.Context) {
ctx := d.ctx(c)
posts, _, _ := d.Post.List(services.PostListQuery{
Page: 1, Size: 50,
ViewerIsAdmin: true,
Status: models.ContentStatusPending,
Sort: "latest",
})
postRows := make([]adminModPostRow, 0, len(posts))
for _, p := range posts {
author := ""
if p.User.ID > 0 {
author = p.User.Nickname
if author == "" {
author = p.User.Username
}
}
board := ""
if p.Board.ID > 0 {
board = p.Board.Name
}
postRows = append(postRows, adminModPostRow{
ID: p.ID, Title: p.Title, AuthorName: author, BoardName: board,
CreatedAt: p.CreatedAt.Format("2006-01-02 15:04"),
})
}
comments, _, _ := d.Comment.ListPending(1, 50)
commentRows := make([]adminModCommentRow, 0, len(comments))
for _, cm := range comments {
author := "游客"
if cm.UserID > 0 {
author = cm.User.Nickname
if author == "" {
author = cm.User.Username
}
} else if cm.GuestNick != "" {
author = cm.GuestNick
}
excerpt := strings.TrimSpace(stripTagsRough(cm.Content))
runes := []rune(excerpt)
if len(runes) > 80 {
excerpt = string(runes[:80]) + "…"
}
title := ""
if cm.Post.ID > 0 {
title = cm.Post.Title
}
commentRows = append(commentRows, adminModCommentRow{
ID: cm.ID, PostID: cm.PostID, PostTitle: title, Floor: cm.Floor,
AuthorName: author, Excerpt: excerpt,
CreatedAt: cm.CreatedAt.Format("2006-01-02 15:04"),
})
}
ctx.HTML(http.StatusOK, "admin/moderation", adminModData{
AdminChrome: d.adminChrome(ctx, "内容审核", "moderation"),
Posts: postRows,
Comments: commentRows,
})
}
func stripTagsRough(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
return b.String()
}
// AdminPostApprove 通过帖子
func (d Deps) AdminPostApprove(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/moderation")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.Post.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
ctx.SetFlash(err.Error())
} else {
ctx.SetFlash("帖子已通过")
}
ctx.Redirect("/admin/moderation")
}
// AdminPostReject 拒绝帖子
func (d Deps) AdminPostReject(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/moderation")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
reason := strings.TrimSpace(c.PostForm("reason"))
if reason == "" {
ctx.SetFlash("请填写拒绝原因")
ctx.Redirect("/admin/moderation")
return
}
post, err := d.Post.FindByID(uint(id))
if err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/moderation")
return
}
if err := d.Post.SetStatus(post.ID, models.ContentStatusRejected); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/moderation")
return
}
pid := post.ID
if d.Message != nil {
_, _ = d.Message.SendSystem(
post.UserID,
"帖子《"+post.Title+"》未通过审核",
services.FormatRejectContent(post.Title, post.ID, reason),
models.MessageKindReject,
&pid,
nil,
)
}
ctx.SetFlash("已拒绝该帖")
ctx.Redirect("/admin/moderation")
}
// AdminCommentApprove 通过评论
func (d Deps) AdminCommentApprove(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/moderation")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
ctx.SetFlash(err.Error())
} else {
if d.Notify != nil {
if comment, err := d.Comment.GetByID(uint(id)); err == nil {
comment.Status = models.ContentStatusPublished
d.Notify.AsyncNotifyCommentPublished(comment)
d.Notify.AsyncNotifyCommentMentions(comment)
}
}
ctx.SetFlash("评论已通过")
}
ctx.Redirect("/admin/moderation")
}
// AdminCommentReject 拒绝评论
func (d Deps) AdminCommentReject(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/moderation")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
reason := strings.TrimSpace(c.PostForm("reason"))
if reason == "" {
ctx.SetFlash("请填写拒绝原因")
ctx.Redirect("/admin/moderation")
return
}
cm, err := d.Comment.GetByID(uint(id))
if err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/moderation")
return
}
if err := d.Comment.SetStatus(cm.ID, models.ContentStatusRejected); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/moderation")
return
}
if d.Message != nil && cm.UserID > 0 {
pid := cm.PostID
title := cm.Post.Title
_, _ = d.Message.SendSystem(
cm.UserID,
fmt.Sprintf("评论未通过审核(帖 #%d", cm.PostID),
services.FormatCommentRejectContent(title, cm.PostID, cm.Floor, reason),
models.MessageKindReject,
&pid,
nil,
)
}
ctx.SetFlash("已拒绝该评论")
ctx.Redirect("/admin/moderation")
}
type adminAsideWidgetRow struct {
ID string
Label string
Enabled bool
Index int
CanMoveUp bool
CanMoveDown bool
}
type adminSettingsData struct {
AdminChrome
Brand services.SiteBranding
RatePost int
RateComment int
RateReg int
RateLogin int
RateWindow int
FilterWords string
FilterCount int
Mail services.MailConfig
MailReady bool
TestTo string
AsideWidgets []adminAsideWidgetRow
}
// AdminSettingsGet 设置页
func (d Deps) AdminSettingsGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminSettings(ctx, "")
}
func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
words := d.Settings.FilterWordsContent()
lim := d.Settings.Limits()
mail := d.Settings.MailConfigPublic()
widgets := d.Settings.AsideWidgets()
asideRows := make([]adminAsideWidgetRow, 0, len(widgets))
last := len(widgets) - 1
for i, w := range widgets {
asideRows = append(asideRows, adminAsideWidgetRow{
ID: w.ID, Label: asideWidgetLabel(w.ID), Enabled: w.Enabled, Index: i,
CanMoveUp: i > 0, CanMoveDown: i < last,
})
}
data := adminSettingsData{
AdminChrome: d.adminChrome(ctx, "站点设置", "settings"),
Brand: d.Settings.SiteBranding(),
RatePost: lim.RateLimitPost,
RateComment: lim.RateLimitComment,
RateReg: lim.RateLimitRegister,
RateLogin: lim.RateLimitLogin,
RateWindow: lim.RateLimitWindowSec,
FilterWords: words,
FilterCount: services.CountFilterWords(words),
Mail: mail,
MailReady: d.Settings.MailReady(),
AsideWidgets: asideRows,
}
data.Error = errMsg
ctx.HTML(http.StatusOK, "admin/settings", data)
}
func asideWidgetLabel(id string) string {
switch id {
case services.AsideWidgetTagCloud:
return "标签云"
case services.AsideWidgetRecentComments:
return "最新评论"
case services.AsideWidgetRecentUsers:
return "最新用户"
case services.AsideWidgetFriendLinks:
return "友链"
default:
return id
}
}
// AdminSettingsBrandPost 品牌
func (d Deps) AdminSettingsBrandPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
cur := d.Settings.SiteBranding()
in := services.SiteBranding{
Name: strings.TrimSpace(c.PostForm("name")),
Slogan: strings.TrimSpace(c.PostForm("slogan")),
Description: strings.TrimSpace(c.PostForm("description")),
Keywords: strings.TrimSpace(c.PostForm("keywords")),
LogoMark: strings.TrimSpace(c.PostForm("logo_mark")),
Logo: cur.Logo,
Favicon: cur.Favicon,
OGImage: cur.OGImage,
ICPBeian: strings.TrimSpace(c.PostForm("icp_beian")),
ICPBeianURL: strings.TrimSpace(c.PostForm("icp_beian_url")),
FriendLinks: cur.FriendLinks,
}
if err := d.Settings.UpdateSiteBranding(in); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash("品牌设置已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsLimitsPost 限流
func (d Deps) AdminSettingsLimitsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
postN, _ := strconv.Atoi(c.PostForm("rate_limit_post"))
commentN, _ := strconv.Atoi(c.PostForm("rate_limit_comment"))
regN, _ := strconv.Atoi(c.PostForm("rate_limit_register"))
loginN, _ := strconv.Atoi(c.PostForm("rate_limit_login"))
windowN, _ := strconv.Atoi(c.PostForm("rate_limit_window_sec"))
if err := d.Settings.UpdateRateLimits(postN, commentN, regN, loginN, windowN); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash("限流设置已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsFilterWordsPost 敏感词
func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
content := c.PostForm("filter_words")
if err := d.Settings.UpdateFilterWords(content, d.Filter); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("敏感词已更新(有效词 %d 个)· %s", services.CountFilterWords(content), time.Now().Format("15:04:05")))
ctx.Redirect("/admin/settings")
}
// AdminSettingsMailPost 保存 SMTP
func (d Deps) AdminSettingsMailPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
port, _ := strconv.Atoi(c.PostForm("port"))
cfg := services.MailConfig{
Enabled: c.PostForm("enabled") == "1" || c.PostForm("enabled") == "on",
Host: strings.TrimSpace(c.PostForm("host")),
Port: port,
Username: strings.TrimSpace(c.PostForm("username")),
Password: c.PostForm("password"),
From: strings.TrimSpace(c.PostForm("from")),
FromName: strings.TrimSpace(c.PostForm("from_name")),
Encryption: strings.TrimSpace(c.PostForm("encryption")),
}
if err := d.Settings.UpdateMailConfig(cfg); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash("邮件设置已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsMailTestPost 发送测试信
func (d Deps) AdminSettingsMailTestPost(c *gin.Context) {
ctx := d.ctx(c)
to := strings.TrimSpace(c.PostForm("test_to"))
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
if err := services.ValidateEmail(to); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
if !d.Settings.MailReady() {
d.renderAdminSettings(ctx, services.ErrMailNotConfigured.Error())
return
}
if d.Mail == nil {
d.renderAdminSettings(ctx, "邮件服务未就绪")
return
}
siteName := d.Settings.SiteBranding().Name
err := d.Mail.Send(services.NormalizeEmail(to), "邮件配置测试",
fmt.Sprintf("这是一封来自%s的测试邮件说明 SMTP 配置正常。", siteName))
if err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash("测试邮件已发送至 " + to)
ctx.Redirect("/admin/settings")
}
// AdminSettingsAsideWidgetsPost 侧栏组件开关与排序
func (d Deps) AdminSettingsAsideWidgetsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
order := c.PostFormArray("order")
enabledSet := make(map[string]bool)
for _, id := range c.PostFormArray("enabled") {
id = strings.TrimSpace(id)
if id != "" {
enabledSet[id] = true
}
}
widgets := make([]services.AsideWidget, 0, len(order))
for _, id := range order {
id = strings.TrimSpace(id)
if id == "" {
continue
}
widgets = append(widgets, services.AsideWidget{ID: id, Enabled: enabledSet[id]})
}
widgets = services.NormalizeAsideWidgets(widgets)
if move := strings.TrimSpace(c.PostForm("move")); move != "" {
widgets = moveAsideWidget(widgets, move)
}
if err := d.Settings.UpdateAsideWidgets(widgets); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash("侧栏组件已保存")
ctx.Redirect("/admin/settings")
}
// moveAsideWidget 解析 move 值「up:id」或「down:id」并交换相邻项
func moveAsideWidget(widgets []services.AsideWidget, move string) []services.AsideWidget {
parts := strings.SplitN(move, ":", 2)
if len(parts) != 2 {
return widgets
}
dir, id := parts[0], strings.TrimSpace(parts[1])
idx := -1
for i, w := range widgets {
if w.ID == id {
idx = i
break
}
}
if idx < 0 {
return widgets
}
switch dir {
case "up":
if idx == 0 {
return widgets
}
widgets[idx-1], widgets[idx] = widgets[idx], widgets[idx-1]
case "down":
if idx >= len(widgets)-1 {
return widgets
}
widgets[idx], widgets[idx+1] = widgets[idx+1], widgets[idx]
}
return widgets
}