fix: 管理删评对公众剥离作者身份,评论者本人仍可见原文

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:44:37 +08:00
parent 4b93739468
commit c9d27b2b0c
10 changed files with 405 additions and 174 deletions

View File

@@ -38,7 +38,7 @@ type BountyMeta struct {
Escrowed bool `json:"escrowed"`
Refunded bool `json:"refunded"`
Expired bool `json:"expired"`
EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写
EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写
ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清
}
@@ -58,16 +58,16 @@ type LotteryMeta struct {
}
var (
ErrPollClosed = errors.New("投票已结束")
ErrAlreadyVoted = errors.New("你已投过票")
ErrInvalidPollOpt = errors.New("无效的投票选项")
ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
ErrBountySettled = errors.New("悬赏已结算")
ErrBountyNotEscrow = errors.New("悬赏未托管")
ErrLotteryDrawn = errors.New("已开奖")
ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
ErrPollClosed = errors.New("投票已结束")
ErrAlreadyVoted = errors.New("你已投过票")
ErrInvalidPollOpt = errors.New("无效的投票选项")
ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
ErrBountySettled = errors.New("悬赏已结算")
ErrBountyNotEscrow = errors.New("悬赏未托管")
ErrLotteryDrawn = errors.New("已开奖")
ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
ErrLotteryPrizesLocked = errors.New("已有评论,无法修改奖品设置")
ErrQuestionNotType = errors.New("本文不是问答帖")
ErrQuestionNotType = errors.New("本文不是问答帖")
)
func parseQuestionMeta(raw string) (*QuestionMeta, error) {
@@ -476,17 +476,12 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
// AcceptedAnswer 问答/悬赏已采纳答案(详情页正文下展示;与评论分页无关)
type AcceptedAnswer struct {
ID uint `json:"id"`
Floor int `json:"floor"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Deleted bool `json:"deleted,omitempty"`
User struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
} `json:"user"`
ID uint `json:"id"`
Floor int `json:"floor"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Deleted bool `json:"deleted,omitempty"`
User *CommentActorBrief `json:"user,omitempty"` // 管理删对公众省略
}
type QuestionState struct {
@@ -554,17 +549,17 @@ type LotteryEntrant struct {
}
type LotteryState struct {
Slots int `json:"slots"`
Prizes []LotteryPrize `json:"prizes"`
Drawn bool `json:"drawn"`
Closed bool `json:"closed"`
EndsAt string `json:"ends_at,omitempty"`
EntryCount int `json:"entry_count"`
Eligible bool `json:"eligible"`
CooldownHours int `json:"cooldown_hours"`
CanDraw bool `json:"can_draw"`
CanEditPrizes bool `json:"can_edit_prizes"`
Winners []LotteryEntrant `json:"winners"`
Slots int `json:"slots"`
Prizes []LotteryPrize `json:"prizes"`
Drawn bool `json:"drawn"`
Closed bool `json:"closed"`
EndsAt string `json:"ends_at,omitempty"`
EntryCount int `json:"entry_count"`
Eligible bool `json:"eligible"`
CooldownHours int `json:"cooldown_hours"`
CanDraw bool `json:"can_draw"`
CanEditPrizes bool `json:"can_edit_prizes"`
Winners []LotteryEntrant `json:"winners"`
}
func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, viewerID uint, loadActor func() *Actor) {
@@ -580,23 +575,23 @@ func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, vi
switch pt {
case model.PostTypeQuestion:
detail.Question = s.buildQuestionState(post, canManage)
detail.Question = s.buildQuestionState(post, viewerID, canManage, isMod)
case model.PostTypePoll:
detail.Poll = s.buildPollState(post, viewerID, canManage)
case model.PostTypeBounty:
detail.Bounty = s.buildBountyState(post, canManage)
detail.Bounty = s.buildBountyState(post, viewerID, canManage, isMod)
case model.PostTypeLottery:
detail.Lottery = s.buildLotteryState(post, viewerID, canManage)
}
}
func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *QuestionState {
func (s *PostService) buildQuestionState(post *model.Post, viewerID uint, canManage, isMod bool) *QuestionState {
m, err := parseQuestionMeta(post.TypeMeta)
if err != nil || m == nil {
m = &QuestionMeta{}
}
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor)
answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor, viewerID, isMod)
solvedAt := m.SolvedAt
if m.Solved && solvedAt == "" && answer != nil && !answer.CreatedAt.IsZero() {
solvedAt = answer.CreatedAt.UTC().Format(time.RFC3339) // 旧数据:无 solved_at 时用采纳评论时间
@@ -610,7 +605,7 @@ func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *Ques
CanAccept: canManage && !m.Solved,
CanSolve: canManage && !m.Solved,
// 已采纳答案后不可重新打开;仅「手动标已解决」可撤回
CanReopen: canManage && m.Solved && m.AcceptedCommentID == 0,
CanReopen: canManage && m.Solved && m.AcceptedCommentID == 0,
}
}
@@ -713,7 +708,7 @@ func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose b
}
}
func (s *PostService) buildBountyState(post *model.Post, canManage bool) *BountyState {
func (s *PostService) buildBountyState(post *model.Post, viewerID uint, canManage, isMod bool) *BountyState {
m, err := parseBountyMeta(post.TypeMeta)
if err != nil {
return nil
@@ -724,7 +719,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
Points: m.Points,
AcceptedCommentID: m.AcceptedCommentID,
AcceptedFloor: floor,
AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor),
AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor, viewerID, isMod),
Escrowed: m.Escrowed,
Refunded: m.Refunded,
Expired: m.Expired,
@@ -736,7 +731,8 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
}
// loadAcceptedAnswer 加载已采纳评论快照(公开可见正文);软删则清空正文并标记 deleted。
func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *AcceptedAnswer {
// 管理删:公众不返回评论者身份(与评论树 toCommentNode 一致)。
func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, viewerID uint, isMod bool) *AcceptedAnswer {
if postID == 0 || commentID == 0 {
return nil
}
@@ -756,13 +752,23 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *Acc
Floor: floor,
CreatedAt: c.CreatedAt,
}
ans.User.ID = c.User.ID
ans.User.Username = c.User.Username
ans.User.Nickname = c.User.Nickname
ans.User.Avatar = c.User.Avatar
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
hideAuthor := staff && !isMod && viewerID != c.UserID
if !hideAuthor && c.User.ID != 0 {
ans.User = &CommentActorBrief{
ID: c.User.ID,
Username: c.User.Username,
Nickname: c.User.Nickname,
Avatar: c.User.Avatar,
}
}
if c.DeletedAt.Valid {
ans.Deleted = true
ans.Content = ""
if isMod || viewerID == c.UserID {
ans.Content = c.Content
} else {
ans.Content = ""
}
return ans
}
ans.Content = c.Content
@@ -1513,12 +1519,10 @@ func (s *PostService) loadManageablePost(actor *Actor, userID, postID uint) (*mo
func (s *PostService) detailAfterInteract(post *model.Post, viewerID uint) (*PostDetail, error) {
detail := buildPostDetail(post)
locked, hint := s.evalContentAccess(post, viewerID, nil)
detail.ContentLocked = locked
sanitized, fullyLocked, hint := s.sanitizePostContent(post, viewerID, nil)
detail.Content = sanitized
detail.ContentLocked = fullyLocked
detail.AccessHint = hint
if locked {
detail.Content = ""
}
atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
detail.Attachments = atts
s.fillInteractState(detail, post, viewerID, nil)