From c971d99bdb0638e25fa4d2703093c44ae752cbf7 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 29 Oct 2026 15:53:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(vote):=20=E7=AB=99=E6=96=B9=E5=A4=84?= =?UTF-8?q?=E7=BD=AE=E8=A6=86=E7=9B=96=E6=8A=95=E7=A5=A8=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提问链路上 ActiveLevel 有 7 处调用,投票链路 0 处。于是 L4(整页停用)处置某人后: /_/domain 已经 302 到 /restricted,但 /vote/{token} 照常渲染、照常收票;被处置者 还能发新投票;探索页的「热门/全部投票」也不做处置过滤 —— 同一张页面里 「箱主消失了、他的投票还在曝光」。这是运营口径不一致,不是越权。 补齐三处,与提问链路对齐: - vote.Page L4 → 302 /restricted(同箱主页;查询失败 fail-open,与 Pager 一致) - vote.Submit L4 → 拒绝(页面进不去不代表直接构造 POST 也拦得住) - VotePublish L2 起拒绝(L2 已是「收窄公开」,不该再新增公开内容) - 探索页投票列表改用 Restrictions.ActiveLevels 批量过滤(与 GetShowInHome 同口径, 一次查询避免 N+1),功能开关过滤逻辑保持不变 新增字典 key vote.err.restrictedPublish(四语言)。 验证(本地实例): - L4 后 /vote/{token} 302 → /restricted;撤销后恢复 200 - 探索页该投票出现次数 0(处置中)→ 2(撤销后) - 草稿投票在 L2 下发布后状态停留 draft,撤销后同一请求变为 published --- internal/i18n/dict_en.go | 1 + internal/i18n/dict_ja.go | 1 + internal/i18n/dict_zh_cn.go | 1 + internal/i18n/dict_zh_tw.go | 1 + route/user/explore.go | 33 +++++++++++++++++++++++++++------ route/user/vote.go | 15 +++++++++++++++ route/vote/vote.go | 22 ++++++++++++++++++++++ 7 files changed, 68 insertions(+), 6 deletions(-) diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index 54a6963..7fa7d3d 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -841,6 +841,7 @@ var enDict = map[string]string{ "vote.err.fieldSerialize": "Failed to serialize field options", "vote.err.editClosed": "Votes that are ended or archived cannot be edited", "vote.err.publishDraftOnly": "Only draft polls can be published", + "vote.err.restrictedPublish": "Publishing is suspended while this account is under moderation", "vote.err.closePublishedOnly": "Only active polls can be closed", "vote.err.publishTitleRequired": "Please fill in the poll title before publishing", "vote.err.publishFormFieldsRequired": "Form polls need at least 1 field", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index f0c09b5..1feb0a0 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -840,6 +840,7 @@ var jaDict = map[string]string{ "vote.err.fieldSerialize": "項目の選択肢のシリアライズに失敗しました", "vote.err.editClosed": "終了またはアーカイブされた投票は編集できません", "vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます", + "vote.err.restrictedPublish": "現在はプラットフォームによる措置期間中のため、投票の公開を停止しています", "vote.err.closePublishedOnly": "進行中の投票のみ終了できます", "vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください", "vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 0906434..e104215 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -841,6 +841,7 @@ var zhCNDict = map[string]string{ "vote.err.fieldSerialize": "字段选项序列化失败", "vote.err.editClosed": "已结束或归档的投票不允许编辑", "vote.err.publishDraftOnly": "只有草稿状态的投票可以发布", + "vote.err.restrictedPublish": "当前处于平台处置期,已暂停发布投票", "vote.err.closePublishedOnly": "只有进行中的投票可以结束", "vote.err.publishTitleRequired": "请先填写投票标题再发布", "vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 16d10df..0249d1d 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -840,6 +840,7 @@ var zhTWDict = map[string]string{ "vote.err.fieldSerialize": "欄位選項序列化失敗", "vote.err.editClosed": "已結束或歸檔的投票不允許編輯", "vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布", + "vote.err.restrictedPublish": "當前處於平台處置期,已暫停發布投票", "vote.err.closePublishedOnly": "只有進行中的投票可以結束", "vote.err.publishTitleRequired": "請先填寫投票標題再發布", "vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位", diff --git a/route/user/explore.go b/route/user/explore.go index c95e2f0..65c0191 100644 --- a/route/user/explore.go +++ b/route/user/explore.go @@ -94,10 +94,16 @@ func getHotVotesCached(ctx context.Context, c cache.Cache) ([]*db.Vote, error) { return votes, nil } -// filterVotesByOwnerFeature 过滤掉箱主已关闭投票功能的投票(开关 / 定时 / 定量收集), -// 避免探索页出现点了打不开的死链。功能配置批量加载,避免逐条查询造成 N+1; -// 加载失败时按原样返回,依赖表抖动不应导致探索页空白。 -func filterVotesByOwnerFeature(ctx context.Context, votes []*db.Vote) []*db.Vote { +// filterVotesForExplore 过滤掉不该出现在探索页的投票: +// +// 1. 箱主已关闭投票功能(开关 / 定时 / 定量收集)—— 避免点开是死链; +// 2. 箱主处于站方处置中 —— 与首页箱主列表(GetShowInHome 用 +// activeRestrictionsSubQuery)保持同一口径。此前只过滤了功能开关, +// 于是同一张探索页里会出现「箱主本人在列表里消失了,他的投票还在曝光」。 +// +// 两次配置都是批量加载,避免逐条查询造成 N+1;任一侧加载失败时按「该项不过滤」 +// 处理,依赖表抖动不应让探索页整页空白。 +func filterVotesForExplore(ctx context.Context, votes []*db.Vote) []*db.Vote { if len(votes) == 0 { return votes } @@ -111,6 +117,18 @@ func filterVotesByOwnerFeature(ctx context.Context, votes []*db.Vote) []*db.Vote } } + // 生效处置(任意级别):这批箱主的内容不再进入公开曝光 + restricted := make(map[uint]bool, len(ownerIDs)) + if levels, err := db.Restrictions.ActiveLevels(ctx.Request().Context(), ownerIDs); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch load user restrictions") + } else { + for uid, level := range levels { + if level != "" { + restricted[uid] = true + } + } + } + controls, err := db.FeatureControls.ListByUserIDs(ctx.Request().Context(), ownerIDs, db.FeatureVote) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch load vote feature controls") @@ -120,6 +138,9 @@ func filterVotesByOwnerFeature(ctx context.Context, votes []*db.Vote) []*db.Vote now := time.Now() out := make([]*db.Vote, 0, len(votes)) for _, v := range votes { + if restricted[v.UserID] { + continue + } fc, ok := controls[v.UserID] if !ok { fc = db.DefaultFeatureControl(v.UserID, db.FeatureVote) @@ -178,14 +199,14 @@ func Explore(ctx context.Context, c cache.Cache) { logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get hot votes") hotVotes = []*db.Vote{} } - ctx.Data["HotVotes"] = filterVotesByOwnerFeature(ctx, hotVotes) + ctx.Data["HotVotes"] = filterVotesForExplore(ctx, hotVotes) allVotes, err := db.Votes.ListPublishedPublic(ctx.Request().Context(), 30) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get public votes for explore") allVotes = []*db.Vote{} } - ctx.Data["AllVotes"] = filterVotesByOwnerFeature(ctx, allVotes) + ctx.Data["AllVotes"] = filterVotesForExplore(ctx, allVotes) // 计算总页数(向上取整) totalPages := (int(total) + pageSize - 1) / pageSize diff --git a/route/user/vote.go b/route/user/vote.go index d3c43a8..66d965b 100644 --- a/route/user/vote.go +++ b/route/user/vote.go @@ -915,6 +915,21 @@ func VotePublish(ctx context.Context) { return } + // 站方处置:L2 起不得再发布新的公开内容(与「设公开」同口径)。 + // 此前投票链路完全没有处置校验:被处置的箱主仍能发新投票并进探索页曝露。 + level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), ctx.User.ID) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction") + ctx.SetInternalErrorFlash() + ctx.Redirect("/user/questions#votes") + return + } + if db.RestrictionAtLeast(level, db.RestrictionL2) { + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "vote.err.restrictedPublish")) + ctx.Redirect("/user/questions#votes") + return + } + if err := validateForPublish(ctx, vote); err != nil { ctx.SetErrorFlash(err.Error()) ctx.Redirect("/user/questions#votes") diff --git a/route/vote/vote.go b/route/vote/vote.go index 7753627..d8c93d9 100644 --- a/route/vote/vote.go +++ b/route/vote/vote.go @@ -607,6 +607,18 @@ func Page(ctx context.Context) { return } + // 站方处置 L4:整页停止对外访问(数据保留)。 + // 与提问箱主页同一口径:此前投票链路完全不查处置, + // 于是 L4 后 /_/domain 已跳 /restricted,/vote/{token} 却照常可访。 + // 查询失败不拦(fail-open):DB 异常时整站都不正常, + // 这里再 fail-closed 只会让所有投票一起白屏。 + if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), vote.UserID); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query vote owner restriction") + } else if level == db.RestrictionL4 { + ctx.Redirect("/restricted") + return + } + now := time.Now() isOwner := ctx.IsLogged && ctx.User.ID == vote.UserID @@ -703,6 +715,16 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) { return } + // 站方处置 L4:与 Page 同一口径。 + // 页面进不去不代表 POST 也拦得住——直接构造请求可以绕过页面。 + if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), vote.UserID); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query vote owner restriction") + } else if level == db.RestrictionL4 { + ctx.SetErrorFlash(i18n.T(ctxLang(ctx), "vote.flash.notFound")) + ctx.Redirect("/") + return + } + // 发布者的语言与主题:提交成功/失败都回跳投票页,提示文案必须与页面语言一致。 owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID) if err != nil {