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 PostHref string PostPath string PostTitle string AuthorName string AuthorID uint AuthorHref string BoardID uint BoardHref string 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 CanViewRevisions bool CanReportPost bool IsAdmin bool Status string StatusLabel string ShowModerationBanner bool IsQuestion bool QuestionResolved bool CanToggleQuestion bool Bounty *postBountyView Lottery *postLotteryView Poll *postPollView } type postBountyView struct { Points int Status string StatusLabel string CanRefund bool RefundBlockReason string AwardedCommentID uint } type postLotteryWinnerView struct { UserID uint Name string FloorHint string } type postLotteryView struct { WinnerCount int Status string StatusLabel string ParticipantCount int CanDraw bool Winners []postLotteryWinnerView } type postPollOptionView struct { ID uint Text string VoteCount int Percent int Selected bool } type postPollView struct { Multi bool MaxChoices int Closed bool EndsLabel string TotalVotes int ShowResults bool HasVoted bool CanVote bool CanClose bool Options []postPollOptionView } // 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 Status string StatusLabel string CanAwardBounty bool IsBountyAward 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 } match := d.permalink().MatchPostPath(c.Request.URL.Path) if d.redirectIfNotCanonical(c, match) { 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) canAwardBounty := post.PostType == models.PostTypeBounty && post.BountyStatus == models.BountyStatusOpen && (ctx.IsAdmin() || post.UserID == ctx.UserID()) 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()), Status: cm.Status, CanAwardBounty: canAwardBounty && !cm.ContentHidden && cm.Status == models.ContentStatusPublished && cm.UserID != post.UserID && cm.UserID > 0, IsBountyAward: post.BountyCommentID > 0 && cm.ID == post.BountyCommentID, } if cm.Status == models.ContentStatusPending || cm.Status == models.ContentStatusRejected { if ctx.IsAdmin() || (cm.UserID > 0 && cm.UserID == ctx.UserID()) { view.StatusLabel = contentStatusLabel(cm.Status) } } 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 statusLabel := "" showBanner := false if post.Status == models.ContentStatusPending || post.Status == models.ContentStatusRejected { if ctx.IsAdmin() || post.UserID == ctx.UserID() { statusLabel = contentStatusLabel(post.Status) showBanner = true } } var pollView *postPollView if post.PostType == models.PostTypePoll { if pv, err := services.GetPollView(post.ID, ctx.UserID()); err == nil && pv != nil { pollView = mapPollView(pv, ctx.IsSigned(), ctx.IsAdmin() || post.UserID == ctx.UserID()) } } var bountyView *postBountyView if post.PostType == models.PostTypeBounty { isOperator := ctx.IsAdmin() || post.UserID == ctx.UserID() canRefund, blockReason := services.CanRefundBounty(post, ctx.IsAdmin()) bv := &postBountyView{ Points: post.BountyPoints, Status: post.BountyStatus, StatusLabel: bountyStatusLabel(post.BountyStatus), CanRefund: isOperator && canRefund, AwardedCommentID: post.BountyCommentID, } if isOperator && !canRefund { bv.RefundBlockReason = blockReason } bountyView = bv } var lotteryView *postLotteryView if post.PostType == models.PostTypeLottery { if lv, err := services.GetPostLotteryView(post); err == nil && lv != nil { isOperator := ctx.IsAdmin() || post.UserID == ctx.UserID() winners := make([]postLotteryWinnerView, 0, len(lv.Winners)) for _, w := range lv.Winners { name := strings.TrimSpace(w.Nickname) if name == "" { name = w.Username } winners = append(winners, postLotteryWinnerView{UserID: w.UserID, Name: name}) } lotteryView = &postLotteryView{ WinnerCount: lv.WinnerCount, Status: lv.Status, StatusLabel: lotteryStatusLabel(lv.Status), ParticipantCount: lv.ParticipantCount, CanDraw: isOperator && lv.Status != models.PostLotteryStatusDrawn, Winners: winners, } } } ctx.HTML(http.StatusOK, "post", PostPageData{ PageChrome: chrome, PostID: post.ID, PostHref: d.postHref(post.ID), PostPath: url.QueryEscape(d.postHref(post.ID)), PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID, AuthorHref: d.userHref(post.UserID), BoardID: post.BoardID, BoardHref: d.boardHref(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()), CanViewRevisions: canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()), CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(), IsAdmin: ctx.IsAdmin(), Status: post.Status, StatusLabel: statusLabel, ShowModerationBanner: showBanner, IsQuestion: post.PostType == models.PostTypeQuestion, QuestionResolved: post.QuestionResolved, CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()), Bounty: bountyView, Lottery: lotteryView, Poll: pollView, }) } func mapPollView(pv *services.PollView, loggedIn, canClose bool) *postPollView { hasVoted := len(pv.MyOptionIDs) > 0 selected := make(map[uint]bool, len(pv.MyOptionIDs)) for _, id := range pv.MyOptionIDs { selected[id] = true } showResults := pv.Closed || hasVoted opts := make([]postPollOptionView, 0, len(pv.Options)) for _, o := range pv.Options { opts = append(opts, postPollOptionView{ ID: o.ID, Text: o.Text, VoteCount: o.VoteCount, Percent: o.Percent, Selected: selected[o.ID], }) } ends := "" if pv.EndsAt != nil { ends = formatTime(*pv.EndsAt) } return &postPollView{ Multi: pv.Multi, MaxChoices: pv.MaxChoices, Closed: pv.Closed, EndsLabel: ends, TotalVotes: pv.TotalVotes, ShowResults: showResults, HasVoted: hasVoted, CanVote: loggedIn && !pv.Closed && !hasVoted, CanClose: canClose && !pv.Closed, Options: opts, } } func contentStatusLabel(status string) string { switch status { case models.ContentStatusPending: return "审核中" case models.ContentStatusRejected: return "已拒绝" default: return "" } } func lotteryStatusLabel(status string) string { switch status { case models.PostLotteryStatusDrawn: return "已开奖" case models.PostLotteryStatusOpen: return "待开奖" default: return status } } func bountyStatusLabel(status string) string { switch status { case models.BountyStatusOpen: return "进行中" case models.BountyStatusAwarded: return "已采纳" case models.BountyStatusRefunded: return "已退回" default: return status } } 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" if d.Limiter != nil && !d.Limiter.Allow("comment", fmt.Sprintf("%d", ctx.UserID())) { ctx.SetFlash("操作过于频繁,请稍后再试") ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) return } safe := "

