This commit is contained in:
tamakyi
2026-10-19 01:11:28 +08:00
parent 84f8afe8b1
commit bd613a6f5a
15 changed files with 68 additions and 11 deletions
+16
View File
@@ -443,3 +443,19 @@ Community rules and content boundaries / コミュニティルールとコンテ
⚠️ 用户反馈「成功页按钮还是老样子」时,先确认**是否真的部署了**:直接抓线上 HTML 看有没有新标记 ⚠️ 用户反馈「成功页按钮还是老样子」时,先确认**是否真的部署了**:直接抓线上 HTML 看有没有新标记
(`grep 'uk-margin-large-top uk-text-center'` 在 `/rules` 上)。实测那次线上仍是 `uk-margin-large-top"` (`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 可忽略。
+1
View File
@@ -778,6 +778,7 @@ var enDict = map[string]string{
"admin.flash.moderationStrictnessUpdated": "Moderation strictness updated", "admin.flash.moderationStrictnessUpdated": "Moderation strictness updated",
"admin.flash.moderationStrictnessInvalid": "Invalid moderation strictness value, nothing was saved", "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 management: owner visible (P1)
"vote.err.invalidID": "Invalid vote ID", "vote.err.invalidID": "Invalid vote ID",
+1
View File
@@ -778,6 +778,7 @@ var jaDict = map[string]string{
"admin.flash.moderationStrictnessUpdated": "審核の厳格度を更新しました", "admin.flash.moderationStrictnessUpdated": "審核の厳格度を更新しました",
"admin.flash.moderationStrictnessInvalid": "審核の厳格度の値が不正なため、保存しませんでした", "admin.flash.moderationStrictnessInvalid": "審核の厳格度の値が不正なため、保存しませんでした",
"admin.flash.strictnessLocked": "措置期間中はモデレーション強度が最高に固定されます。解除後に元の設定へ戻ります",
// 投票管理:所有者に表示(P1) // 投票管理:所有者に表示(P1)
"vote.err.invalidID": "無効な投票 ID", "vote.err.invalidID": "無効な投票 ID",
+1
View File
@@ -778,6 +778,7 @@ var zhCNDict = map[string]string{
"admin.flash.moderationStrictnessUpdated": "审核严格度已更新", "admin.flash.moderationStrictnessUpdated": "审核严格度已更新",
"admin.flash.moderationStrictnessInvalid": "审核严格度取值无效,未保存", "admin.flash.moderationStrictnessInvalid": "审核严格度取值无效,未保存",
"admin.flash.strictnessLocked": "处置期间审核严格度已锁定为最高档,解除后自动恢复你的原设置",
// 投票管理:箱主可见(P1) // 投票管理:箱主可见(P1)
"vote.err.invalidID": "无效的投票 ID", "vote.err.invalidID": "无效的投票 ID",
+1
View File
@@ -778,6 +778,7 @@ var zhTWDict = map[string]string{
"admin.flash.moderationStrictnessUpdated": "審核嚴格度已更新", "admin.flash.moderationStrictnessUpdated": "審核嚴格度已更新",
"admin.flash.moderationStrictnessInvalid": "審核嚴格度取值無效,未儲存", "admin.flash.moderationStrictnessInvalid": "審核嚴格度取值無效,未儲存",
"admin.flash.strictnessLocked": "處置期間審核嚴格度已鎖定為最高檔,解除後自動恢復你的原設定",
// 投票管理:箱主可見(P1) // 投票管理:箱主可見(P1)
"vote.err.invalidID": "無效的投票 ID", "vote.err.invalidID": "無效的投票 ID",
+1 -1
View File
@@ -275,7 +275,7 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
// 内容安全检测(仅文本) // 内容安全检测(仅文本)
// 统一守卫:与提问 / 投票共用同一份档位判定,箱主的「审核严格度」决定放行与否。 // 统一守卫:与提问 / 投票共用同一份档位判定,箱主的「审核严格度」决定放行与否。
if content != "" { 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:那会导致输入框内容在刷新后丢失(用户已确认的无提示+内容消失问题)。 // 不复用 SetErrorFlash + Redirect:那会导致输入框内容在刷新后丢失(用户已确认的无提示+内容消失问题)。
renderConversation(ctx, domain, token, content, err) renderConversation(ctx, domain, token, content, err)
@@ -16,6 +16,7 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n" "github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/security/censor" "github.com/tamakyi/TamaBox/internal/security/censor"
) )
@@ -35,6 +36,24 @@ func StrictnessFor(raw string) censor.Strictness {
return censor.ParseStrictness(conf.Security.DefaultModerationStrictness) 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 执行一次内容审核,并按箱主严格度裁决是否放行。 // Check 执行一次内容审核,并按箱主严格度裁决是否放行。
// //
// 这是提问 / 投票 / 私聊三类场景**唯一**应当使用的审核入口。 // 这是提问 / 投票 / 私聊三类场景**唯一**应当使用的审核入口。
+2 -2
View File
@@ -102,7 +102,7 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行 // 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行
// (提问 / 投票 / 私聊共用同一份判定,见 internal/security/moderation)。 // (提问 / 投票 / 私聊共用同一份判定,见 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.SetErrorFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return return
@@ -176,7 +176,7 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
followUpID := uint(ctx.ParamInt("followUpID")) 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 在这里不会显示出来(既有缺陷)。 // 同请求渲染,用 SetError;原先的 SetErrorFlash 在这里不会显示出来(既有缺陷)。
ctx.SetError(err, f) ctx.SetError(err, f)
ctx.Success("question/item") ctx.Success("question/item")
+1 -1
View File
@@ -356,7 +356,7 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
// 内容审核严格度:提问 / 私聊 / 投票三条链路共用同一份档位(users.moderation_strictness), // 内容审核严格度:提问 / 私聊 / 投票三条链路共用同一份档位(users.moderation_strictness),
// 取值解释与裁决逻辑见 internal/security/censor/policy.go。 // 取值解释与裁决逻辑见 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 { if f.StartChat == "true" && ctx.IsLogged {
+1 -1
View File
@@ -185,7 +185,7 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
// 🚨 Content security check. // 🚨 Content security check.
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed, // 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。 // 拒绝发布而非免审放行)。返回的 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 { if err != nil {
ctx.SetError(err, f) ctx.SetError(err, f)
ctx.Success("question/item") ctx.Success("question/item")
+8
View File
@@ -25,6 +25,14 @@ func UpdateModerationStrictness(ctx context.Context, f form.UpdateModerationStri
return 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) { if !censor.IsValidStrictness(f.Strictness) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.flash.moderationStrictnessInvalid")) ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.flash.moderationStrictnessInvalid"))
+5
View File
@@ -33,6 +33,11 @@ func Profile(ctx context.Context) {
ctx.Data["HasPagePassword"] = ctx.User.PagePassword != "" ctx.Data["HasPagePassword"] = ctx.User.PagePassword != ""
ctx.Data["HasAskPassword"] = ctx.User.AskPassword != "" ctx.Data["HasAskPassword"] = ctx.User.AskPassword != ""
ctx.Data["ActiveProfileTab"] = ProfileTab(ctx.Query("tab")) 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["TagLimits"] = tagLimits()
ctx.Data["ChatLimits"] = chatLimits() ctx.Data["ChatLimits"] = chatLimits()
InjectFeatureViews(ctx) InjectFeatureViews(ctx)
+3 -3
View File
@@ -59,7 +59,7 @@ type voteForm struct {
// censorCheck 对单段文本做内容审核。与提问 / 私聊共用 moderation.Check 守卫, // censorCheck 对单段文本做内容审核。与提问 / 私聊共用 moderation.Check 守卫,
// 是否拦截由发起人的「审核严格度」档位决定;fail-closed,服务异常时拒绝发布。 // 是否拦截由发起人的「审核严格度」档位决定;fail-closed,服务异常时拒绝发布。
func censorCheck(ctx context.Context, text string) error { 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 return err
} }
@@ -146,7 +146,7 @@ func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) {
// 内容审核(fail-closed):非草稿执行;草稿跳过(见上说明) // 内容审核(fail-closed):非草稿执行;草稿跳过(见上说明)
var censorMeta datatypes.JSON var censorMeta datatypes.JSON
if !draft { 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 { if err != nil {
return nil, err 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") Error("Failed to get vote owner for moderation strictness")
return censor.StrictnessStrict return censor.StrictnessStrict
} }
return moderation.StrictnessFor(owner.ModerationStrictness) return moderation.EffectiveStrictness(ctx.Request().Context(), ownerID, owner.ModerationStrictness)
} }
// censorVoteForPublish 发布前对投票全部可见文本做内容审核(fail-closed)。 // censorVoteForPublish 发布前对投票全部可见文本做内容审核(fail-closed)。
+1 -1
View File
@@ -67,7 +67,7 @@ func InvalidateResultsCache(voteID uint) {
func censorCheck(ctx context.Context, owner *db.User, lang, text string) error { func censorCheck(ctx context.Context, owner *db.User, lang, text string) error {
strictness := censor.StrictnessStrict strictness := censor.StrictnessStrict
if owner != nil { 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) _, err := moderation.Check(ctx.Request().Context(), strictness, lang, text)
return err return err
+7 -2
View File
@@ -15,6 +15,11 @@
<p class="uk-text-meta uk-text-small" data-page-node-id="GF3aXisM5hvonHBGY2m69p">{{t $root.LangCode "admin.moderationStrictnessHint"}}</p> <p class="uk-text-meta uk-text-small" data-page-node-id="GF3aXisM5hvonHBGY2m69p">{{t $root.LangCode "admin.moderationStrictnessHint"}}</p>
<p class="uk-text-meta uk-text-small" data-page-node-id="AoSpxE29Qnz8tgDhTEX8SU">{{t $root.LangCode "admin.moderationStrictnessBaseline"}}</p> <p class="uk-text-meta uk-text-small" data-page-node-id="AoSpxE29Qnz8tgDhTEX8SU">{{t $root.LangCode "admin.moderationStrictnessBaseline"}}</p>
{{ if $root.StrictnessLocked }}
<div class="uk-alert-warning" uk-alert>
<p>{{t $root.LangCode "admin.flash.strictnessLocked"}}</p>
</div>
{{ end }}
<!-- 档位列必须 uk-table-shrink + nowrap:右侧「拦截范围」文案很长, <!-- 档位列必须 uk-table-shrink + nowrap:右侧「拦截范围」文案很长,
自动布局会把该列压到极窄,导致 4 字档位名被逐字折行。 --> 自动布局会把该列压到极窄,导致 4 字档位名被逐字折行。 -->
@@ -48,7 +53,7 @@
解析走 internal/template 的 moderationStrictness 函数(同一份事实来源)。 */}} 解析走 internal/template 的 moderationStrictness 函数(同一份事实来源)。 */}}
{{ $current := moderationStrictness $root.LoggedUser.ModerationStrictness }} {{ $current := moderationStrictness $root.LoggedUser.ModerationStrictness }}
<div class="uk-margin-small-top" data-page-node-id="HIWMgKH7FXZ9BYygBQ23A8"> <div class="uk-margin-small-top" data-page-node-id="HIWMgKH7FXZ9BYygBQ23A8">
<select name="strictness" class="uk-select uk-width-medium" style="max-width: 100%;" data-page-node-id="43cBp9IvsqBOUUpcIqISNq"> <select name="strictness" class="uk-select uk-width-medium" style="max-width: 100%;"{{ if $root.StrictnessLocked }} disabled{{ end }} data-page-node-id="43cBp9IvsqBOUUpcIqISNq">
<option value="lax"{{ if eq $current "lax" }} selected{{ end }} data-page-node-id="XLfr3E5HDhGCCe27M9DQQX"> <option value="lax"{{ if eq $current "lax" }} selected{{ end }} data-page-node-id="XLfr3E5HDhGCCe27M9DQQX">
{{t $root.LangCode "admin.moderationStrictnessOption.lax"}} {{t $root.LangCode "admin.moderationStrictnessOption.lax"}}
</option> </option>
@@ -66,7 +71,7 @@
</div> </div>
<div class="tb-card-save" data-page-node-id="vmD40ecSRAGLrWqlz5F2c5"> <div class="tb-card-save" data-page-node-id="vmD40ecSRAGLrWqlz5F2c5">
<button type="submit" class="uk-button uk-button-primary" data-page-node-id="r04SFvzxXIgjRvgG0xaQIw">{{t $root.LangCode "admin.updateModerationStrictness"}}</button> <button type="submit" class="uk-button uk-button-primary"{{ if $root.StrictnessLocked }} disabled{{ end }} data-page-node-id="r04SFvzxXIgjRvgG0xaQIw">{{t $root.LangCode "admin.updateModerationStrictness"}}</button>
</div> </div>
</form> </form>
</div> </div>