diff --git a/.workbuddy/memory/2026-10-18.md b/.workbuddy/memory/2026-10-18.md index 2675895..2ff6e87 100644 --- a/.workbuddy/memory/2026-10-18.md +++ b/.workbuddy/memory/2026-10-18.md @@ -443,3 +443,19 @@ Community rules and content boundaries / コミュニティルールとコンテ ⚠️ 用户反馈「成功页按钮还是老样子」时,先确认**是否真的部署了**:直接抓线上 HTML 看有没有新标记 (`grep 'uk-margin-large-top uk-text-center'` 在 `/rules` 上)。实测那次线上仍是 `uk-margin-large-top"` 旧结构 → 与浏览器缓存无关,是代码没更新。以后别先怀疑缓存。 +(第二次复测已生效:`uk-text-center` 在、弹窗按钮 `>送信<`、成功页 `flex: 1 1 160px` 出现 2 次。) + +## 受控用户强制最高审核档(用户要求:不分级,全部级别) + +- `moderation.EffectiveStrictness(ctx, userID, raw)`:**有任何处置(L1–L4)就返回 strict**,忽略账号设置。 + **处置查询失败时回退账号自身设置**,而不是"失败即强制 strict" —— 否则 DB 抖动会让全站突然加严、 + 正常提交被大面积误拒,影响面远大于漏放一个受控账号。 +- 7 个审核调用点全部从 `StrictnessFor(raw)` 改为 `EffectiveStrictness(...)`:chat_handler、follow_up×2、 + page、question、user/vote×3、vote/vote。`StrictnessFor` 保留为纯解析函数(模板回显用)。 +- `UpdateModerationStrictness`:处置期内直接拒绝保存并 flash `admin.flash.strictnessLocked`。 +- 设置页 `templates/user/moderation-strictness.html`:`$root.StrictnessLocked` 时提示 + select/按钮 disabled。 + ⚠️ 该文件仍只能用 shell/python 改(Edit/Write 会注入 `data-page-node-id`),本次用 python 精确替换。 +- Profile handler 注入 `StrictnessLocked`(读 ActiveLevel)。 + +验证:`go build ./...` EXIT=0;checktpl OK;db/i18n/templates 测试全绿。 +性能:每次内容提交多一次 `user_restrictions` 查询(走 user_id 索引),审核本身要走七牛网络,1ms 可忽略。 diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index 6aeb04f..a965174 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -778,6 +778,7 @@ var enDict = map[string]string{ "admin.flash.moderationStrictnessUpdated": "Moderation strictness updated", "admin.flash.moderationStrictnessInvalid": "Invalid moderation strictness value, nothing was saved", + "admin.flash.strictnessLocked": "Moderation strictness is locked at the highest level during a restriction and restored afterwards", // Vote management: owner visible (P1) "vote.err.invalidID": "Invalid vote ID", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index 1917937..99523f3 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -778,6 +778,7 @@ var jaDict = map[string]string{ "admin.flash.moderationStrictnessUpdated": "審核の厳格度を更新しました", "admin.flash.moderationStrictnessInvalid": "審核の厳格度の値が不正なため、保存しませんでした", + "admin.flash.strictnessLocked": "措置期間中はモデレーション強度が最高に固定されます。解除後に元の設定へ戻ります", // 投票管理:所有者に表示(P1) "vote.err.invalidID": "無効な投票 ID", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 1ce449f..04617ed 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -778,6 +778,7 @@ var zhCNDict = map[string]string{ "admin.flash.moderationStrictnessUpdated": "审核严格度已更新", "admin.flash.moderationStrictnessInvalid": "审核严格度取值无效,未保存", + "admin.flash.strictnessLocked": "处置期间审核严格度已锁定为最高档,解除后自动恢复你的原设置", // 投票管理:箱主可见(P1) "vote.err.invalidID": "无效的投票 ID", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 4f7f5e9..f8fa6bf 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -778,6 +778,7 @@ var zhTWDict = map[string]string{ "admin.flash.moderationStrictnessUpdated": "審核嚴格度已更新", "admin.flash.moderationStrictnessInvalid": "審核嚴格度取值無效,未儲存", + "admin.flash.strictnessLocked": "處置期間審核嚴格度已鎖定為最高檔,解除後自動恢復你的原設定", // 投票管理:箱主可見(P1) "vote.err.invalidID": "無效的投票 ID", diff --git a/internal/route/chat/chat_handler.go b/internal/route/chat/chat_handler.go index 876e0a2..1be7c66 100644 --- a/internal/route/chat/chat_handler.go +++ b/internal/route/chat/chat_handler.go @@ -275,7 +275,7 @@ func SendMessage(ctx context.Context, cache cache.Cache) { // 内容安全检测(仅文本) // 统一守卫:与提问 / 投票共用同一份档位判定,箱主的「审核严格度」决定放行与否。 if content != "" { - if _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), content); err != nil { + if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), content); err != nil { // 审核拦截:就地重渲染会话页,回填已输入内容并展示拦截原因。 // 不复用 SetErrorFlash + Redirect:那会导致输入框内容在刷新后丢失(用户已确认的无提示+内容消失问题)。 renderConversation(ctx, domain, token, content, err) diff --git a/internal/security/moderation/moderation.go b/internal/security/moderation/moderation.go index 638ed75..6a93bf6 100644 --- a/internal/security/moderation/moderation.go +++ b/internal/security/moderation/moderation.go @@ -16,6 +16,7 @@ import ( "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/conf" + "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/i18n" "github.com/tamakyi/TamaBox/internal/security/censor" ) @@ -35,6 +36,24 @@ func StrictnessFor(raw string) censor.Strictness { return censor.ParseStrictness(conf.Security.DefaultModerationStrictness) } +// EffectiveStrictness 返回账号**实际生效**的审核档位,是内容审核应当使用的入口。 +// +// 处于处置期(L1–L4)的账号一律强制 strict,忽略账号自身设置: +// 限制期内允许保留 lax / standard,等于给了箱主一个免审通道 —— 一边被限制、 +// 一边把门槛调到最低,是明显可被利用的组合。 +// +// 处置查询失败时**回退账号自身设置**,而不是"失败即强制 strict": +// DB 抖动时反向兜底会让全站突然加严,正常用户的提交被大面积误拒, +// 影响面远大于「漏放一个受控账号」。 +func EffectiveStrictness(ctx context.Context, userID uint, raw string) censor.Strictness { + if userID != 0 { + if level, _, err := db.Restrictions.ActiveLevel(ctx, userID); err == nil && level != "" { + return censor.StrictnessStrict + } + } + return StrictnessFor(raw) +} + // Check 执行一次内容审核,并按箱主严格度裁决是否放行。 // // 这是提问 / 投票 / 私聊三类场景**唯一**应当使用的审核入口。 diff --git a/route/question/follow_up.go b/route/question/follow_up.go index 549d804..494378b 100644 --- a/route/question/follow_up.go +++ b/route/question/follow_up.go @@ -102,7 +102,7 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi // 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行 // (提问 / 投票 / 私聊共用同一份判定,见 internal/security/moderation)。 - if _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), f.Content); err != nil { + if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), f.Content); err != nil { ctx.SetErrorFlash(err.Error()) ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) return @@ -176,7 +176,7 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio followUpID := uint(ctx.ParamInt("followUpID")) // 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。 - if _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil { + if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil { // 同请求渲染,用 SetError;原先的 SetErrorFlash 在这里不会显示出来(既有缺陷)。 ctx.SetError(err, f) ctx.Success("question/item") diff --git a/route/question/page.go b/route/question/page.go index a5b535e..df90e3c 100644 --- a/route/question/page.go +++ b/route/question/page.go @@ -356,7 +356,7 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r // 内容审核严格度:提问 / 私聊 / 投票三条链路共用同一份档位(users.moderation_strictness), // 取值解释与裁决逻辑见 internal/security/censor/policy.go。 // 在此解析一次,供下方私聊分支与普通提问分支复用,保证同一请求的判定口径一致。 - strictness := moderation.StrictnessFor(pageUser.ModerationStrictness) + strictness := moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness) // ---------- 私聊处理 ---------- if f.StartChat == "true" && ctx.IsLogged { diff --git a/route/question/question.go b/route/question/question.go index 3ff990c..eb1fbf7 100644 --- a/route/question/question.go +++ b/route/question/question.go @@ -185,7 +185,7 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question // 🚨 Content security check. // 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed, // 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。 - censorResponse, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), answer) + censorResponse, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), answer) if err != nil { ctx.SetError(err, f) ctx.Success("question/item") diff --git a/route/user/moderation.go b/route/user/moderation.go index 794a811..2f9fa18 100644 --- a/route/user/moderation.go +++ b/route/user/moderation.go @@ -25,6 +25,14 @@ func UpdateModerationStrictness(ctx context.Context, f form.UpdateModerationStri return } + // 处置期内禁止修改档位:EffectiveStrictness 会强制 strict,允许保存只会让箱主 + // 以为「我调成宽松了」,实际审核仍按严格执行,是更糟的误导。 + if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), ctx.User.ID); err == nil && level != "" { + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.flash.strictnessLocked")) + ctx.Redirect(returnTab(ctx)) + return + } + // 非法取值一律拒绝保存:宁可保留原档位,也不要写入一个解释不了的中间状态。 if !censor.IsValidStrictness(f.Strictness) { ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.flash.moderationStrictnessInvalid")) diff --git a/route/user/profile.go b/route/user/profile.go index fcb8c5b..03cd6cb 100644 --- a/route/user/profile.go +++ b/route/user/profile.go @@ -33,6 +33,11 @@ func Profile(ctx context.Context) { ctx.Data["HasPagePassword"] = ctx.User.PagePassword != "" ctx.Data["HasAskPassword"] = ctx.User.AskPassword != "" ctx.Data["ActiveProfileTab"] = ProfileTab(ctx.Query("tab")) + // 处置期内审核严格度被强制为最高档且不可修改(见 moderation.EffectiveStrictness), + // 设置页需要把下拉和保存按钮禁用,否则箱主会以为自己的修改生效了。 + if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), ctx.User.ID); err == nil && level != "" { + ctx.Data["StrictnessLocked"] = true + } ctx.Data["TagLimits"] = tagLimits() ctx.Data["ChatLimits"] = chatLimits() InjectFeatureViews(ctx) diff --git a/route/user/vote.go b/route/user/vote.go index e92561f..d3c43a8 100644 --- a/route/user/vote.go +++ b/route/user/vote.go @@ -59,7 +59,7 @@ type voteForm struct { // censorCheck 对单段文本做内容审核。与提问 / 私聊共用 moderation.Check 守卫, // 是否拦截由发起人的「审核严格度」档位决定;fail-closed,服务异常时拒绝发布。 func censorCheck(ctx context.Context, text string) error { - _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(ctx.User.ModerationStrictness), ctx.Lang(), text) + _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), ctx.User.ID, ctx.User.ModerationStrictness), ctx.Lang(), text) return err } @@ -146,7 +146,7 @@ func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) { // 内容审核(fail-closed):非草稿执行;草稿跳过(见上说明) var censorMeta datatypes.JSON if !draft { - titleCensor, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(ctx.User.ModerationStrictness), ctx.Lang(), title) + titleCensor, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), ctx.User.ID, ctx.User.ModerationStrictness), ctx.Lang(), title) if err != nil { return nil, err } @@ -974,7 +974,7 @@ func voteOwnerStrictness(ctx context.Context, ownerID uint) censor.Strictness { Error("Failed to get vote owner for moderation strictness") return censor.StrictnessStrict } - return moderation.StrictnessFor(owner.ModerationStrictness) + return moderation.EffectiveStrictness(ctx.Request().Context(), ownerID, owner.ModerationStrictness) } // censorVoteForPublish 发布前对投票全部可见文本做内容审核(fail-closed)。 diff --git a/route/vote/vote.go b/route/vote/vote.go index 0142c94..5f88157 100644 --- a/route/vote/vote.go +++ b/route/vote/vote.go @@ -67,7 +67,7 @@ func InvalidateResultsCache(voteID uint) { func censorCheck(ctx context.Context, owner *db.User, lang, text string) error { strictness := censor.StrictnessStrict if owner != nil { - strictness = moderation.StrictnessFor(owner.ModerationStrictness) + strictness = moderation.EffectiveStrictness(ctx.Request().Context(), owner.ID, owner.ModerationStrictness) } _, err := moderation.Check(ctx.Request().Context(), strictness, lang, text) return err diff --git a/templates/user/moderation-strictness.html b/templates/user/moderation-strictness.html index c886b91..7f9f17f 100644 --- a/templates/user/moderation-strictness.html +++ b/templates/user/moderation-strictness.html @@ -15,6 +15,11 @@
+ {{ if $root.StrictnessLocked }} +{{t $root.LangCode "admin.flash.strictnessLocked"}}
+