// Copyright 2022 E99p1ant. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. // Package moderation 提供跨场景统一的内容审核守卫。 // // 存在意义:提问、投票、私聊三条链路都要对用户输入做内容审核,过去各自调用 // censor.Text 并自行解释结果,判定口径一旦调整就要改多处,极易遗漏。 // 本包收敛为唯一入口:调审核 → 按箱主「审核严格度」裁决 → 返回是否放行。 package moderation import ( "context" "github.com/pkg/errors" "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" ) // StrictnessFor 解析账号最终生效的严格度档位: // - 账号显式设置了档位(lax / standard / strict)→ 用该档位; // - 账号未设置(空值,存量账号的默认形态)→ 跟随站点默认 // [security] default_moderation_strictness; // - 站点默认同样为空或非法 → 回退 censor.DefaultStrictness(严格)。 // // 回退方向刻意取「严格」:配置缺失或非法时宁可从严,也不要因为识别失败 // 把全站静默切到最松档(那等于在配置出错时悄悄放大漏放面)。 func StrictnessFor(raw string) censor.Strictness { if raw != "" { return censor.ParseStrictness(raw) } 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 执行一次内容审核,并按箱主严格度裁决是否放行。 // // 这是提问 / 投票 / 私聊三类场景**唯一**应当使用的审核入口。 // // 返回: // - resp:审核明细,调用方用于落 content_censor_metadata 等审计字段;裁决失败时可能为 nil。 // - err != nil:应当拒绝本次提交,错误信息已按 lang 本地化,可直接展示给用户。 // // 审核服务不可用(超时/报错)时仍为 fail-closed 拒绝提交:这是引入本能力之前就存在的 // 行为,不在本次改造范围内(相关可用性问题另见 outputs/llm-moderation-feasibility 评审)。 func Check(ctx context.Context, strictness censor.Strictness, lang, text string) (*censor.TextCensorResponse, error) { resp, err := censor.Text(ctx, text) if err != nil { logrus.WithContext(ctx).WithError(err). WithField("strictness", string(strictness)). Error("Failed to censor text") return nil, errors.New(i18n.T(lang, "censor.unavailable")) } if resp == nil { return nil, errors.New(i18n.T(lang, "censor.unavailable")) } decision, detail := censor.Decide(string(strictness), resp) if decision.Blocked() { logrus.WithContext(ctx). WithField("strictness", string(strictness)). WithField("source", resp.SourceName). WithField("forbidden_type", resp.ForbiddenType). Info("Text blocked by moderation strictness policy") return resp, errors.New(resp.ErrorMessageFor(detail)) } return resp, nil }