fix(vote): 站方处置覆盖投票链路

提问链路上 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
This commit is contained in:
tamakyi
2026-10-29 15:53:49 +08:00
parent ced4c1635c
commit c971d99bdb
7 changed files with 68 additions and 6 deletions
+1
View File
@@ -841,6 +841,7 @@ var enDict = map[string]string{
"vote.err.fieldSerialize": "Failed to serialize field options", "vote.err.fieldSerialize": "Failed to serialize field options",
"vote.err.editClosed": "Votes that are ended or archived cannot be edited", "vote.err.editClosed": "Votes that are ended or archived cannot be edited",
"vote.err.publishDraftOnly": "Only draft polls can be published", "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.closePublishedOnly": "Only active polls can be closed",
"vote.err.publishTitleRequired": "Please fill in the poll title before publishing", "vote.err.publishTitleRequired": "Please fill in the poll title before publishing",
"vote.err.publishFormFieldsRequired": "Form polls need at least 1 field", "vote.err.publishFormFieldsRequired": "Form polls need at least 1 field",
+1
View File
@@ -840,6 +840,7 @@ var jaDict = map[string]string{
"vote.err.fieldSerialize": "項目の選択肢のシリアライズに失敗しました", "vote.err.fieldSerialize": "項目の選択肢のシリアライズに失敗しました",
"vote.err.editClosed": "終了またはアーカイブされた投票は編集できません", "vote.err.editClosed": "終了またはアーカイブされた投票は編集できません",
"vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます", "vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます",
"vote.err.restrictedPublish": "現在はプラットフォームによる措置期間中のため、投票の公開を停止しています",
"vote.err.closePublishedOnly": "進行中の投票のみ終了できます", "vote.err.closePublishedOnly": "進行中の投票のみ終了できます",
"vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください", "vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください",
"vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です", "vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です",
+1
View File
@@ -841,6 +841,7 @@ var zhCNDict = map[string]string{
"vote.err.fieldSerialize": "字段选项序列化失败", "vote.err.fieldSerialize": "字段选项序列化失败",
"vote.err.editClosed": "已结束或归档的投票不允许编辑", "vote.err.editClosed": "已结束或归档的投票不允许编辑",
"vote.err.publishDraftOnly": "只有草稿状态的投票可以发布", "vote.err.publishDraftOnly": "只有草稿状态的投票可以发布",
"vote.err.restrictedPublish": "当前处于平台处置期,已暂停发布投票",
"vote.err.closePublishedOnly": "只有进行中的投票可以结束", "vote.err.closePublishedOnly": "只有进行中的投票可以结束",
"vote.err.publishTitleRequired": "请先填写投票标题再发布", "vote.err.publishTitleRequired": "请先填写投票标题再发布",
"vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段", "vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段",
+1
View File
@@ -840,6 +840,7 @@ var zhTWDict = map[string]string{
"vote.err.fieldSerialize": "欄位選項序列化失敗", "vote.err.fieldSerialize": "欄位選項序列化失敗",
"vote.err.editClosed": "已結束或歸檔的投票不允許編輯", "vote.err.editClosed": "已結束或歸檔的投票不允許編輯",
"vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布", "vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布",
"vote.err.restrictedPublish": "當前處於平台處置期,已暫停發布投票",
"vote.err.closePublishedOnly": "只有進行中的投票可以結束", "vote.err.closePublishedOnly": "只有進行中的投票可以結束",
"vote.err.publishTitleRequired": "請先填寫投票標題再發布", "vote.err.publishTitleRequired": "請先填寫投票標題再發布",
"vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位", "vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位",
+27 -6
View File
@@ -94,10 +94,16 @@ func getHotVotesCached(ctx context.Context, c cache.Cache) ([]*db.Vote, error) {
return votes, nil return votes, nil
} }
// filterVotesByOwnerFeature 过滤掉箱主已关闭投票功能的投票(开关 / 定时 / 定量收集), // filterVotesForExplore 过滤掉不该出现在探索页的投票:
// 避免探索页出现点了打不开的死链。功能配置批量加载,避免逐条查询造成 N+1; //
// 加载失败时按原样返回,依赖表抖动不应导致探索页空白。 // 1. 箱主已关闭投票功能(开关 / 定时 / 定量收集)—— 避免点开是死链;
func filterVotesByOwnerFeature(ctx context.Context, votes []*db.Vote) []*db.Vote { // 2. 箱主处于站方处置中 —— 与首页箱主列表(GetShowInHome 用
// activeRestrictionsSubQuery)保持同一口径。此前只过滤了功能开关,
// 于是同一张探索页里会出现「箱主本人在列表里消失了,他的投票还在曝光」。
//
// 两次配置都是批量加载,避免逐条查询造成 N+1;任一侧加载失败时按「该项不过滤」
// 处理,依赖表抖动不应让探索页整页空白。
func filterVotesForExplore(ctx context.Context, votes []*db.Vote) []*db.Vote {
if len(votes) == 0 { if len(votes) == 0 {
return votes 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) controls, err := db.FeatureControls.ListByUserIDs(ctx.Request().Context(), ownerIDs, db.FeatureVote)
if err != nil { if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch load vote feature controls") 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() now := time.Now()
out := make([]*db.Vote, 0, len(votes)) out := make([]*db.Vote, 0, len(votes))
for _, v := range votes { for _, v := range votes {
if restricted[v.UserID] {
continue
}
fc, ok := controls[v.UserID] fc, ok := controls[v.UserID]
if !ok { if !ok {
fc = db.DefaultFeatureControl(v.UserID, db.FeatureVote) 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") logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get hot votes")
hotVotes = []*db.Vote{} hotVotes = []*db.Vote{}
} }
ctx.Data["HotVotes"] = filterVotesByOwnerFeature(ctx, hotVotes) ctx.Data["HotVotes"] = filterVotesForExplore(ctx, hotVotes)
allVotes, err := db.Votes.ListPublishedPublic(ctx.Request().Context(), 30) allVotes, err := db.Votes.ListPublishedPublic(ctx.Request().Context(), 30)
if err != nil { if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get public votes for explore") logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get public votes for explore")
allVotes = []*db.Vote{} allVotes = []*db.Vote{}
} }
ctx.Data["AllVotes"] = filterVotesByOwnerFeature(ctx, allVotes) ctx.Data["AllVotes"] = filterVotesForExplore(ctx, allVotes)
// 计算总页数(向上取整) // 计算总页数(向上取整)
totalPages := (int(total) + pageSize - 1) / pageSize totalPages := (int(total) + pageSize - 1) / pageSize
+15
View File
@@ -915,6 +915,21 @@ func VotePublish(ctx context.Context) {
return 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 { if err := validateForPublish(ctx, vote); err != nil {
ctx.SetErrorFlash(err.Error()) ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes") ctx.Redirect("/user/questions#votes")
+22
View File
@@ -607,6 +607,18 @@ func Page(ctx context.Context) {
return 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() now := time.Now()
isOwner := ctx.IsLogged && ctx.User.ID == vote.UserID isOwner := ctx.IsLogged && ctx.User.ID == vote.UserID
@@ -703,6 +715,16 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
return 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) owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err != nil { if err != nil {