Files
jiang13-forum/routers/web/post.go
freefire 145c7a3e1f feat: SSR 评论编辑与作者删除
帖详情提供时限内编辑与软删入口,作者删除与管理员共用回收站子树语义。

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

454 lines
12 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"
"html"
"net/http"
"net/url"
"strconv"
"strings"
"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"
)
// PostPageData 帖详情
type PostPageData struct {
PageChrome
PostID uint
PostPath string
PostTitle string
AuthorName string
AuthorID uint
BoardID uint
BoardName string
Pinned bool // 展示用:全局或版内置顶
GlobalPinned bool
BoardPinned bool
Featured bool
EditLocked bool
PostTypeLabel string
CreatedLabel string
ViewCount int
LikeCount int
Liked bool
Favorited bool
BodyHTML string
CommentCount int
Comments []CommentView
CommentsLocked bool
CanEdit bool
CanReportPost bool
IsAdmin bool
}
// CommentView 评论
type CommentView struct {
ID uint
Floor int
AuthorName string
AuthorID uint
CreatedLabel string
Content string
ContentHidden bool
ReplyToID uint
ReplyToFloor int
ReplyToAuthor string
LikeCount int
Liked bool
IsPrivate bool
CanReport bool
CanEdit bool
CanDelete bool
}
// PostView GET /post/:id
func (d Deps) PostView(c *gin.Context) {
ctx := d.ctx(c)
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 {
d.render404(ctx)
return
}
post, err := d.Post.FindByID(uint(id))
if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) {
d.render404(ctx)
return
}
if post.Status == models.ContentStatusPublished {
d.Post.RecordView(uint(id))
}
hasReplied := ctx.UserID() > 0 && d.Comment.HasUserReplied(uint(id), ctx.UserID())
body := services.ApplyPostContentGates(post.Content, post, ctx.UserID(), ctx.IsAdmin(), hasReplied)
comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil)
cv := make([]CommentView, 0, len(comments))
for _, cm := range comments {
an := strings.TrimSpace(cm.User.Nickname)
if an == "" {
an = cm.User.Username
}
view := CommentView{
ID: cm.ID, Floor: cm.Floor, AuthorName: an, AuthorID: cm.UserID,
CreatedLabel: formatTime(cm.CreatedAt),
Content: cm.Content, ContentHidden: cm.ContentHidden,
LikeCount: cm.LikeCount, Liked: cm.Liked, IsPrivate: cm.IsPrivate,
CanReport: ctx.IsSigned() && (cm.UserID == 0 || cm.UserID != ctx.UserID()),
CanEdit: !cm.ContentHidden && d.Comment.CanUserEditComment(&cm, ctx.UserID(), ctx.IsAdmin()),
CanDelete: !cm.ContentHidden && d.Comment.CanUserDeleteComment(&cm, ctx.UserID(), ctx.IsAdmin()),
}
if cm.ReplyTarget != nil {
view.ReplyToID = cm.ReplyTarget.ID
view.ReplyToFloor = cm.ReplyTarget.Floor
rn := strings.TrimSpace(cm.ReplyTarget.User.Nickname)
if rn == "" {
rn = cm.ReplyTarget.User.Username
}
if rn == "" {
rn = cm.ReplyTarget.GuestNick
}
view.ReplyToAuthor = rn
} else if cm.ReplyTo != nil {
view.ReplyToID = *cm.ReplyTo
}
cv = append(cv, view)
}
author := strings.TrimSpace(post.User.Nickname)
if author == "" {
author = post.User.Username
}
boardName := ""
if post.Board.ID > 0 {
boardName = post.Board.Name
}
chrome := d.chrome(ctx, post.Title+" · "+d.Settings.SiteBranding().Name, "", "post/body")
chrome.ActiveBoard = post.BoardID
ctx.HTML(http.StatusOK, "post", PostPageData{
PageChrome: chrome, PostID: post.ID,
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)),
PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID,
BoardID: post.BoardID, BoardName: boardName,
Pinned: post.Pinned || post.BoardPinned, GlobalPinned: post.Pinned, BoardPinned: post.BoardPinned,
Featured: post.Featured, EditLocked: post.EditLocked,
PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),
ViewCount: post.ViewCount, LikeCount: post.LikeCount,
Liked: d.Post.IsLiked(ctx.UserID(), post.ID), Favorited: d.Post.IsFavorited(ctx.UserID(), post.ID),
BodyHTML: body, CommentCount: len(cv), Comments: cv,
CommentsLocked: post.CommentsLocked,
CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()),
CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(),
IsAdmin: ctx.IsAdmin(),
})
}
func postTypeLabel(t string) string {
switch t {
case models.PostTypeQuestion:
return "问答"
case models.PostTypePoll:
return "投票"
case models.PostTypeBounty:
return "悬赏"
case models.PostTypeLottery:
return "抽奖"
default:
return ""
}
}
// PostComment POST 评论
func (d Deps) PostComment(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/")
return
}
id, err := parsePostID(c, d)
if err != nil {
d.render404(ctx)
return
}
content := strings.TrimSpace(c.PostForm("content"))
if content == "" {
ctx.SetFlash("评论不能为空")
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
return
}
var replyTo *uint
if raw := strings.TrimSpace(c.PostForm("reply_to")); raw != "" {
rid, err := strconv.ParseUint(raw, 10, 64)
if err == nil && rid > 0 {
v := uint(rid)
replyTo = &v
}
}
isPrivate := c.PostForm("is_private") == "1" || c.PostForm("is_private") == "on"
safe := "<p>" + html.EscapeString(content) + "</p>"
cm, err := d.Comment.Create(services.CommentCreateInput{
PostID: id, UserID: ctx.UserID(), Content: safe,
ReplyTo: replyTo, IsPrivate: isPrivate,
})
if err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
return
}
anchor := "#comments"
if cm != nil && cm.Floor > 0 {
anchor = fmt.Sprintf("#floor-%d", cm.Floor)
}
ctx.Redirect(fmt.Sprintf("/post/%d%s", id, anchor))
}
// PostCommentLike 评论点赞切换PRG
func (d Deps) PostCommentLike(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求")
ctx.Redirect("/")
return
}
postID, err := parsePostID(c, d)
if err != nil {
d.render404(ctx)
return
}
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil || cid == 0 {
d.render404(ctx)
return
}
cm, err := d.Comment.GetByID(uint(cid))
if err != nil || cm.PostID != postID {
d.render404(ctx)
return
}
_, _, _ = d.Comment.ToggleLike(ctx.UserID(), uint(cid))
ctx.Redirect(fmt.Sprintf("/post/%d#floor-%d", postID, cm.Floor))
}
// PostUnlock POST /post/:id/unlock — 积分解锁JSON供详情页渐进增强
func (d Deps) PostUnlock(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求,请重试"})
return
}
id, err := parsePostID(c, d)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
var req struct {
BlockKey string `json:"block_key" form:"block_key"`
}
_ = c.ShouldBindJSON(&req)
if req.BlockKey == "" {
req.BlockKey = strings.TrimSpace(c.PostForm("block_key"))
}
if req.BlockKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"})
return
}
res, err := services.UnlockPointsBlock(ctx.UserID(), id, req.BlockKey)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "解锁成功",
"unlock": res,
})
}
// PostLike 赞
func (d Deps) PostLike(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求")
ctx.Redirect("/")
return
}
id, err := parsePostID(c, d)
if err != nil {
d.render404(ctx)
return
}
_, _ = d.Post.ToggleLike(ctx.UserID(), id)
ctx.Redirect(fmt.Sprintf("/post/%d", id))
}
// PostFavorite 收藏
func (d Deps) PostFavorite(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求")
ctx.Redirect("/")
return
}
id, err := parsePostID(c, d)
if err != nil {
d.render404(ctx)
return
}
_, _ = d.Post.ToggleFavorite(ctx.UserID(), id)
ctx.Redirect(fmt.Sprintf("/post/%d", id))
}
type commentEditData struct {
PageChrome
PostID uint
CommentID uint
Floor int
Content string
}
// CommentEditGet 编辑评论页
func (d Deps) CommentEditGet(c *gin.Context) {
ctx := d.ctx(c)
postID, cm, errMsg := d.loadEditableComment(ctx, c)
if errMsg != "" {
ctx.SetFlash(errMsg)
if postID > 0 {
ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID))
return
}
ctx.Redirect("/")
return
}
chrome := d.chrome(ctx, "编辑评论 · "+d.Settings.SiteBranding().Name, "", "")
ctx.HTML(http.StatusOK, "post/comment_edit", commentEditData{
PageChrome: chrome,
PostID: postID,
CommentID: cm.ID,
Floor: cm.Floor,
Content: commentHTMLToPlain(cm.Content),
})
}
// CommentEditPost 保存评论编辑
func (d Deps) CommentEditPost(c *gin.Context) {
ctx := d.ctx(c)
postID, cm, errMsg := d.loadEditableComment(ctx, c)
if errMsg != "" {
ctx.SetFlash(errMsg)
if postID > 0 {
ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID))
return
}
ctx.Redirect("/")
return
}
if !ctx.CheckCSRF() {
d.renderCommentEdit(ctx, "无效请求,请重试", postID, cm, c.PostForm("content"))
return
}
plain := strings.TrimSpace(c.PostForm("content"))
if plain == "" {
d.renderCommentEdit(ctx, "评论不能为空", postID, cm, plain)
return
}
safe := "<p>" + html.EscapeString(plain) + "</p>"
_, enteredPending, err := d.Comment.Update(ctx.UserID(), cm.ID, ctx.IsAdmin(), ctx.SkipsModeration(), safe)
if err != nil {
d.renderCommentEdit(ctx, err.Error(), postID, cm, plain)
return
}
if enteredPending {
ctx.SetFlash("评论已更新,审核通过后公开显示")
} else {
ctx.SetFlash("评论已更新")
}
ctx.Redirect(fmt.Sprintf("/post/%d#floor-%d", postID, cm.Floor))
}
// CommentDeletePost 软删评论
func (d Deps) CommentDeletePost(c *gin.Context) {
ctx := d.ctx(c)
postID, err := parsePostID(c, d)
if err != nil || postID == 0 {
d.render404(ctx)
return
}
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil || cid == 0 {
d.render404(ctx)
return
}
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID))
return
}
cm, err := d.Comment.GetByID(uint(cid))
if err != nil || cm.PostID != postID {
d.render404(ctx)
return
}
if !d.Comment.CanUserDeleteComment(cm, ctx.UserID(), ctx.IsAdmin()) {
ctx.SetFlash(services.ErrPermissionDenied.Error())
ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID))
return
}
if err := d.Comment.Delete(ctx.UserID(), cm.ID, ctx.IsAdmin()); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/post/%d#floor-%d", postID, cm.Floor))
return
}
ctx.SetFlash("评论已删除")
ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID))
}
func (d Deps) loadEditableComment(ctx *webctx.Context, c *gin.Context) (postID uint, cm *models.Comment, errMsg string) {
pid, err := parsePostID(c, d)
if err != nil || pid == 0 {
return 0, nil, "帖子不存在"
}
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil || cid == 0 {
return pid, nil, "评论不存在"
}
cm, err = d.Comment.GetByID(uint(cid))
if err != nil || cm.PostID != pid {
return pid, nil, "评论不存在"
}
if !d.Comment.CanUserEditComment(cm, ctx.UserID(), ctx.IsAdmin()) {
return pid, cm, "无权编辑该评论或已超过可编辑时限"
}
return pid, cm, ""
}
func (d Deps) renderCommentEdit(ctx *webctx.Context, errMsg string, postID uint, cm *models.Comment, content string) {
chrome := d.chrome(ctx, "编辑评论 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
ctx.HTML(http.StatusOK, "post/comment_edit", commentEditData{
PageChrome: chrome,
PostID: postID,
CommentID: cm.ID,
Floor: cm.Floor,
Content: content,
})
}
// commentHTMLToPlain 发评存的是转义后的 <p>…</p>,编辑时还原为纯文本
func commentHTMLToPlain(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "<p>")
s = strings.TrimSuffix(s, "</p>")
return html.UnescapeString(s)
}
func parsePostID(c *gin.Context, d Deps) (uint, error) {
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
id, err := strconv.ParseUint(idStr, 10, 64)
return uint(id), err
}