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 CanViewRevisions bool CanReportPost bool IsAdmin bool Status string StatusLabel string ShowModerationBanner bool IsQuestion bool QuestionResolved bool CanToggleQuestion bool Poll *postPollView } 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 } // 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()), Status: cm.Status, } 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()) } } 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()), 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()), 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 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 := "

" + 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)) } 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 }