Files
tamabox/internal/security/moderation/moderation.go
T
2026-10-19 01:11:28 +08:00

91 lines
3.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}