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 {