91 lines
3.9 KiB
Go
91 lines
3.9 KiB
Go
// 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
|
||
}
|