" + html.EscapeString(content) + "

" 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 } if d.Notify != nil && cm != nil { switch cm.Status { case models.ContentStatusPublished: d.Notify.AsyncNotifyCommentPublished(cm) d.Notify.AsyncNotifyCommentMentions(cm) case models.ContentStatusPending: ctx.SetFlash("评论已提交,审核通过后公开显示") d.Notify.AsyncNotifyPendingComment(cm) } } 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)) } // PostPollVote 投票提交 func (d Deps) PostPollVote(c *gin.Context) { ctx := d.ctx(c) id, err := parsePostID(c, d) if err != nil || id == 0 { d.render404(ctx) return } if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } raw := c.PostFormArray("option_ids") if len(raw) == 0 { if one := strings.TrimSpace(c.PostForm("option_id")); one != "" { raw = []string{one} } } oids := make([]uint, 0, len(raw)) for _, s := range raw { v, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64) if err == nil && v > 0 { oids = append(oids, uint(v)) } } if err := services.VotePoll(id, ctx.UserID(), oids); err != nil { ctx.SetFlash(err.Error()) ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } ctx.SetFlash("投票成功") ctx.Redirect(fmt.Sprintf("/post/%d", id)) } // PostPollClose 结束投票 func (d Deps) PostPollClose(c *gin.Context) { ctx := d.ctx(c) id, err := parsePostID(c, d) if err != nil || id == 0 { d.render404(ctx) return } if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } post, err := d.Post.FindByID(id) if err != nil { d.render404(ctx) return } if err := services.ClosePoll(id, ctx.UserID(), ctx.IsAdmin(), post.UserID); err != nil { ctx.SetFlash(err.Error()) ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } ctx.SetFlash("投票已结束") ctx.Redirect(fmt.Sprintf("/post/%d", id)) } // PostQuestionResolvePost 标记问答帖已解决 / 未解决 func (d Deps) PostQuestionResolvePost(c *gin.Context) { ctx := d.ctx(c) id, err := parsePostID(c, d) if err != nil || id == 0 { d.render404(ctx) return } if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } resolved := c.PostForm("resolved") == "1" || c.PostForm("resolved") == "on" if err := d.Post.SetQuestionResolved(ctx.UserID(), id, ctx.IsAdmin(), resolved); err != nil { ctx.SetFlash(err.Error()) ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } if resolved { ctx.SetFlash("已标为已解决") } else { ctx.SetFlash("已标为未解决") } ctx.Redirect(fmt.Sprintf("/post/%d", id)) } // PostBountyAwardPost 采纳评论并发放悬赏 func (d Deps) PostBountyAwardPost(c *gin.Context) { ctx := d.ctx(c) id, err := parsePostID(c, d) if err != nil || id == 0 { d.render404(ctx) return } if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } cid, _ := strconv.ParseUint(c.PostForm("comment_id"), 10, 64) if err := services.AwardBounty(id, ctx.UserID(), ctx.IsAdmin(), uint(cid)); err != nil { ctx.SetFlash(err.Error()) ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) return } ctx.SetFlash("已采纳并发放悬赏") ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) } // PostBountyRefundPost 取消悬赏并退回积分 func (d Deps) PostBountyRefundPost(c *gin.Context) { ctx := d.ctx(c) id, err := parsePostID(c, d) if err != nil || id == 0 { d.render404(ctx) return } if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } if err := services.RefundBounty(id, ctx.UserID(), ctx.IsAdmin()); err != nil { ctx.SetFlash(err.Error()) ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } ctx.SetFlash("悬赏已退回") ctx.Redirect(fmt.Sprintf("/post/%d", id)) } // PostLotteryDrawPost 抽奖帖开奖 func (d Deps) PostLotteryDrawPost(c *gin.Context) { ctx := d.ctx(c) id, err := parsePostID(c, d) if err != nil || id == 0 { d.render404(ctx) return } if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } if _, err := services.DrawPostLottery(id, ctx.UserID(), ctx.IsAdmin()); err != nil { ctx.SetFlash(err.Error()) ctx.Redirect(fmt.Sprintf("/post/%d", id)) return } ctx.SetFlash("开奖完成") 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 := "

" + html.EscapeString(plain) + "

" _, 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 发评存的是转义后的

,编辑时还原为纯文本 func commentHTMLToPlain(s string) string { s = strings.TrimSpace(s) s = strings.TrimPrefix(s, "

") s = strings.TrimSuffix(s, "

") return html.UnescapeString(s) } type postRevisionRow struct { ID uint EditorName string Title string CreatedLabel string } type postRevisionsData struct { PageChrome PostID uint PostTitle string Revisions []postRevisionRow } type postRevisionDetailData struct { PageChrome PostID uint PostTitle string RevID uint EditorName string CreatedLabel string Title string Tags string BodyHTML string } // PostRevisionsGet 帖子修订历史列表(作者或管理员) func (d Deps) PostRevisionsGet(c *gin.Context) { ctx := d.ctx(c) post, ok := d.loadRevisionPost(ctx, c) if !ok { return } revs, err := d.Post.ListRevisions(post.ID) if err != nil { ctx.SetFlash(err.Error()) ctx.Redirect(fmt.Sprintf("/post/%d", post.ID)) return } rows := make([]postRevisionRow, 0, len(revs)) for _, r := range revs { name := displayName(&r.Editor) if name == "" { name = fmt.Sprintf("用户 #%d", r.EditorID) } rows = append(rows, postRevisionRow{ ID: r.ID, EditorName: name, Title: r.Title, CreatedLabel: formatTime(r.CreatedAt), }) } chrome := d.chrome(ctx, "修订历史 · "+post.Title+" · "+d.Settings.SiteBranding().Name, "", "") ctx.HTML(http.StatusOK, "post/revisions", postRevisionsData{ PageChrome: chrome, PostID: post.ID, PostTitle: post.Title, Revisions: rows, }) } // PostRevisionDetailGet 单条修订快照 func (d Deps) PostRevisionDetailGet(c *gin.Context) { ctx := d.ctx(c) post, ok := d.loadRevisionPost(ctx, c) if !ok { return } rid, err := strconv.ParseUint(c.Param("rid"), 10, 64) if err != nil || rid == 0 { d.render404(ctx) return } rev, err := d.Post.GetRevision(post.ID, uint(rid)) if err != nil { d.render404(ctx) return } name := displayName(&rev.Editor) if name == "" { name = fmt.Sprintf("用户 #%d", rev.EditorID) } chrome := d.chrome(ctx, "修订 #"+strconv.FormatUint(rid, 10)+" · "+post.Title+" · "+d.Settings.SiteBranding().Name, "", "") ctx.HTML(http.StatusOK, "post/revision_detail", postRevisionDetailData{ PageChrome: chrome, PostID: post.ID, PostTitle: post.Title, RevID: rev.ID, EditorName: name, CreatedLabel: formatTime(rev.CreatedAt), Title: rev.Title, Tags: rev.Tags, BodyHTML: rev.Content, }) } func (d Deps) loadRevisionPost(ctx *webctx.Context, c *gin.Context) (*models.Post, bool) { id, err := parsePostID(c, d) if err != nil || id == 0 { d.render404(ctx) return nil, false } post, err := d.Post.FindByID(id) if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) { d.render404(ctx) return nil, false } if !canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()) { ctx.SetFlash("无权查看编辑历史") ctx.Redirect(fmt.Sprintf("/post/%d", post.ID)) return nil, false } return post, true } func canViewPostRevisions(post *models.Post, userID uint, isAdmin bool) bool { if post == nil || userID == 0 { return false } return isAdmin || post.UserID == userID } 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 }