aliyun sensor server

This commit is contained in:
tamakyi
2026-09-10 18:04:19 +08:00
parent e3e59e7c69
commit 8e2325eda5
38 changed files with 3147 additions and 344 deletions
+42
View File
@@ -42,6 +42,11 @@ func Init() error {
return errors.Wrap(err, "map 'security'")
}
if err := validateTextCensorSource(Security.EnableTextCensor, Security.TextCensorSource,
App.QiniuAccessKey, App.QiniuAccessSecret, App.AliyunAccessKey, App.AliyunAccessKeySecret); err != nil {
return err
}
if err := File.Section("server").MapTo(&Server); err != nil {
return errors.Wrap(err, "map 'server'")
}
@@ -103,3 +108,40 @@ func Init() error {
return nil
}
// validateTextCensorSource 校验文本审核通道([security] text_censor_source)与凭据的搭配。
//
// 单独抽成纯函数(只依赖入参、不读全局)是为了能直接被单测覆盖,也让 Init 保持轻薄。
//
// 规则:
// - 审核整体关闭(enabled=false)→ 不校验,此时不需要任何凭据。
// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都没配凭据时
// 维持既有行为(运行期 fail-closed),不阻断启动 —— 否则 app.sample.ini 原样拷贝
// (enable_text_censor = true 且凭据留空)将无法启动,破坏开箱流程。
// - 显式选 qiniu / aliyun → 必须配齐该通道的凭据,否则启动即失败。
// 理由:缺凭据时运行期审核必然失败,而审核失败是 fail-closed(拒绝提交),
// 会让发布/私信整站不可用,且现象隐蔽(只在日志可见)——不如启动就报错。
// - 其它取值 → 直接报错,不做静默回退:拼写错误若被悄悄当成 auto,
// 运维会以为「已经只用七牛了」,实际仍在走双通道。
func validateTextCensorSource(enabled bool, source, qiniuKey, qiniuSecret, aliyunKey, aliyunSecret string) error {
if !enabled {
return nil
}
switch strings.ToLower(strings.TrimSpace(source)) {
case "", CensorSourceAuto:
return nil
case CensorSourceQiniu:
if qiniuKey == "" || qiniuSecret == "" {
return errors.New("security.text_censor_source = qiniu requires app.qiniu_access_key and app.qiniu_access_secret")
}
return nil
case CensorSourceAliyun:
if aliyunKey == "" || aliyunSecret == "" {
return errors.New("security.text_censor_source = aliyun requires app.aliyun_access_key and app.aliyun_access_key_secret")
}
return nil
default:
return errors.Errorf("security.text_censor_source must be one of qiniu / aliyun / auto, got %q", source)
}
}
+35 -6
View File
@@ -12,6 +12,19 @@ var (
BuildCommit = "dev"
)
// 文本审核服务通道取值([security] text_censor_source)。
//
// 定义在 conf 侧是为了让「配置校验」(conf.Init)与「通道装配」
// (internal/security/censor)引用同一份字面量,避免两边各写一份字符串而漂移。
const (
// CensorSourceAuto 七牛优先、阿里云兜底(历史行为,留空同义)。
CensorSourceAuto = "auto"
// CensorSourceQiniu 只用七牛。
CensorSourceQiniu = "qiniu"
// CensorSourceAliyun 只用阿里云。
CensorSourceAliyun = "aliyun"
)
var (
App struct {
Production bool `ini:"production"`
@@ -51,12 +64,28 @@ var (
// (域名/题目不存在等均走 302 业务跳转,不产生计数)一律放行,仅命中后台扫描特征
// 路径(KindSensitive)才参与统计封禁。避免共享出口(CDN 边缘 / CGNAT NAT444)下
// 正常用户被 404 探测流量连坐误伤。如需恢复对纯 404 字典扫描的封禁再置 true。
BanTrackNotFound bool `ini:"ban_track_notfound"`
BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数
BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数
BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒)
BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟)
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
BanTrackNotFound bool `ini:"ban_track_notfound"`
BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数
BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数
BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒)
BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟)
// DefaultModerationStrictness 站点默认的「内容审核严格度」档位
// (lax / standard / strict)。仅对未单独设置档位的账号生效:
// 账号一旦显式选择了档位就以账号设置为准(见 moderation.StrictnessFor)。
// 留空或非法值回退 strict(最严档)—— 保证升级不会放宽任何拦截;
// 严格档拦截面不小于升级前,部署后默认拦截面可能略有扩大;
// 若希望默认更少误伤,在本段显式写 standard 即可,无需改代码。
DefaultModerationStrictness string `ini:"default_moderation_strictness"`
// TextCensorSource 文本审核服务通道,取值:
// qiniu —— 只用七牛(凭据见 [app] qiniu_access_key / qiniu_access_secret)
// aliyun —— 只用阿里云(走内容安全 2.0 / 文本审核增强版 TextModeration;
// 开通只需实名认证,个人账号可用。凭据见 [app] aliyun_access_key / aliyun_access_key_secret)
// auto(默认,留空同义)—— 保持历史行为:七牛优先,未通过时用阿里云二次复核
// 仅显式选定的通道会被调用;选定通道缺少凭据时启动即报错(见 conf.Init),
// 避免运行时审核必然失败 —— 审核失败是 fail-closed(拒绝提交),
// 会让发布/私信整站不可用,且现象隐蔽(只在日志里)。
TextCensorSource string `ini:"text_censor_source"`
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
}
Server struct {
+55
View File
@@ -0,0 +1,55 @@
// 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 conf
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestValidateTextCensorSource 覆盖 [security] text_censor_source 与 [app] 凭据的搭配校验。
//
// 关注两类真实故障:
// 1. 显式选定通道却缺凭据 —— 运行期审核必然失败,而审核失败是 fail-closed,
// 会让整站发布/私信不可用且现象隐蔽,必须在启动阶段拦住;
// 2. 取值拼写错误 —— 若被静默回退成 auto,运维会误以为「已经只用七牛了」。
func TestValidateTextCensorSource(t *testing.T) {
for _, tc := range []struct {
name string
enabled bool
source string
qk, qs string
ak, as string
wantErr bool
}{
{name: "审核关闭:不校验任何通道配置", enabled: false, source: "aliyun"},
{name: "审核关闭:非法取值也不报错", enabled: false, source: "qinui"},
{name: "留空:通过(历史默认行为)", enabled: true, source: ""},
{name: "auto:通过", enabled: true, source: "auto"},
{name: "auto:无任何凭据也通过(不阻断开箱启动)", enabled: true, source: "auto"},
{name: "qiniu:凭据齐备通过", enabled: true, source: "qiniu", qk: "k", qs: "s"},
{name: "qiniu:缺 access key 报错", enabled: true, source: "qiniu", qs: "s", wantErr: true},
{name: "qiniu:缺 access secret 报错", enabled: true, source: "qiniu", qk: "k", wantErr: true},
{name: "aliyun:凭据齐备通过", enabled: true, source: "aliyun", ak: "k", as: "s"},
{name: "aliyun:缺凭据报错", enabled: true, source: "aliyun", wantErr: true},
{name: "大小写与空白不敏感", enabled: true, source: " QINIU ", qk: "k", qs: "s"},
{name: "非法取值:报错而非静默回退为 auto", enabled: true, source: "qinui", qk: "k", qs: "s", wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
err := validateTextCensorSource(tc.enabled, tc.source, tc.qk, tc.qs, tc.ak, tc.as)
if tc.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
})
}
}
+29 -3
View File
@@ -37,6 +37,9 @@ type UsersStore interface {
// 提问/私聊/投票三个面板共用一份),必须分开写,避免单侧保存误清空另一侧。
UpdateHarassmentType(ctx context.Context, id uint, typ HarassmentSettingType) error
UpdateBlockWords(ctx context.Context, id uint, words string) error
// UpdateModerationStrictness 更新内容审核严格度(lax / standard / strict,空值表示跟随站点默认)。
// 只写 moderation_strictness 列,不影响防骚扰与屏蔽词。
UpdateModerationStrictness(ctx context.Context, id uint, strictness string) error
Authenticate(ctx context.Context, email, password string) (*User, error)
ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error
UpdatePassword(ctx context.Context, id uint, newPassword string) error
@@ -104,9 +107,15 @@ type User struct {
Notify NotifyType `json:"notify"`
HarassmentSetting HarassmentSettingType `json:"harassment_setting"`
BlockWords string `json:"-"`
Tags string `json:"tags" gorm:"type:text"`
ShowInHome bool `json:"show_in_home" gorm:"default:false;index"`
UseCustomQRColor bool `json:"use_custom_qr_color" gorm:"default:false"`
// ModerationStrictness 内容审核严格度:lax / standard / strict。
// 空值表示「跟随站点默认」([security] default_moderation_strictness),
// 存量账号迁移后即为空值形态 —— 缺省回退 strict(最严档),
// 保证引入该字段这一动作不会放宽任何账号的拦截面。
// 取值解释与裁决逻辑见 internal/security/censor/policy.go。
ModerationStrictness string `json:"-" gorm:"column:moderation_strictness;type:varchar(16);default:''"`
Tags string `json:"tags" gorm:"type:text"`
ShowInHome bool `json:"show_in_home" gorm:"default:false;index"`
UseCustomQRColor bool `json:"use_custom_qr_color" gorm:"default:false"`
PagePassword string `json:"-" gorm:"column:page_password;type:varchar(255);default:''"`
AskPassword string `json:"-" gorm:"column:ask_password;type:varchar(255);default:''"`
@@ -390,6 +399,23 @@ func (db *users) UpdateBlockWords(ctx context.Context, id uint, words string) er
return nil
}
// UpdateModerationStrictness 仅更新内容审核严格度。
// 只写 moderation_strictness 列,不动 harassment_setting 与 block_words,
// 避免三个独立设置面板互相覆盖。
//
// 取值合法性由上层(route/user)在写入前校验。此处不再二次校验是有意为之:
// 本包不能 import internal/security/censor(后者要读 censor_logs,反向依赖本包,
// 会形成 import 环)。即便绕过上层写入脏值也不会有安全问题——解析层的
// censor.ParseStrictness 对未知取值回退「严格」档,方向是保守的(见 policy.go)。
func (db *users) UpdateModerationStrictness(ctx context.Context, id uint, strictness string) error {
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"ModerationStrictness": strictness,
}).Error; err != nil {
return errors.Wrap(err, "update user")
}
return nil
}
func (db *users) Authenticate(ctx context.Context, email, password string) (*User, error) {
u, err := db.GetByEmail(ctx, email)
if err != nil {
+7
View File
@@ -37,6 +37,13 @@ type UpdateBlockWords struct {
BlockWords string `label:"屏蔽词"`
}
// UpdateModerationStrictness 内容审核严格度(lax / standard / strict)。
// 与屏蔽词一样是独立的设置入口:两者分处不同的表单,必须分开写,
// 否则单侧保存会把另一侧的值清空(见 route/user/harassment.go 顶部注释)。
type UpdateModerationStrictness struct {
Strictness string `form:"strictness"`
}
type UpdateTags struct {
Tags string `form:"tags"`
ShowInHome string `form:"show_in_home"`
+20 -1
View File
@@ -215,6 +215,22 @@ var enDict = map[string]string{
"admin.globalBlockWords": "Global blocked words",
"admin.globalBlockWordsHint": "Blocked words apply to questions, chat messages sent to you, and vote submissions. This single setting is shared across the question / chat / vote panels and takes effect immediately from any of them.",
"admin.updateBlockWords": "Save blocked words",
// Moderation strictness: shared by questions / polls / chat. Decision rules live in internal/security/censor/policy.go.
"admin.moderationStrictness": "Moderation strictness",
"admin.moderationStrictnessHint": "Applies to questions, polls and chat messages alike. This controls whether content is blocked once the moderation service flags it - it does not affect blocked words, which are always rejected outright.",
"admin.moderationStrictnessBaseline": "Baseline (always enforced, cannot be disabled): political, terrorism and pornographic content, plus hits on the moderation service's custom wordlists.",
"admin.moderationStrictnessColumnLevel": "Level",
"admin.moderationStrictnessColumnScope": "Blocked when",
"admin.moderationStrictnessOption.lax": "Lenient",
"admin.moderationStrictnessOption.standard": "Standard",
"admin.moderationStrictnessOption.strict": "Strict",
"admin.moderationStrictnessScope.lax": "Only baseline content is blocked; everything else is allowed (fewest false positives).",
"admin.moderationStrictnessScope.standard": "Baseline content, plus abuse and contraband with a confidence of 90% or higher.",
"admin.moderationStrictnessScope.strict": "Baseline content, plus abuse, contraband, spam, ads, flooding, meaningless and harmful content at any confidence; also trusts the moderation service's overall block recommendation. The strictest tier — its block scope is never smaller than before this setting existed.",
"admin.moderationStrictnessUnsetHint": "Falls back to the site default when not set.",
"admin.updateModerationStrictness": "Save moderation strictness",
"admin.allowChat": "Allow registered users to start a chat with me",
"admin.dailyLimitHint": "0 means unlimited",
"admin.updateChat": "Update chat settings",
@@ -226,7 +242,7 @@ var enDict = map[string]string{
"admin.askPasswordPlaceholder": "Enter ask password",
"admin.account": "Account settings",
"admin.exportHint": "You can export all your personal data from %s, including your basic information, received questions and answers.",
"admin.exportJSON": "Export all data (JSON)",
"admin.exportJSON": "Export JSON",
"admin.exportJSONHint": "Includes questions, answers, follow-ups and chats for full backup or migration.",
"admin.deactivate": "Deactivate my account",
"admin.deactivateHint": "You can deactivate your account at any time. After deactivation, your account can no longer log in, your question box page and questions will be inaccessible, and others won't be able to send you new questions.<b>This action cannot be undone. Please be careful!</b>",
@@ -707,6 +723,9 @@ var enDict = map[string]string{
"admin.export.errUser": "Export failed: could not get user information",
"admin.export.errQuestion": "Export failed: could not get question information",
"admin.flash.moderationStrictnessUpdated": "Moderation strictness updated",
"admin.flash.moderationStrictnessInvalid": "Invalid moderation strictness value, nothing was saved",
// Vote management: owner visible (P1)
"vote.err.invalidID": "Invalid vote ID",
"vote.err.notExist": "The vote does not exist",
+20 -1
View File
@@ -215,6 +215,22 @@ var jaDict = map[string]string{
"admin.globalBlockWords": "共通ブロックワード",
"admin.globalBlockWordsHint": "ブロックワードは質問・チャット(相手からあなたへのメッセージ)・投票の投稿内容に適用されます。この設定は質問/チャット/投票の3つのタブで共有され、どこで変更してもすぐに反映されます。",
"admin.updateBlockWords": "ブロックワードを保存",
// 審核の厳格度:質問 / 投票 / チャットで共通。判定規則は internal/security/censor/policy.go。
"admin.moderationStrictness": "審核の厳格度",
"admin.moderationStrictnessHint": "質問・投票・チャットの3種類すべてに適用されます。ここで調整するのは「審核が検知した後にブロックするかどうか」であり、ブロックワードには影響しません(ブロックワードは常に拒否されます)。",
"admin.moderationStrictnessBaseline": "下限(全段階で強制適用・無効化不可):政治・テロ・ポルノ、および審核サービスのカスタム辞書へのヒット。",
"admin.moderationStrictnessColumnLevel": "段階",
"admin.moderationStrictnessColumnScope": "ブロック対象",
"admin.moderationStrictnessOption.lax": "緩和",
"admin.moderationStrictnessOption.standard": "標準",
"admin.moderationStrictnessOption.strict": "厳格",
"admin.moderationStrictnessScope.lax": "下限の内容のみブロックし、それ以外はすべて許可します(誤検知が最も少ない)。",
"admin.moderationStrictnessScope.standard": "下限の内容に加え、信頼度 90% 以上の辱罵・違禁コンテンツ。",
"admin.moderationStrictnessScope.strict": "下限の内容に加え、辱罵・違禁・スパム・広告・連投・無意味・有害コンテンツ(信頼度不問)。さらに審核サービス全体のブロック推奨も採用します。最も厳しい段階で、ブロック範囲は本設定導入前を下回りません。",
"admin.moderationStrictnessUnsetHint": "未設定の場合はサイトの既定段階に従います。",
"admin.updateModerationStrictness": "審核の厳格度を保存",
"admin.allowChat": "登録ユーザーからのチャットを許可",
"admin.dailyLimitHint": "0 は無制限",
"admin.updateChat": "チャット設定を更新",
@@ -226,7 +242,7 @@ var jaDict = map[string]string{
"admin.askPasswordPlaceholder": "質問パスワードを入力",
"admin.account": "アカウント設定",
"admin.exportHint": "%s のすべての個人データ(基本情報、受け取った質問と回答を含む)をエクスポートできます。",
"admin.exportJSON": "全データをエクスポート(JSON)",
"admin.exportJSON": "全JSONをエクスポート",
"admin.exportJSONHint": "質問・回答・追加質問・チャットを含む完全バックアップ。",
"admin.deactivate": "アカウントを停止",
"admin.deactivateHint": "いつでもアカウントを停止できます。停止後はログインできなくなり、質問箱のページや質問にもアクセスできなくなります。新しい質問も受け付けられません。<b>この操作は取り消せません。ご注意ください!</b>",
@@ -707,6 +723,9 @@ var jaDict = map[string]string{
"admin.export.errUser": "エクスポート失敗:ユーザー情報の取得に失敗しました",
"admin.export.errQuestion": "エクスポート失敗:質問情報の取得に失敗しました",
"admin.flash.moderationStrictnessUpdated": "審核の厳格度を更新しました",
"admin.flash.moderationStrictnessInvalid": "審核の厳格度の値が不正なため、保存しませんでした",
// 投票管理:所有者に表示(P1)
"vote.err.invalidID": "無効な投票 ID",
"vote.err.notExist": "投票が存在しません",
+20 -1
View File
@@ -215,6 +215,22 @@ var zhCNDict = map[string]string{
"admin.globalBlockWords": "全局屏蔽词",
"admin.globalBlockWordsHint": "屏蔽词对提问、私聊(对方发给你的消息)与投票提交内容生效;三个设置页同步显示同一份设置,任意一处修改即时生效。",
"admin.updateBlockWords": "保存屏蔽词",
// 审核严格度:提问 / 投票 / 私聊共用一份,判定规则见 internal/security/censor/policy.go
"admin.moderationStrictness": "审核严格度",
"admin.moderationStrictnessHint": "对提问、投票、私聊三类内容同时生效。这里调节的是「审核命中之后是否拦截」,不影响屏蔽词——屏蔽词始终直接拒绝。",
"admin.moderationStrictnessBaseline": "底线拦截(所有档位强制生效,不可关闭):涉政、暴恐、色情,以及审核服务自定义词库的命中。",
"admin.moderationStrictnessColumnLevel": "档位",
"admin.moderationStrictnessColumnScope": "拦截范围",
"admin.moderationStrictnessOption.lax": "畅所欲言",
"admin.moderationStrictnessOption.standard": "正常水平",
"admin.moderationStrictnessOption.strict": "不听坏话",
"admin.moderationStrictnessScope.lax": "仅拦截底线内容,其余一律放行(误伤最少)。",
"admin.moderationStrictnessScope.standard": "底线内容,以及置信度不低于 90% 的辱骂、违禁内容。",
"admin.moderationStrictnessScope.strict": "底线内容,以及辱骂、违禁、垃圾信息、广告、灌水、无意义、不良场景内容(不限置信度);同时采信审核服务给出的整体拦截建议。最严格档,拦截面不小于升级前。",
"admin.moderationStrictnessUnsetHint": "未设置时跟随站点默认档位。",
"admin.updateModerationStrictness": "保存审核严格度",
"admin.allowChat": "允许注册用户向我发起私聊",
"admin.dailyLimitHint": "0 表示不限制",
"admin.updateChat": "更新私聊设置",
@@ -226,7 +242,7 @@ var zhCNDict = map[string]string{
"admin.askPasswordPlaceholder": "输入提问密码",
"admin.account": "账号设置",
"admin.exportHint": "您可以导出您在 %s 中的所有个人数据,包括你的基本信息、收到的问题以及回答。",
"admin.exportJSON": "导出全部数据(JSON)",
"admin.exportJSON": "导出JSON",
"admin.exportJSONHint": "包含提问、回答、追问与私聊记录,可完整备份或迁移。",
"admin.deactivate": "停用我的账号",
"admin.deactivateHint": "您随时可以选择停用您的账号。停用后,您的账号将无法登录,您的提问箱页面以及提问将无法访问,其他人也无法再给您发送新的提问。<b>该操作无法撤销!请谨慎操作!</b>",
@@ -707,6 +723,9 @@ var zhCNDict = map[string]string{
"admin.export.errUser": "导出失败:获取用户信息失败",
"admin.export.errQuestion": "导出失败:获取问题信息失败",
"admin.flash.moderationStrictnessUpdated": "审核严格度已更新",
"admin.flash.moderationStrictnessInvalid": "审核严格度取值无效,未保存",
// 投票管理:箱主可见(P1)
"vote.err.invalidID": "无效的投票 ID",
"vote.err.notExist": "投票不存在",
+20 -1
View File
@@ -215,6 +215,22 @@ var zhTWDict = map[string]string{
"admin.globalBlockWords": "全局屏蔽詞",
"admin.globalBlockWordsHint": "屏蔽詞對提問、私聊(對方傳送給你的訊息)與投票提交內容生效;三個設定頁同步顯示同一份設定,任意一處修改即時生效。",
"admin.updateBlockWords": "儲存屏蔽詞",
// 審核嚴格度:提問 / 投票 / 私聊共用一份,判定規則見 internal/security/censor/policy.go
"admin.moderationStrictness": "審核嚴格度",
"admin.moderationStrictnessHint": "對提問、投票、私聊三類內容同時生效。這裡調節的是「審核命中之後是否攔截」,不影響屏蔽詞——屏蔽詞一律直接拒絕。",
"admin.moderationStrictnessBaseline": "底線攔截(所有檔位強制生效,無法關閉):涉政、暴恐、色情,以及審核服務自訂詞庫的命中。",
"admin.moderationStrictnessColumnLevel": "檔位",
"admin.moderationStrictnessColumnScope": "攔截範圍",
"admin.moderationStrictnessOption.lax": "暢所欲言",
"admin.moderationStrictnessOption.standard": "正常水平",
"admin.moderationStrictnessOption.strict": "不聽壞話",
"admin.moderationStrictnessScope.lax": "僅攔截底線內容,其餘一律放行(誤傷最少)。",
"admin.moderationStrictnessScope.standard": "底線內容,以及信心度不低於 90% 的辱罵、違禁內容。",
"admin.moderationStrictnessScope.strict": "底線內容,以及辱罵、違禁、垃圾訊息、廣告、洗版、無意義、不良場景內容(不限信心度);同時採信審核服務給出的整體攔截建議。最嚴格檔,攔截面不小於升級前。",
"admin.moderationStrictnessUnsetHint": "未設定時跟隨站點預設檔位。",
"admin.updateModerationStrictness": "儲存審核嚴格度",
"admin.allowChat": "允許註冊使用者向我發起私聊",
"admin.dailyLimitHint": "0 表示不限制",
"admin.updateChat": "更新私聊設定",
@@ -226,7 +242,7 @@ var zhTWDict = map[string]string{
"admin.askPasswordPlaceholder": "輸入提問密碼",
"admin.account": "帳號設定",
"admin.exportHint": "您可以匯出您在 %s 中的所有個人資料,包括你的基本資訊、收到的問題以及回答。",
"admin.exportJSON": "導出全部資料(JSON)",
"admin.exportJSON": "導出JSON",
"admin.exportJSONHint": "包含提問、回答、追問與私聊記錄,可完整備份或遷移。",
"admin.deactivate": "停用我的帳號",
"admin.deactivateHint": "您隨時可以選擇停用您的帳號。停用後,您的帳號將無法登入,您的提問箱頁面以及提問將無法訪問,其他人也無法再給您發送新的提問。<b>該操作無法撤銷!請謹慎操作!</b>",
@@ -707,6 +723,9 @@ var zhTWDict = map[string]string{
"admin.export.errUser": "匯出失敗:獲取使用者資訊失敗",
"admin.export.errQuestion": "匯出失敗:獲取問題資訊失敗",
"admin.flash.moderationStrictnessUpdated": "審核嚴格度已更新",
"admin.flash.moderationStrictnessInvalid": "審核嚴格度取值無效,未儲存",
// 投票管理:箱主可見(P1)
"vote.err.invalidID": "無效的投票 ID",
"vote.err.notExist": "投票不存在",
+28 -15
View File
@@ -18,7 +18,7 @@ import (
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/storage"
)
@@ -27,10 +27,18 @@ const (
MaxMessageImageSize = 5 * 1024 * 1024 // 5MB
)
// ConversationPage 私聊会话页(GET 入口)。审核拦截后的就地重渲染由 renderConversation 统一处理。
func ConversationPage(ctx context.Context) {
domain := ctx.Param("domain")
token := ctx.Param("token")
renderConversation(ctx, ctx.Param("domain"), ctx.Param("token"), "", nil)
}
// renderConversation 渲染私聊会话页,并可选地回填审核拦截提示与已输入内容。
//
// prefillContent 非空时回填到消息输入框:审核拦截(以及其它服务端校验失败)后保留用户已输入内容,
// 避免 Redirect 重渲染导致输入丢失。modErr 非空时作为错误提示展示(与提问链路 ctx.SetError
// 一致的就地重渲染,而非 flash + Redirect),提示通过 templates/base/alert 渲染。
// 函数内部在所有出口都会发起 Redirect 或 Success 渲染,调用方在调用后应直接 return。
func renderConversation(ctx context.Context, domain, token, prefillContent string, modErr error) {
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
@@ -119,8 +127,19 @@ func ConversationPage(ctx context.Context) {
// 单条消息字数上限,供发送 / 回复 textarea 的 maxlength 与后端校验同源
// (conf.Limits.ChatMessageLen,[limits] 配置段)
ctx.Data["ChatMessageLimit"] = conf.Limits.ChatMessageLen
ctx.Data["CanSend"] = !conv.IsClosed && !conv.IsDeletedByOwner &&
canSend := !conv.IsClosed && !conv.IsDeletedByOwner &&
((isOwner && conv.MessageCount < conv.MaxMessages) || (isAsker && conv.MessageCount < conv.MaxMessages && !isBlocked))
ctx.Data["CanSend"] = canSend
// 回填审核拦截时已输入内容(仅当对话仍可发送,避免关闭/删除场景下回填到隐藏输入框)。
if canSend {
ctx.Data["Content"] = prefillContent
}
// 审核拦截等错误提示:就地通过 base/alert 渲染,不再走 flash + Redirect(否则内容丢失且无提示)。
if modErr != nil {
ctx.Data["HasError"] = true
ctx.Data["Error"] = modErr.Error()
}
ctx.Success("chat/conversation")
}
@@ -254,18 +273,12 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
}
// 内容安全检测(仅文本)
// 统一守卫:与提问 / 投票共用同一份档位判定,箱主的「审核严格度」决定放行与否。
if content != "" {
censorResponse, err := censor.Text(ctx.Request().Context(), content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发送而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if !censorResponse.Pass {
ctx.SetErrorFlash(censorResponse.ErrorMessage())
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
if _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), content); err != nil {
// 审核拦截:就地重渲染会话页,回填已输入内容并展示拦截原因。
// 不复用 SetErrorFlash + Redirect:那会导致输入框内容在刷新后丢失(用户已确认的无提示+内容消失问题)。
renderConversation(ctx, domain, token, content, err)
return
}
}
+3
View File
@@ -365,6 +365,9 @@ func New() *flamego.Flame {
// ?tab= 参数回跳到来源面板。
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
// 内容审核严格度:与屏蔽词一样在提问/私聊/投票三个面板共用一份,
// 保存后经 ?tab= 参数回跳到来源面板。
f.Post("/moderation-strictness/update", form.Bind(form.UpdateModerationStrictness{}), user.UpdateModerationStrictness)
f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags)
// Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录)
f.Post("/logout", auth.Logout)
+533 -112
View File
@@ -4,14 +4,71 @@
package censor
// 阿里云文本审核通道 —— 内容安全 2.0「文本审核增强版」。
//
// 迁移记录(2026-09):本通道原走「内容安全 1.0」(/green/text/scan + alibaba-cloud-sdk-go),
// 但 1.0 的开通前提是**企业实名认证**,个人认证账号无法开通,接口会永久返回
// `code=596 You have not opened Yundun Content Moderation Service`。
// 现改为 2.0(TextModeration + alibabacloud-go/green-20220302/v3):2.0 只要求实名认证
// (个人可开通),且标签体系对底线维度(涉政/暴恐/色情/自定义词库)覆盖完整。
//
// 官方文档:
// - 文本审核增强版通用服务 API:https://help.aliyun.com/practice_detail/434034
// - 标签处理建议参考: https://help.aliyun.com/practice_detail/465315
//
// ⚠️ 不要把 SDK 里 `TextModerationResponseBodyData.Labels` 的内联注释当作权威标签表:
// 那份列表是旧的且不完整(没有 political 相关标签),以官方《标签处理建议参考》为准。
import (
"context"
"encoding/json"
stderrors "errors"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/aliyun/alibaba-cloud-sdk-go/services/green"
openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
green20220302 "github.com/alibabacloud-go/green-20220302/v3/client"
"github.com/alibabacloud-go/tea/dara"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const (
// aliyunRegionID 内容安全 2.0 的接入地域,SDK 据此解析出
// green-cip.cn-shanghai.aliyuncs.com(见绿网 SDK 的 EndpointMap)。
aliyunRegionID = "cn-shanghai"
// aliyunTextService 文本审核增强版的服务场景。
//
// 选 comment_detection(公聊评论内容检测):官方定位「评论/弹幕/公聊/转发」,
// 明确覆盖涉黄/涉政/涉暴等底线违规与广告导流,是本站(公开问答为主、私聊为辅)
// 最通用的单一场景。本通道被提问/回答、投票、私聊三条链路共用,接口层不带场景信息;
// 若要按场景细分(例如私聊改用 chat_detection),需给审核入口补 scene 参数。
aliyunTextService = "comment_detection"
// aliyunContentRuneLimit 2.0 通用服务单次 content 的硬上限。
// 官方文档:content 限定在 600 字符以内。这里按 550 切分以留出安全余量。
aliyunContentRuneLimit = 550
// aliyunChunkOverlapRunes 相邻分片的重叠字符数。
// 切分点可能正好落在一个风险词中间,两段各自都看不出问题;重叠可消除该盲区。
// 代价是少量重复计费(文本审核标准 7.5 元/万次,可忽略)。
aliyunChunkOverlapRunes = 50
aliyunReadTimeoutMs = 10000
aliyunConnectTimeoutMs = 5000
// aliyunMaxRawLogBytes 错误信息里回显的原始响应长度上限(按字符计),
// 避免异常响应体过大时把日志刷屏。
aliyunMaxRawLogBytes = 512
// aliyunTextModerationAPI 归一化响应的格式标记。
//
// 解析器据此拒绝「非本格式」的载荷(例如迁移前内容安全 1.0 的响应),
// 让 censor.go 的缓存回放能安全地退回一次实时调用,
// 而不是把无法识别的历史缓存误判成「通过」。
aliyunTextModerationAPI = "TextModeration"
)
type AliyunTextCensor struct {
@@ -25,170 +82,534 @@ func NewAliyunTextCensor(accessKey, accessKeySecret string) *AliyunTextCensor {
}
}
// 包级复用的 green.Client:审核请求高频且密钥在启动后不变,
// 包级复用的 client:审核请求高频且密钥在启动后不变,
// 用 sync.Once 懒加载一次并设置超时,避免每次审核都重建连接与对象。
var (
aliyunGreenOnce sync.Once
aliyunGreenClient *green.Client
aliyunGreenClient *green20220302.Client
aliyunGreenErr error
// warnAliyunAccountIssueDoOnce 保证「账号侧未就绪」的告警只打一次,避免按请求刷屏。
warnAliyunAccountIssueDoOnce sync.Once
)
func (c *AliyunTextCensor) getClient() (*green.Client, error) {
// newAliyunClient 构造内容安全 2.0 客户端。
//
// 不设置 Endpoint 而设置 RegionId:SDK 内置 EndpointMap 会把 cn-shanghai
// 解析成 green-cip.cn-shanghai.aliyuncs.com。超时单位是毫秒
// (见 darabonba-openapi 的 client.ReadTimeout → 请求参数 readTimeout)。
func newAliyunClient(accessKey, accessKeySecret string) (*green20220302.Client, error) {
regionID := aliyunRegionID
readTimeout := aliyunReadTimeoutMs
connectTimeout := aliyunConnectTimeoutMs
client, err := green20220302.NewClient(&openapiutil.Config{
AccessKeyId: &accessKey,
AccessKeySecret: &accessKeySecret,
RegionId: &regionID,
ReadTimeout: &readTimeout,
ConnectTimeout: &connectTimeout,
})
if err != nil {
return nil, errors.Wrap(err, "new content moderation client")
}
return client, nil
}
func (c *AliyunTextCensor) getClient() (*green20220302.Client, error) {
aliyunGreenOnce.Do(func() {
aliyunGreenClient, aliyunGreenErr = green.NewClientWithAccessKey("cn-shanghai", c.accessKey, c.accessKeySecret)
if aliyunGreenErr == nil {
aliyunGreenClient.SetConnectTimeout(5 * time.Second)
aliyunGreenClient.SetReadTimeout(10 * time.Second)
}
aliyunGreenClient, aliyunGreenErr = newAliyunClient(c.accessKey, c.accessKeySecret)
})
return aliyunGreenClient, aliyunGreenErr
}
type AliyunTextCensorResponse struct {
Code int `json:"code"`
Data []struct {
Code int `json:"code"`
Content string `json:"content"`
FilteredContent string `json:"filteredContent"`
Msg string `json:"msg"`
Results []struct {
Details []struct {
Contexts []struct {
Context string `json:"context"`
Positions []struct {
EndPos int `json:"endPos"`
StartPos int `json:"startPos"`
} `json:"positions"`
} `json:"contexts"`
Label string `json:"label"`
} `json:"details"`
Label string `json:"label"`
Rate float64 `json:"rate"`
Scene string `json:"scene"`
Suggestion string `json:"suggestion"`
} `json:"results"`
TaskId string `json:"taskId"`
} `json:"data"`
Msg string `json:"msg"`
RequestId string `json:"requestId"`
// newAliyunTextModerationRequest 构造 TextModeration 请求。
//
// 单独抽出便于测试断言请求形态:Service 必须是选定场景,
// ServiceParameters 是 JSON 字符串 {"content": ...}。
func newAliyunTextModerationRequest(text string) (*green20220302.TextModerationRequest, error) {
serviceParameters, err := json.Marshal(map[string]string{"content": text})
if err != nil {
return nil, errors.Wrap(err, "marshal service parameters")
}
return (&green20220302.TextModerationRequest{}).
SetService(aliyunTextService).
SetServiceParameters(string(serviceParameters)), nil
}
func (r *AliyunTextCensorResponse) IsPass() bool {
if len(r.Data) == 0 || len(r.Data[0].Results) == 0 {
return false
// splitAliyunText 把待审文本切成不超过 2.0 单次上限的分片(按 rune 切,避免切坏多字节字符)。
//
// 为什么必须切分:2.0 通用服务单次 content 硬上限 600 字符,而本站内容上限为
// 提问 1000 / 回答·追问·私聊 2000 / 投票描述 5000 字,长文本必然超限。
// 把超长文本直接发给接口会被拒,进而在 fail-closed 下变成「长内容一律发不出去」。
//
// 分片带 aliyunChunkOverlapRunes 的重叠,消除「风险词正好跨切分点」的漏判盲区。
func splitAliyunText(text string) []string {
runes := []rune(text)
if len(runes) <= aliyunContentRuneLimit {
return []string{text}
}
// ⚠️ Right now, we allow `review` and `pass` to pass the censor.
// 任一 scene 判定为 block 即不通过(此前只检查首个 result,存在漏审)。
for _, result := range r.Data[0].Results {
if result.Suggestion == "block" {
return false
step := aliyunContentRuneLimit - aliyunChunkOverlapRunes
chunks := make([]string, 0, len(runes)/step+1)
for start := 0; start < len(runes); start += step {
end := start + aliyunContentRuneLimit
if end > len(runes) {
end = len(runes)
}
chunks = append(chunks, string(runes[start:end]))
if end == len(runes) {
break
}
}
return true
return chunks
}
// Censor censors text with Aliyun API.
// https://developer.qiniu.com/censor/7260/api-text-censor
func (c *AliyunTextCensor) Censor(_ context.Context, text string) (*TextCensorResponse, error) {
// aliyunTextModerationReason 是 2.0 响应中 Data.Reason 的结构(该字段本身是 JSON 字符串)。
//
// 只声明用到的字段:riskLevel 风险级别、riskTips 子标签、riskWords 命中词。
type aliyunTextModerationReason struct {
RiskLevel string `json:"riskLevel"`
RiskTips string `json:"riskTips"`
RiskWords string `json:"riskWords"`
}
// parseAliyunReason 解析 Data.Reason。
//
// 字段缺失或非法 JSON 时返回零值 —— 忽略解析错误是刻意的:Reason 是辅助字段,
// 不应因为它的格式变化导致整条审核失败。风险级别拿不到时的保守处理见 aliyunRiskLevelScore。
func parseAliyunReason(raw string) aliyunTextModerationReason {
var reason aliyunTextModerationReason
if strings.TrimSpace(raw) == "" {
return reason
}
_ = json.Unmarshal([]byte(raw), &reason)
return reason
}
// aliyunRiskLevelScore 把 2.0 的 riskLevel 折算成 0~1 分,供 policy.go 的置信度门槛复用。
//
// ⚠️ 这是有损的近似:2.0 通用服务**不返回逐标签置信度**,只返回整条内容的 riskLevel,
// 因此同一响应内所有标签共用这一个分值(1.0 是按维度给 rate,改造后不具备该信息了)。
// 折算后 abuseScoreFloor=0.9 的实际语义变成「标准档只拦 riskLevel=high 的辱骂」,
// 与改造前的设计意图(标准档只拦高置信度辱骂)一致。
func aliyunRiskLevelScore(level string) float64 {
switch strings.ToLower(strings.TrimSpace(level)) {
case "high":
return 1
case "medium":
return 0.6
case "low":
return 0.3
case "none":
return 0
default:
// 标签存在却拿不到风险级别:取 1(保守)。
// 宁可让它够到辱骂门槛被判拦截,也不要因为字段缺失把命中标签静默降权后放行。
return 1
}
}
// aliyunRiskLevelSuggestion 把 riskLevel 映射成审核服务自身的管控建议,
// 供 policy.go 中「严格档采信 block 建议」的分支使用。
func aliyunRiskLevelSuggestion(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "high":
return SuggestionBlock
case "medium":
return SuggestionReview
case "low":
return SuggestionPass
default:
return ""
}
}
// aliyunRiskLevelRank riskLevel 的高低排序,用于多分片取最高。
var aliyunRiskLevelRank = map[string]int{
"none": 0,
"low": 1,
"medium": 2,
"high": 3,
}
// aliyunHighestRiskLevel 取多分片中最高的风险级别(high > medium > low > none)。
// 任一分片命中高风险,整条内容即按高风险处置;未知级别按最高处理(方向保守)。
func aliyunHighestRiskLevel(levels []string) string {
best := ""
bestRank := -1
for _, level := range levels {
normalized := strings.ToLower(strings.TrimSpace(level))
if normalized == "" {
continue
}
rank, ok := aliyunRiskLevelRank[normalized]
if !ok {
normalized, rank = "high", aliyunRiskLevelRank["high"]
}
if rank > bestRank {
best, bestRank = normalized, rank
}
}
return best
}
// aliyunTextModerationRaw 归一化后的可落库响应。
//
// 它是「在线调用」与「缓存回放」两条路径的唯一输入格式:
// Censor 把(可能多分片的)2.0 响应合并成它并序列化,随后交给 AliyunTextCensorParser;
// censor.go 命中落库缓存时,也把这段 JSON 交给同一个 parser。
type aliyunTextModerationRaw struct {
// API 格式标记,取值恒为 aliyunTextModerationAPI,供解析器甄别旧格式载荷。
API string `json:"api"`
StatusCode int32 `json:"statusCode"`
Code int32 `json:"code"`
Message string `json:"message"`
RequestIDs []string `json:"requestIds,omitempty"`
// Labels 已过滤掉 nonLabel / normal,且已去重。
Labels []string `json:"labels,omitempty"`
RiskLevel string `json:"riskLevel,omitempty"`
Hint string `json:"hint,omitempty"`
}
// aliyunEmptyCleanRaw 空内容对应的归一化响应(无标签 → 通过)。见 Censor 的空内容分支。
var aliyunEmptyCleanRaw = []byte(`{"api":"TextModeration","statusCode":200,"code":200,"message":"OK"}`)
// aliyunChunkResult 单个分片审核结果里我们关心的最小信息集。
// 抽出这一层是为了让「多分片合并」这段逻辑可以脱离 SDK 与网络单独测试。
type aliyunChunkResult struct {
StatusCode int32
Code int32
Message string
RequestID string
Labels []string
RiskLevel string
Hint string
}
// extractAliyunChunkResult 从 SDK 响应里提取分片结果。
//
// code 非 200/0 表示请求被阿里云拒绝(服务未开通、限流、参数错误等),
// 必须报错而不是当成「未命中」—— 否则 fail-closed 会被悄悄变成 fail-open。
func extractAliyunChunkResult(resp *green20220302.TextModerationResponse) (aliyunChunkResult, error) {
if resp == nil || resp.Body == nil {
return aliyunChunkResult{}, errors.New("text moderation returned an empty response body")
}
result := aliyunChunkResult{
StatusCode: dara.Int32Value(resp.StatusCode),
Code: dara.Int32Value(resp.Body.Code),
Message: dara.StringValue(resp.Body.Message),
RequestID: dara.StringValue(resp.Body.RequestId),
}
if result.Code != 0 && result.Code != 200 {
return result, errors.Errorf("aliyun rejected the request: code=%d message=%q request_id=%s",
result.Code, result.Message, result.RequestID)
}
if result.StatusCode != 0 && result.StatusCode/100 != 2 {
return result, errors.Errorf("unexpected http status: status=%d message=%q request_id=%s",
result.StatusCode, result.Message, result.RequestID)
}
data := resp.Body.Data
if data == nil {
return result, nil
}
for _, label := range strings.Split(dara.StringValue(data.Labels), ",") {
label = strings.TrimSpace(label)
// nonLabel / normal 是「无风险」的占位标签,不进入明细。
if label == "" || label == "nonLabel" || label == "normal" {
continue
}
result.Labels = append(result.Labels, label)
}
reason := parseAliyunReason(dara.StringValue(data.Reason))
result.RiskLevel = reason.RiskLevel
// Hint 优先取命中词(与 1.0 时代回显命中上下文的行为对齐),缺失时退回子标签。
if reason.RiskWords != "" {
result.Hint = reason.RiskWords
} else {
result.Hint = reason.RiskTips
}
return result, nil
}
// mergeAliyunChunkResults 把多个分片的结果合并成一份归一化响应。
//
// 合并口径:
// - 标签取并集(任一分片命中即视为整条内容命中);
// - 风险级别取最高(high > medium > low > none),未知级别按最高处理;
// - Hint 取首个非空,与「首个命中维度决定提示文案」的既有语义保持一致。
func mergeAliyunChunkResults(results []aliyunChunkResult) aliyunTextModerationRaw {
merged := aliyunTextModerationRaw{
API: aliyunTextModerationAPI,
StatusCode: 200,
Code: 200,
Message: "OK",
}
levels := make([]string, 0, len(results))
seen := make(map[string]struct{}, 4)
for _, result := range results {
merged.RequestIDs = append(merged.RequestIDs, result.RequestID)
if result.RiskLevel != "" {
levels = append(levels, result.RiskLevel)
}
if merged.Hint == "" {
merged.Hint = result.Hint
}
for _, label := range result.Labels {
if _, ok := seen[label]; ok {
continue
}
seen[label] = struct{}{}
merged.Labels = append(merged.Labels, label)
}
}
merged.RiskLevel = aliyunHighestRiskLevel(levels)
return merged
}
// Censor censors text with Aliyun Content Moderation 2.0 (TextModeration).
// 接口文档:https://help.aliyun.com/practice_detail/434034
func (c *AliyunTextCensor) Censor(ctx context.Context, text string) (*TextCensorResponse, error) {
client, err := c.getClient()
if err != nil {
return nil, errors.Wrap(err, "new client with access key")
return nil, errors.Wrap(err, "new content moderation client")
}
content, err := json.Marshal(
map[string]interface{}{
"scenes": []string{"antispam"},
"tasks": []map[string]interface{}{
{"content": text},
},
},
)
// 空内容(例如原文只是一条可信链接,已被 RemoveTrustedURL 整段移除)无可审核内容,
// 直接按「未命中」处理:既省一次调用,也避免空 content 被判为参数错误后
// 在 fail-closed 下把这类提交一并拒掉。
if strings.TrimSpace(text) == "" {
return AliyunTextCensorParser(aliyunEmptyCleanRaw)
}
chunks := splitAliyunText(text)
results := make([]aliyunChunkResult, 0, len(chunks))
for i, chunk := range chunks {
req, err := newAliyunTextModerationRequest(chunk)
if err != nil {
return nil, err
}
resp, err := client.TextModeration(req)
if err != nil {
// 非 2xx / 签名错误 / RAM 权限不足 / 欠费停服 / 服务未开通:SDK 直接返回错误。
warnAliyunAccountIssueOnce(ctx, err)
return nil, errors.Wrapf(err, "text moderation (chunk %d/%d len=%d)",
i+1, len(chunks), utf8.RuneCountInString(chunk))
}
result, err := extractAliyunChunkResult(resp)
if err != nil {
warnAliyunAccountIssueOnce(ctx, err)
return nil, errors.Wrapf(err, "text moderation (chunk %d/%d len=%d)",
i+1, len(chunks), utf8.RuneCountInString(chunk))
}
results = append(results, result)
}
raw, err := json.Marshal(mergeAliyunChunkResults(results))
if err != nil {
return nil, errors.Wrap(err, "marshal content")
return nil, errors.Wrap(err, "marshal merged response")
}
req := green.CreateTextScanRequest()
req.SetContent(content)
resp, err := client.TextScan(req)
if err != nil {
return nil, errors.Wrap(err, "text scan")
}
if !resp.IsSuccess() {
return nil, errors.New("response is not success")
}
return AliyunTextCensorParser(resp.GetHttpContentBytes())
return AliyunTextCensorParser(raw)
}
func (*AliyunTextCensor) String() string {
return "aliyun"
}
// aliyunRawDigest 返回原始响应的可读摘要(超长截断),供错误诊断使用。
func aliyunRawDigest(raw []byte) string {
s := strings.TrimSpace(string(raw))
if s == "" {
return "<empty>"
}
runes := []rune(s)
if len(runes) > aliyunMaxRawLogBytes {
return string(runes[:aliyunMaxRawLogBytes]) + "...(truncated)"
}
return s
}
// AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse。
//
// 在线调用与缓存回放共用本函数(见 aliyunTextModerationRaw 的说明)。
func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
var responseJSON AliyunTextCensorResponse
if err := json.Unmarshal(raw, &responseJSON); err != nil {
return nil, errors.Wrap(err, "unmarshal response")
var parsed aliyunTextModerationRaw
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, errors.Wrapf(err, "unmarshal response (raw=%s)", aliyunRawDigest(raw))
}
if len(responseJSON.Data) == 0 {
return nil, errors.New("response data is empty")
// 格式标记不符:典型是迁移前内容安全 1.0 落库的缓存(形如 {"code":200,"data":[...]})。
// 这种载荷无法按 2.0 语义解释,必须报错,交由 censor.go 退回一次实时调用;
// 若当成「无标签 → 通过」,历史缓存就会变成绕过审核的后门。
if parsed.API != aliyunTextModerationAPI {
return nil, errors.Errorf("unsupported aliyun response format (api=%q raw=%s)",
parsed.API, aliyunRawDigest(raw))
}
var hint string
var label string
var confidence float64
for _, result := range responseJSON.Data[0].Results {
if result.Label == "normal" {
// 2.0 不返回逐标签置信度,只有整条内容的 riskLevel,所有标签共用折算后的分值。
score := aliyunRiskLevelScore(parsed.RiskLevel)
// Pass 取「审核服务未给出任何风险标签」。注意它不是最终裁决 ——
// 是否拦截由 policy.go 按箱主严格度决定。
pass := len(parsed.Labels) == 0
suggestion := aliyunRiskLevelSuggestion(parsed.RiskLevel)
// 与七牛保持一致:审核服务判为「非通过」却没给出建议时补成 block。
// 典型场景是命中了尚未映射的新标签(既没有明细维度、也没有 riskLevel),
// 不补的话策略层会把它当成未命中放行,与改造前的 fail-closed 行为不一致。
if suggestion == "" && !pass {
suggestion = SuggestionBlock
}
details := make([]LabelScore, 0, len(parsed.Labels))
var (
forbiddenType ForbiddenType
confidence float64
)
for _, label := range parsed.Labels {
mapped := formatAliyunForbiddenType(label)
if mapped == "" {
// 未映射的标签不进入明细:它无法参与按维度裁决。
// 原始标签仍完整保留在 RawResponse 中供审计。
continue
}
// Get the first context as the hint, forbidden type, confidence.
for _, detail := range result.Details {
for _, context := range detail.Contexts {
hint = context.Context
}
}
details = append(details, LabelScore{Label: mapped, Score: score})
label = result.Label
confidence = result.Rate
break
// 保留既有语义:首个命中的标签决定 ForbiddenType / Confidence。
if forbiddenType == "" {
forbiddenType = mapped
confidence = score
}
}
return &TextCensorResponse{
SourceName: "aliyun",
Pass: responseJSON.IsPass(),
ForbiddenType: formatAliyunForbiddenType(label),
Hint: hint,
Confidence: confidence,
RawResponse: raw,
SourceName: "aliyun",
Pass: pass,
ForbiddenType: forbiddenType,
Hint: parsed.Hint,
Confidence: confidence,
RawResponse: raw,
Details: details,
ProviderSuggestion: suggestion,
}, nil
}
// formatAliyunForbiddenType 把内容安全 2.0 的标签映射为本项目的 ForbiddenType。
//
// 标签取值以官方《文本审核增强版标签处理建议参考》为准;括号内为官方释义与风险级别:
//
// political_content / regional 涉政 (高)
// violence 暴恐 (高)
// sexual_content / sexuality 色情 (高)
// contraband 违禁 (高)
// ad / PullinTraffic / ad_compliance 广告引流/广告法合规(中高)
// profanity 辱骂 (中高)
// religion 宗教 (中高)
// negative_content / cyberbullying 不良内容/网络暴力 (中)
// nonsense 无意义 (中)
// spam 垃圾信息
// C_customized 自定义词库命中
//
// 映射结果决定 policy.go 的裁决:政治/暴恐/色情/自定义属底线维度,任何档位都拦。
// 注意 2.0 没有「灌水(flood)」维度(同类内容归入 spam),因此 ForbiddenTypeFlood
// 不会由本通道产生,七牛仍可能返回它。
func formatAliyunForbiddenType(typ string) ForbiddenType {
switch typ {
case "spam":
return ForbiddenTypeSpam
case "ad":
return ForbiddenTypeAd
case "politics":
switch strings.ToLower(strings.TrimSpace(typ)) {
case "political_content", "regional":
return ForbiddenTypePolitics
case "terrorism":
case "violence":
return ForbiddenTypeTerrorism
case "abuse":
return ForbiddenTypeAbuse
case "porn":
case "sexual_content", "sexuality":
return ForbiddenTypePorn
case "flood":
return ForbiddenTypeFlood
case "contraband":
return ForbiddenTypeContraband
case "meaningless":
return ForbiddenTypeMeaningless
case "harmful":
case "ad", "pullintraffic", "ad_compliance":
return ForbiddenTypeAd
case "profanity":
return ForbiddenTypeAbuse
case "negative_content", "cyberbullying", "religion":
return ForbiddenTypeHarmful
case "nonsense":
return ForbiddenTypeMeaningless
case "spam":
return ForbiddenTypeSpam
case "c_customized", "customized":
// 命中运营方在阿里云控制台配置的自定义词库(属显式规则,见 policy.go 的底线集合)。
return ForbiddenTypeCustomized
default:
return ""
}
}
// aliyunAccountIssueSignals 「账号侧未就绪」的特征信号(小写匹配),覆盖结构化 code 与消息文本两条路径:
// - 596 / "not opened":服务未开通
// - Forbidden:RAM 未授权
// - InvalidAccessKeyId / SignatureDoesNotMatch:密钥错误
var aliyunAccountIssueSignals = []string{
"596",
"not opened",
"forbidden",
"invalidaccesskeyid",
"signaturedoesnotmatch",
"no permission",
}
// aliyunIsAccountIssue 判断一次失败是否属于「账号侧未就绪」。
// 先看 SDK 的结构化 code(稳定),再退回错误消息文本匹配(兼容 code 缺失的情况)。
func aliyunIsAccountIssue(err error) bool {
if err == nil {
return false
}
candidates := []string{strings.ToLower(err.Error())}
var sdkErr *dara.SDKError
if stderrors.As(err, &sdkErr) {
candidates = append(candidates, strings.ToLower(dara.StringValue(sdkErr.GetCode())))
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
for _, signal := range aliyunAccountIssueSignals {
if strings.Contains(candidate, signal) {
return true
}
}
}
return false
}
// warnAliyunAccountIssueOnce 在首次出现「账号侧未就绪」类失败时告警一次。
//
// 这类问题(服务未开通 / RAM 未授权 / 密钥无效)不会自愈,会把 fail-closed 的审核链路
// 永久卡死:外部表现是「全站内容都发不出去」,或(auto 模式下)内容静默落到下一个通道,
// 但日志里原本只是一条普通 ERRO,极易被当成偶发网络抖动而忽略。
// 因此第一次遇到就打出可操作的指引,并用 sync.Once 约束为只打一次,避免按请求刷屏。
func warnAliyunAccountIssueOnce(ctx context.Context, err error) {
if !aliyunIsAccountIssue(err) {
return
}
warnAliyunAccountIssueDoOnce.Do(func() {
logrus.WithContext(ctx).WithError(err).
Warn("Aliyun text moderation looks unavailable due to an account-level issue " +
"(service not activated / RAM not authorized / invalid AccessKey). " +
"Fix: activate 内容安全 2.0「文本审核增强版」(pay-as-you-go) at the Content Moderation " +
"console, and grant AliyunYundunGreenWebFullAccess to the RAM user. " +
"Note: 内容安全 1.0 requires an enterprise-verified Aliyun account and can never be " +
"activated by an individual account (code=596 recurs forever), while 2.0 only requires " +
"real-name verification. Until this is fixed the censor chain keeps failing closed.")
})
}
+459
View File
@@ -0,0 +1,459 @@
// 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 censor
import (
"encoding/json"
"errors"
"strings"
"testing"
"unicode/utf8"
green20220302 "github.com/alibabacloud-go/green-20220302/v3/client"
"github.com/alibabacloud-go/tea/dara"
"github.com/stretchr/testify/assert"
)
func TestNewAliyunTextModerationRequestShape(t *testing.T) {
// 2.0(TextModeration)的接口契约:Service 指明审核场景,
// ServiceParameters 是 JSON 字符串 {"content": ...}。
req, err := newAliyunTextModerationRequest("测试文本")
assert.NoError(t, err)
assert.Equal(t, aliyunTextService, dara.StringValue(req.Service))
var serviceParameters map[string]string
assert.NoError(t, json.Unmarshal([]byte(dara.StringValue(req.ServiceParameters)), &serviceParameters))
assert.Equal(t, "测试文本", serviceParameters["content"])
}
func TestSplitAliyunText(t *testing.T) {
// 未超限时原样返回,不做无意义的切分。
assert.Equal(t, []string{"短文本"}, splitAliyunText("短文本"))
// 恰好等于上限:仍是单片。
exact := strings.Repeat("a", aliyunContentRuneLimit)
assert.Equal(t, []string{exact}, splitAliyunText(exact))
// 各档站点内容上限都要能覆盖:提问 1000 / 回答·私聊 2000 / 投票描述 5000。
for _, length := range []int{aliyunContentRuneLimit + 1, 1000, 2000, 5000} {
text := strings.Repeat("敏", length)
chunks := splitAliyunText(text)
assert.Greater(t, len(chunks), 1, "length=%d 应当被切分", length)
for i, chunk := range chunks {
// 单次不得超过 2.0 的硬上限,否则请求会被服务端拒绝。
assert.LessOrEqual(t, utf8.RuneCountInString(chunk), aliyunContentRuneLimit,
"length=%d chunk=%d 超过单次上限", length, i)
// 按 rune 切分,不能切坏多字节字符。
assert.True(t, utf8.ValidString(chunk), "length=%d chunk=%d 切坏字符", length, i)
}
// 首片必须从原文开头开始。
assert.Equal(t, string([]rune(text)[:aliyunContentRuneLimit]), chunks[0], "length=%d", length)
// 末片必须收束到原文结尾,否则尾部内容整段漏审。
assert.True(t, strings.HasSuffix(text, chunks[len(chunks)-1]),
"length=%d 最后一个分片未覆盖到原文结尾", length)
// 相邻分片必须重叠:消除「风险词正好跨切分点」的漏判盲区。
for i := 1; i < len(chunks); i++ {
previous := []rune(chunks[i-1])
current := []rune(chunks[i])
overlap := aliyunChunkOverlapRunes
if len(previous) < overlap {
overlap = len(previous)
}
assert.Equal(t, string(previous[len(previous)-overlap:]), string(current[:overlap]),
"length=%d 分片 %d 与上一片的重叠不符合预期", length, i)
}
}
}
func TestAliyunRiskLevelScore(t *testing.T) {
// 2.0 不返回逐标签置信度,只返回 riskLevel,这里折算成 0~1 供 policy.go 复用。
assert.Equal(t, 1.0, aliyunRiskLevelScore("high"))
assert.Equal(t, 1.0, aliyunRiskLevelScore("HIGH"))
assert.Equal(t, 0.6, aliyunRiskLevelScore("medium"))
assert.Equal(t, 0.3, aliyunRiskLevelScore("low"))
assert.Equal(t, 0.0, aliyunRiskLevelScore("none"))
// 未知/缺失级别取保守值 1:宁可够到辱骂门槛被判拦截,也不要静默降权后放行。
assert.Equal(t, 1.0, aliyunRiskLevelScore(""))
assert.Equal(t, 1.0, aliyunRiskLevelScore("brand-new-level"))
}
func TestAliyunRiskLevelSuggestion(t *testing.T) {
assert.Equal(t, SuggestionBlock, aliyunRiskLevelSuggestion("high"))
assert.Equal(t, SuggestionReview, aliyunRiskLevelSuggestion("medium"))
assert.Equal(t, SuggestionPass, aliyunRiskLevelSuggestion("low"))
assert.Equal(t, "", aliyunRiskLevelSuggestion("none"))
assert.Equal(t, "", aliyunRiskLevelSuggestion(""))
}
func TestAliyunHighestRiskLevel(t *testing.T) {
assert.Equal(t, "", aliyunHighestRiskLevel(nil))
assert.Equal(t, "", aliyunHighestRiskLevel([]string{"", " "}))
assert.Equal(t, "low", aliyunHighestRiskLevel([]string{"low"}))
assert.Equal(t, "medium", aliyunHighestRiskLevel([]string{"low", "medium"}))
assert.Equal(t, "high", aliyunHighestRiskLevel([]string{"low", "high", "medium"}))
// 未知级别按最高处理(方向保守),否则新级别会把整条内容静默降级。
assert.Equal(t, "high", aliyunHighestRiskLevel([]string{"low", "brand-new-level"}))
}
func TestAliyunParserRejectsLegacyFormat(t *testing.T) {
// 迁移前内容安全 1.0 的响应(/green/text/scan)必须被拒绝。
// 这是关键回归护栏:若把无法识别的载荷当成「无标签 → 通过」,
// 历史缓存就会变成绕过审核的后门。
raw := []byte(`{
"code": 200,
"data": [{"code": 200, "results": [
{"label": "porn", "rate": 99.9, "scene": "antispam", "suggestion": "block", "details": []}
]}],
"msg": "OK",
"requestId": "req-legacy"
}`)
_, err := AliyunTextCensorParser(raw)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported aliyun response format")
}
func TestAliyunParserCleanResponsePasses(t *testing.T) {
raw := []byte(`{"api":"TextModeration","statusCode":200,"code":200,"message":"OK","requestIds":["req-1"]}`)
parsed, err := AliyunTextCensorParser(raw)
assert.NoError(t, err)
assert.True(t, parsed.Pass)
assert.Empty(t, parsed.Details)
assert.Equal(t, ForbiddenType(""), parsed.ForbiddenType)
}
func TestAliyunEmptyCleanRawParses(t *testing.T) {
// 空内容分支(原文只有一条可信链接、被 RemoveTrustedURL 整段移除)也必须走同一个解析器,
// 保证「在线调用」与「缓存回放」两条路径产出的结构完全一致。
parsed, err := AliyunTextCensorParser(aliyunEmptyCleanRaw)
assert.NoError(t, err)
assert.True(t, parsed.Pass)
assert.Empty(t, parsed.Details)
}
func TestAliyunParserFeedsPolicy(t *testing.T) {
// 端到端校验:riskLevel=medium 折算 0.6,低于标准档的辱骂门槛(0.9),
// 因此「标准档只拦高风险辱骂」,而严格档不限置信度、照拦。
raw := []byte(`{
"api": "TextModeration",
"statusCode": 200,
"code": 200,
"message": "OK",
"requestIds": ["req-1"],
"labels": ["profanity", "negative_content"],
"riskLevel": "medium",
"hint": "示例命中词"
}`)
parsed, err := AliyunTextCensorParser(raw)
assert.NoError(t, err)
assert.Len(t, parsed.Details, 2)
assert.Equal(t, ForbiddenTypeAbuse, parsed.Details[0].Label)
assert.InDelta(t, 0.6, parsed.Details[0].Score, 1e-9)
assert.Equal(t, ForbiddenTypeHarmful, parsed.Details[1].Label)
// 首个命中的标签决定 ForbiddenType / Confidence。
assert.Equal(t, ForbiddenTypeAbuse, parsed.ForbiddenType)
assert.InDelta(t, 0.6, parsed.Confidence, 1e-9)
assert.Equal(t, "示例命中词", parsed.Hint)
assert.Equal(t, SuggestionReview, parsed.ProviderSuggestion)
assert.False(t, parsed.Pass)
decision, _ := Decide("lax", parsed)
assert.False(t, decision.Blocked())
// 中等风险不足 0.9 门槛 → 标准档放行(收敛误伤)。
decision, _ = Decide("standard", parsed)
assert.False(t, decision.Blocked())
// 严格档不限置信度 → 拦截。
decision, detail := Decide("strict", parsed)
assert.Equal(t, DecisionBlock, decision)
assert.NotNil(t, detail)
assert.Equal(t, ForbiddenTypeAbuse, detail.Label)
}
func TestAliyunParserMapsBaselineLabels(t *testing.T) {
// 底线四件套在 2.0 下必须仍然映射得到(标签名与 1.0 完全不同):
// political_content 涉政、violence 暴恐、sexual_content 色情、C_customized 自定义词库。
for _, tc := range []struct {
label string
want ForbiddenType
}{
{"political_content", ForbiddenTypePolitics},
{"regional", ForbiddenTypePolitics},
{"violence", ForbiddenTypeTerrorism},
{"sexual_content", ForbiddenTypePorn},
{"sexuality", ForbiddenTypePorn},
{"C_customized", ForbiddenTypeCustomized},
{"contraband", ForbiddenTypeContraband},
{"ad", ForbiddenTypeAd},
{"PullinTraffic", ForbiddenTypeAd},
{"ad_compliance", ForbiddenTypeAd},
{"profanity", ForbiddenTypeAbuse},
{"negative_content", ForbiddenTypeHarmful},
{"cyberbullying", ForbiddenTypeHarmful},
{"religion", ForbiddenTypeHarmful},
{"nonsense", ForbiddenTypeMeaningless},
{"spam", ForbiddenTypeSpam},
} {
assert.Equal(t, tc.want, formatAliyunForbiddenType(tc.label), "label=%s", tc.label)
// 逐一验证:只要映射到 ForbiddenType,就必须能被策略层正常消费。
mapped := formatAliyunForbiddenType(tc.label)
raw, err := json.Marshal(aliyunTextModerationRaw{
API: aliyunTextModerationAPI,
Code: 200,
Labels: []string{tc.label},
RiskLevel: "low",
})
assert.NoError(t, err)
parsed, err := AliyunTextCensorParser(raw)
assert.NoError(t, err)
assert.Len(t, parsed.Details, 1)
assert.Equal(t, mapped, parsed.Details[0].Label)
}
}
func TestAliyunBaselineLabelsBlockAtEveryLevel(t *testing.T) {
// 底线维度不受档位影响:即使审核服务给 low、置信度折算为 0.3,也必须拦。
for _, label := range []string{"political_content", "violence", "sexual_content", "C_customized"} {
raw, err := json.Marshal(aliyunTextModerationRaw{
API: aliyunTextModerationAPI,
Code: 200,
Labels: []string{label},
RiskLevel: "low",
})
assert.NoError(t, err)
parsed, err := AliyunTextCensorParser(raw)
assert.NoError(t, err)
for _, level := range []string{"lax", "standard", "strict"} {
decision, detail := Decide(level, parsed)
assert.Equal(t, DecisionBlock, decision, "label=%s level=%s", label, level)
assert.NotNil(t, detail)
assert.Equal(t, formatAliyunForbiddenType(label), detail.Label)
}
}
}
func TestAliyunUnmappedLabelSkipped(t *testing.T) {
// 未映射的标签不进入明细(无法参与按维度裁决),原始标签仍保留在 RawResponse 中供审计。
raw, err := json.Marshal(aliyunTextModerationRaw{
API: aliyunTextModerationAPI,
Code: 200,
Labels: []string{"brand_new_label"},
RiskLevel: "medium",
})
assert.NoError(t, err)
parsed, err := AliyunTextCensorParser(raw)
assert.NoError(t, err)
assert.Empty(t, parsed.Details)
assert.Equal(t, SuggestionReview, parsed.ProviderSuggestion)
}
func TestAliyunLabeledResponseWithoutRiskLevelFallsBackToBlock(t *testing.T) {
// 与七牛保持一致:审核服务判为「非通过」却没给出建议时补成 block,
// 否则策略层会因「没有明细维度 + 没有建议」把命中内容当成未命中放行。
raw, err := json.Marshal(aliyunTextModerationRaw{
API: aliyunTextModerationAPI,
Code: 200,
Labels: []string{"brand_new_label"},
})
assert.NoError(t, err)
parsed, err := AliyunTextCensorParser(raw)
assert.NoError(t, err)
assert.False(t, parsed.Pass)
assert.Equal(t, SuggestionBlock, parsed.ProviderSuggestion)
// 严格档采信整体 block 建议 → 拦截;宽松/标准档不采信 → 放行。
decision, _ := Decide("strict", parsed)
assert.Equal(t, DecisionBlock, decision)
decision, _ = Decide("standard", parsed)
assert.False(t, decision.Blocked())
decision, _ = Decide("lax", parsed)
assert.False(t, decision.Blocked())
}
func TestAliyunInvalidJSONErrorCarriesRawResponse(t *testing.T) {
// 响应不是 JSON(如网关返回 HTML 错误页)时,错误里要能看到响应原文。
raw := []byte(`<html>502 Bad Gateway</html>`)
_, err := AliyunTextCensorParser(raw)
assert.Error(t, err)
assert.Contains(t, err.Error(), "502 Bad Gateway")
}
func TestAliyunRawDigestTruncates(t *testing.T) {
assert.Equal(t, "<empty>", aliyunRawDigest([]byte(" ")))
long := strings.Repeat("敏", aliyunMaxRawLogBytes+50)
got := aliyunRawDigest([]byte(long))
suffix := "...(truncated)"
assert.True(t, strings.HasSuffix(got, suffix))
// 按字符(而非字节)截断,多字节字符不能被切成半个。
assert.True(t, utf8.ValidString(got))
assert.Equal(t, aliyunMaxRawLogBytes, len([]rune(strings.TrimSuffix(got, suffix))))
}
// aliyunSDKResponse 构造一个 SDK 形态的 TextModeration 响应,省去每个用例重复拼结构体。
func aliyunSDKResponse(statusCode, code int32, labels, reason string) *green20220302.TextModerationResponse {
response := &green20220302.TextModerationResponse{
StatusCode: dara.Int32(statusCode),
Body: &green20220302.TextModerationResponseBody{
Code: dara.Int32(code),
Message: dara.String("OK"),
RequestId: dara.String("req-1"),
},
}
if labels != "" || reason != "" {
response.Body.Data = &green20220302.TextModerationResponseBodyData{
Labels: dara.String(labels),
Reason: dara.String(reason),
}
}
return response
}
func TestExtractAliyunChunkResult(t *testing.T) {
t.Run("成功:解析标签与 Reason", func(t *testing.T) {
result, err := extractAliyunChunkResult(aliyunSDKResponse(200, 200, "profanity, negative_content",
`{"riskLevel":"medium","riskTips":"辱骂_人身攻击","riskWords":"你是个傻子"}`))
assert.NoError(t, err)
assert.Equal(t, int32(200), result.Code)
assert.Equal(t, int32(200), result.StatusCode)
assert.Equal(t, "req-1", result.RequestID)
assert.Equal(t, []string{"profanity", "negative_content"}, result.Labels)
assert.Equal(t, "medium", result.RiskLevel)
// Hint 优先取命中词。
assert.Equal(t, "你是个傻子", result.Hint)
})
t.Run("nonLabel/normal 是占位标签,不进入明细", func(t *testing.T) {
result, err := extractAliyunChunkResult(aliyunSDKResponse(200, 200, "nonLabel,normal,", ""))
assert.NoError(t, err)
assert.Empty(t, result.Labels)
})
t.Run("RiskWords 缺失时 Hint 退回 RiskTips", func(t *testing.T) {
result, err := extractAliyunChunkResult(aliyunSDKResponse(200, 200, "ad",
`{"riskLevel":"high","riskTips":"广告_引流"}`))
assert.NoError(t, err)
assert.Equal(t, "广告_引流", result.Hint)
})
t.Run("Reason 非法 JSON 不应导致整条审核失败", func(t *testing.T) {
result, err := extractAliyunChunkResult(aliyunSDKResponse(200, 200, "ad", `not-json`))
assert.NoError(t, err)
assert.Equal(t, []string{"ad"}, result.Labels)
assert.Equal(t, "", result.RiskLevel)
})
t.Run("code 非 200 必须报错,不能当成未命中", func(t *testing.T) {
// 服务未开通时阿里云会返回自己的错误码。若被当成「无标签 → 通过」,
// fail-closed 会悄悄变成 fail-open。
_, err := extractAliyunChunkResult(aliyunSDKResponse(200, 596, "", ""))
assert.Error(t, err)
assert.Contains(t, err.Error(), "596")
})
t.Run("HTTP 非 2xx 必须报错", func(t *testing.T) {
_, err := extractAliyunChunkResult(aliyunSDKResponse(502, 200, "", ""))
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected http status")
})
t.Run("响应体缺失必须报错", func(t *testing.T) {
_, err := extractAliyunChunkResult(nil)
assert.Error(t, err)
_, err = extractAliyunChunkResult(&green20220302.TextModerationResponse{})
assert.Error(t, err)
})
}
func TestMergeAliyunChunkResults(t *testing.T) {
merged := mergeAliyunChunkResults([]aliyunChunkResult{
{StatusCode: 200, Code: 200, RequestID: "r1", Labels: []string{"profanity"}, RiskLevel: "low"},
{StatusCode: 200, Code: 200, RequestID: "r2", Labels: []string{"profanity", "political_content"},
RiskLevel: "high", Hint: "命中词"},
})
assert.Equal(t, aliyunTextModerationAPI, merged.API)
assert.Equal(t, []string{"r1", "r2"}, merged.RequestIDs)
// 标签取并集并去重,保持出现顺序。
assert.Equal(t, []string{"profanity", "political_content"}, merged.Labels)
// 风险级别取最高:任一分片命中高风险,整条内容按高风险处置。
assert.Equal(t, "high", merged.RiskLevel)
// Hint 取首个非空。
assert.Equal(t, "命中词", merged.Hint)
// 合并结果可直接被解析器消费,且高风险 + 涉政 → 任何档位都拦。
raw, err := json.Marshal(merged)
assert.NoError(t, err)
parsed, err := AliyunTextCensorParser(raw)
assert.NoError(t, err)
assert.Len(t, parsed.Details, 2)
for _, detail := range parsed.Details {
assert.InDelta(t, 1.0, detail.Score, 1e-9)
}
decision, detail := Decide("lax", parsed)
assert.Equal(t, DecisionBlock, decision)
assert.NotNil(t, detail)
assert.Equal(t, ForbiddenTypePolitics, detail.Label)
}
func TestMergeAliyunChunkResultsEmpty(t *testing.T) {
// 没有任何分片(理论上不会发生,因为空内容有单独的短路分支)时,
// 合并结果必须仍然是「无标签」,即不产生任何命中。
merged := mergeAliyunChunkResults(nil)
assert.Empty(t, merged.Labels)
assert.Equal(t, "", merged.RiskLevel)
assert.Equal(t, aliyunTextModerationAPI, merged.API)
}
func TestAliyunIsAccountIssue(t *testing.T) {
// 账号侧未就绪的各类表现都要能识别出来 —— 它们不会自愈,必须给出可操作告警。
for _, message := range []string{
"code=596 You have not opened Yundun Content Moderation Service",
"You have not opened Yundun Content Moderation Service, please go to the page",
"Forbidden.RAM: no permission to perform this action",
"InvalidAccessKeyId.NotFound: the access key id is invalid",
"SignatureDoesNotMatch: signature mismatch",
} {
assert.True(t, aliyunIsAccountIssue(errors.New(message)), "message=%q", message)
}
// 偶发/网络类问题不应被当成账号问题,否则一次性告警会被无关噪声消耗掉。
for _, message := range []string{
"dial tcp 1.2.3.4:443: i/o timeout",
"EOF",
"context deadline exceeded",
} {
assert.False(t, aliyunIsAccountIssue(errors.New(message)), "message=%q", message)
}
assert.False(t, aliyunIsAccountIssue(nil))
}
func TestAliyunIsAccountIssueFromStructuredCode(t *testing.T) {
// SDK 的结构化 code 比消息文本更稳定:即使 message 里没有特征词也要能识别。
sdkErr := dara.NewSDKError(map[string]interface{}{
"code": "Forbidden",
"message": "the request is denied",
})
assert.True(t, aliyunIsAccountIssue(sdkErr))
}
+62 -8
View File
@@ -6,6 +6,8 @@ package censor
import (
"context"
"strings"
"sync"
"time"
"github.com/pkg/errors"
@@ -17,8 +19,49 @@ import (
var (
censorCacheNoMoreThan = 31 * 24 * time.Hour // 1 month
// warnNoCensorCredentialsOnce 保证「没有可用凭据」的告警只打一次,避免按请求刷屏。
warnNoCensorCredentialsOnce sync.Once
)
// censorChain 返回按配置选定的审核通道列表,顺序即优先级:
// 靠前的通道判定「通过」即返回;未通过则继续交给后面的通道复核。
//
// 取值来源 [security] text_censor_source(见 conf.CensorSource*):
// - qiniu → 只用七牛(凭据齐备性已由 conf.Init 启动校验)
// - aliyun → 只用阿里云(同上)
// - 其它(含留空 / auto)→ 七牛优先、阿里云兜底,与引入该配置前完全一致
func censorChain() []TextCensor {
qiniu := NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret)
aliyun := NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret)
switch strings.ToLower(strings.TrimSpace(conf.Security.TextCensorSource)) {
case conf.CensorSourceQiniu:
return []TextCensor{qiniu}
case conf.CensorSourceAliyun:
return []TextCensor{aliyun}
default:
return []TextCensor{qiniu, aliyun}
}
}
// warnIfNoCensorCredentials 在「配置里一条可用凭据都没有」时告警一次。
//
// 这种情况下审核调用必然失败,而调用方是 fail-closed(拒绝提交),表现为
// 全站发不出内容/私信——现象隐蔽、只在日志可见,因此第一次触发就留下明确线索。
// 显式选定通道但缺凭据的情况已由 conf.Init 直接挡住,这里兜的是 auto 的漏网情况。
func warnIfNoCensorCredentials(ctx context.Context) {
qiniuReady := conf.App.QiniuAccessKey != "" && conf.App.QiniuAccessSecret != ""
aliyunReady := conf.App.AliyunAccessKey != "" && conf.App.AliyunAccessKeySecret != ""
if qiniuReady || aliyunReady {
return
}
warnNoCensorCredentialsOnce.Do(func() {
logrus.WithContext(ctx).WithField("text_censor_source", conf.Security.TextCensorSource).
Error("Text censor is enabled but no usable credentials are configured; all submissions will be rejected (fail-closed)")
})
}
// Text checks the text for sensitive content.
// It will save the censor log to the database and invoke the callback function.
func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
@@ -28,11 +71,8 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
var responses []*TextCensorResponse
for _, censor := range []TextCensor{
//No, we need to use qiniu's API.
NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret),
NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret),
} {
warnIfNoCensorCredentials(ctx)
for _, censor := range censorChain() {
sourceName := censor.String()
// Try to get the censor from the database log.
@@ -46,19 +86,33 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
// We got the previous censor log cache, seems like we saved money for the API call.
switch censorLog.SourceName {
case "qiniu":
// HACK: Qiniu's API response is not accurate, so we need to check the text again with aliyun API.
// 七牛缓存命中:只有「通过」才可直接复用(省一次 API 调用)。
// 未通过时不在此下最终结论,而是交给链上的下一个通道复核(auto 模式下即阿里云,
// 这是引入通道配置前就有的行为)。
// text_censor_source = qiniu 时链上只有七牛:continue 后循环结束,
// 仍会由函数末尾返回这份七牛结果,判定不会丢失。
response, err := QiniuTextCensorParser(raw)
if err == nil {
if response.Pass {
return response, nil
}
responses = append(responses, response) // If the text is not passed, we need to check it with aliyun API.
responses = append(responses, response)
continue
}
case "aliyun":
return AliyunTextCensorParser(raw)
// 阿里云缓存命中:能解析就直接复用(省一次 API 调用)。
// 解析失败必须退回一次实时调用,而不是把错误往上抛:
// 最典型的失败源是「迁移到内容安全 2.0 之前落库的 1.0 缓存」,
// 它无法按 2.0 语义解释;若在此返回错误,fail-closed 会让这些
// 历史文本在缓存过期(31 天)前一直发不出去。
response, err := AliyunTextCensorParser(raw)
if err == nil {
return response, nil
}
logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).
Warn("Failed to replay cached aliyun censor result; falling back to a live call")
default:
logrus.WithContext(ctx).WithField("censor_source", censorLog.SourceName).Error("Unknown censor source")
@@ -0,0 +1,51 @@
// 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 censor
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tamakyi/TamaBox/internal/conf"
)
// sourceNames 把通道列表映射成名称序列,便于断言「选了几条、哪条、顺序如何」。
func sourceNames(chain []TextCensor) []string {
names := make([]string, 0, len(chain))
for _, censor := range chain {
names = append(names, censor.String())
}
return names
}
// TestCensorChainSelection 覆盖 [security] text_censor_source 对审核通道链的装配结果。
//
// 顺序即优先级:靠前通道「通过」即返回;未通过则交给后面的通道复核。
// 最要紧的是默认(留空 / auto)必须与引入该配置前完全一致,避免升级改变线上判定。
func TestCensorChainSelection(t *testing.T) {
original := conf.Security.TextCensorSource
t.Cleanup(func() { conf.Security.TextCensorSource = original })
for _, tc := range []struct {
name string
source string
want []string
}{
{name: "留空:七牛优先 + 阿里云兜底(历史行为)", source: "", want: []string{"qiniu", "aliyun"}},
{name: "auto:同留空", source: "auto", want: []string{"qiniu", "aliyun"}},
{name: "qiniu:只走七牛", source: "qiniu", want: []string{"qiniu"}},
{name: "aliyun:只走阿里云", source: "aliyun", want: []string{"aliyun"}},
{name: "大小写与空白不敏感", source: " AlIyUn \t", want: []string{"aliyun"}},
// 未知取值在启动期已被 conf.Init 直接拒绝(见 internal/conf validateTextCensorSource);
// 这里断言 censor 侧仍保守回退到历史行为,避免绕过 Init 的调用路径(如 CLI/tests)行为突变。
{name: "未知取值:保守回退 auto", source: "bogus", want: []string{"qiniu", "aliyun"}},
} {
t.Run(tc.name, func(t *testing.T) {
conf.Security.TextCensorSource = tc.source
assert.Equal(t, tc.want, sourceNames(censorChain()))
})
}
}
+216
View File
@@ -0,0 +1,216 @@
// 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 censor
// Strictness 内容审核严格度档位。
//
// 背景:审核服务(本项目为七牛云文本审核)对「辱骂」等维度的召回偏激进,
// 正常提问/对话经常被判为不通过而被整条拒绝。但我们既不能让审核服务判得更准,
// 也不能让它判得更松(七牛文本审核不支持白名单,自定义词库需提交工单),
// 唯一能控制的是「审核命中之后到底拦不拦」。
//
// 因此把「是否拦截」从审核服务自身的 Pass 判定中剥离出来,改为由本档位策略裁决,
// 让箱主在「少误伤」与「少漏放」之间自行取舍。
type Strictness string
const (
// StrictnessLax 宽松:仅拦截底线维度,其余一律放行。误伤最小,漏放最多。
StrictnessLax Strictness = "lax"
// StrictnessStandard 标准:底线维度 + 高置信度的辱骂/违禁。
StrictnessStandard Strictness = "standard"
// StrictnessStrict 严格:底线维度 + 全部非正常维度(不限置信度)+ 采信审核服务的 block 建议。
//
// 这是三个档位中最严的一档,且是唯一保留「采信审核服务整体 block 建议」的档位。
// 改造前的拦截条件是 `!Pass`(即 涉政/暴恐/色情 命中,或审核服务建议为 block),
// 该条件可被本档完全覆盖,但本档会额外拦截全部带维度的非正常内容(如 review 的辱骂)。
// 换言之:严格档 ⊇ 改造前的行为,是「至少与改造前一样严」,并非完全等价。
StrictnessStrict Strictness = "strict"
)
// DefaultStrictness 缺省档位。
//
// 取 strict 的取舍:本能力只用于「让箱主把审核调松」,从不用于「在用户未选择时把审核调松」。
// 存量账号从未设置过该值,缺省落到最严档,保证部署本功能这一动作本身不会让任何账号
// 的拦截面变小(合规上更保守);想减少误伤的箱主再显式切到 标准 / 宽松。
//
// ⚠️ 注意方向:因为严格档比改造前更严(见 StrictnessStrict 注释),缺省为 strict 意味着
// 部署后默认拦截面会略有扩大 —— 这是刻意的取舍(已于 2026-09-10 确认维持 strict):
// 合规上宁紧勿松,且「调松」应当是箱主的显式动作,部署本身不应悄悄放宽任何账号。
// 若哪天希望默认更少误伤,把下面这行改成 StrictnessStandard 即可(一行常量)。
const DefaultStrictness = StrictnessStrict
// Suggestion 审核服务自身给出的管控建议取值。
const (
SuggestionBlock = "block"
SuggestionReview = "review"
SuggestionPass = "pass"
)
// baselineBlockLabels 底线拦截维度:涉政 / 暴恐 / 色情 / 自定义词库。
//
// 这是合规底线:任何档位下都强制生效、不受置信度门槛约束、不可关闭,
// 也不能通过任何用户配置绕过 —— 见 Decide 中它始终排在最先、无条件返回拦截。
//
// ForbiddenTypeCustomized 归入底线的原因:它是运营方在审核服务控制台(阿里云)
// 显式配置的词库命中,属「用户明写的规则」,与箱主自定义屏蔽词同一性质,
// 不应被档位调低后放行。运营方若要放开,正确的做法是调整控制台词库,而不是调档位。
var baselineBlockLabels = map[ForbiddenType]struct{}{
ForbiddenTypePolitics: {},
ForbiddenTypeTerrorism: {},
ForbiddenTypePorn: {},
ForbiddenTypeCustomized: {},
}
// abuseScoreFloor 标准档位下「辱骂」维度生效所需的最低置信度。
//
// 辱骂是审核服务误伤最集中的维度(普通吐槽、语气较冲的表达常被判为辱骂),
// 因此标准档要求高置信度才拦截;严格档则为任意置信度。
//
// 注:置信度的来源因服务商而异。七牛直接给 0~1 的 score;阿里云内容安全 2.0 只给
// 整条内容的 riskLevel,折算为 1 / 0.6 / 0.3(high / medium / low),因此本阈值
// 在阿里云下的实际语义是「标准档只拦 riskLevel=high 的辱骂」。
const abuseScoreFloor = 0.9
// levelPolicy 单一档位的拦截规则。
type levelPolicy struct {
// blockLabels 该档位额外拦截的维度 -> 生效所需最低置信度(0 表示不限置信度)。
// 注意:底线维度不在此表中,由 Decide 无条件前置判定。
blockLabels map[ForbiddenType]float64
// blockOnSuggestion 是否采信审核服务自身的 block 建议。
//
// 该建议不携带具体维度(可能是任何原因),无法做置信度收敛,误伤面最大,
// 也是此前正常对话被整条拒绝的主要来源,因此仅严格档启用。
blockOnSuggestion bool
}
var strictnessPolicies = map[Strictness]levelPolicy{
// 宽松:只留底线,其余全部放行。
StrictnessLax: {
blockLabels: map[ForbiddenType]float64{},
blockOnSuggestion: false,
},
// 标准:底线 + 高置信度辱骂 + 违禁。
StrictnessStandard: {
blockLabels: map[ForbiddenType]float64{
ForbiddenTypeAbuse: abuseScoreFloor,
ForbiddenTypeContraband: 0,
},
blockOnSuggestion: false,
},
// 严格:底线 + 全部非正常维度(不限置信度)+ 采信审核服务 block 建议。
StrictnessStrict: {
blockLabels: map[ForbiddenType]float64{
ForbiddenTypeSpam: 0,
ForbiddenTypeAd: 0,
ForbiddenTypeAbuse: 0,
ForbiddenTypeFlood: 0,
ForbiddenTypeContraband: 0,
ForbiddenTypeMeaningless: 0,
ForbiddenTypeHarmful: 0,
},
blockOnSuggestion: true,
},
}
// Decision 对单条内容的最终裁决。
type Decision int
const (
// DecisionAllow 放行。
DecisionAllow Decision = iota
// DecisionBlock 拒绝提交。
DecisionBlock
)
// Blocked 是否应当拒绝提交。
func (d Decision) Blocked() bool { return d == DecisionBlock }
// IsValidStrictness 判断取值是否为合法档位,供后台保存前校验。
// 非法取值一律拒绝写入,避免脏数据把账号静默切到非预期档位。
func IsValidStrictness(s string) bool {
switch Strictness(s) {
case StrictnessLax, StrictnessStandard, StrictnessStrict:
return true
default:
return false
}
}
// ParseStrictness 归一化严格度取值,未知/空值回退到 DefaultStrictness(保守方向,即最严)。
// 方向刻意选保守:宁可沿用严格判定,也不要因为识别失败把账号静默放到最松档。
func ParseStrictness(s string) Strictness {
if !IsValidStrictness(s) {
return DefaultStrictness
}
return Strictness(s)
}
// Decide 按档位裁决一次内容审核结果,返回裁决结果与触发拦截的维度(未命中具体维度时为 nil)。
//
// 判定顺序固定为「底线维度 → 档位维度 → 审核服务建议」,且不可调换:
// 1. 底线维度排在最前且无条件返回拦截,保证任何档位都无法把它关掉;
// 2. 档位维度带各自的置信度门槛,用于收敛误伤最集中的维度;
// 3. 审核服务自身的 block 建议排在最后,且仅严格档启用。
//
// Details 为空(例如审核服务返回了 suggestion=block 但没给出任何非 normal 维度)时,
// 只有开启 blockOnSuggestion 的档位会拦截。
func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
if r == nil {
return DecisionAllow, nil
}
policy := strictnessPolicies[ParseStrictness(strictness)]
// 1. 底线:合规底线,任何档位强制生效、不可关闭。
for i := range r.Details {
detail := &r.Details[i]
if _, ok := baselineBlockLabels[detail.Label]; ok {
return DecisionBlock, detail
}
}
// 2. 档位维度:命中所配维度且达到该维度的置信度门槛。
for i := range r.Details {
detail := &r.Details[i]
scoreFloor, ok := policy.blockLabels[detail.Label]
if !ok {
continue
}
if scoreFloor > 0 && detail.Score < scoreFloor {
continue
}
return DecisionBlock, detail
}
// 3. 审核服务自身的 block 建议:仅严格档采信。
if policy.blockOnSuggestion && r.ProviderSuggestion == SuggestionBlock {
return DecisionBlock, nil
}
return DecisionAllow, nil
}
// BlockedLabels 返回指定档位最终会拦截的全部维度(含底线维度)。
//
// 用途:策略层的自描述 API —— 目前由 policy_test.go 用来守住「底线维度在每个档位都必须出现
// 且不重复」这条不变量。设置页展示的拦截范围是 i18n 静态文案
// (templates/user/moderation-strictness.html),尚未消费本函数;若将来要让 UI 直接由策略层
// 生成文案,这里是唯一事实来源。
func BlockedLabels(strictness string) []ForbiddenType {
policy := strictnessPolicies[ParseStrictness(strictness)]
labels := make([]ForbiddenType, 0, len(baselineBlockLabels)+len(policy.blockLabels))
for label := range baselineBlockLabels {
labels = append(labels, label)
}
for label := range policy.blockLabels {
// 底线维度已在上面加入,避免重复展示。
if _, ok := baselineBlockLabels[label]; ok {
continue
}
labels = append(labels, label)
}
return labels
}
+287
View File
@@ -0,0 +1,287 @@
// 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 censor
import (
"testing"
"github.com/stretchr/testify/assert"
)
// resp 构造一个带指定维度判定的审核结果,省去每个用例重复拼结构体。
func resp(suggestion string, details ...LabelScore) *TextCensorResponse {
return &TextCensorResponse{
SourceName: "qiniu",
Pass: suggestion != SuggestionBlock,
ProviderSuggestion: suggestion,
Details: details,
}
}
func TestDecideBaselineAlwaysBlocks(t *testing.T) {
// 底线维度(涉政 / 暴恐 / 色情 / 自定义词库)是强制项:任何档位、任何置信度都必须拦截,
// 且不受审核服务整体建议的影响(这里刻意给 pass 建议,验证仍然拦得住)。
baselines := []ForbiddenType{
ForbiddenTypePolitics,
ForbiddenTypeTerrorism,
ForbiddenTypePorn,
ForbiddenTypeCustomized,
}
levels := []string{"lax", "standard", "strict", "", "unknown-level"}
for _, label := range baselines {
for _, level := range levels {
t.Run(string(label)+"/"+level, func(t *testing.T) {
decision, detail := Decide(level, resp(SuggestionPass, LabelScore{Label: label, Score: 0.01}))
assert.Equal(t, DecisionBlock, decision)
assert.NotNil(t, detail)
assert.Equal(t, label, detail.Label)
})
}
}
}
func TestDecideByLevel(t *testing.T) {
for _, tc := range []struct {
name string
level string
response *TextCensorResponse
wantBlock bool
wantDetail ForbiddenType
}{
{
name: "宽松:高置信度辱骂也放行",
level: "lax",
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeAbuse, Score: 0.99}),
wantBlock: false,
wantDetail: "",
},
{
name: "宽松:垃圾信息放行",
level: "lax",
response: resp(SuggestionPass, LabelScore{Label: ForbiddenTypeSpam, Score: 0.9}),
wantBlock: false,
wantDetail: "",
},
{
name: "宽松:不采信审核服务的整体 block 建议",
level: "lax",
response: resp(SuggestionBlock),
wantBlock: false,
wantDetail: "",
},
{
name: "标准:高置信度辱骂拦截",
level: "standard",
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeAbuse, Score: 0.95}),
wantBlock: true,
wantDetail: ForbiddenTypeAbuse,
},
{
name: "标准:辱骂置信度不足 90% 放行(收敛误伤)",
level: "standard",
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeAbuse, Score: 0.72}),
wantBlock: false,
wantDetail: "",
},
{
name: "标准:辱骂恰好 90% 拦截(阈值含等号)",
level: "standard",
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeAbuse, Score: 0.9}),
wantBlock: true,
wantDetail: ForbiddenTypeAbuse,
},
{
name: "标准:违禁不限置信度",
level: "standard",
response: resp(SuggestionPass, LabelScore{Label: ForbiddenTypeContraband, Score: 0.3}),
wantBlock: true,
wantDetail: ForbiddenTypeContraband,
},
{
name: "标准:垃圾信息放行",
level: "standard",
response: resp(SuggestionPass, LabelScore{Label: ForbiddenTypeSpam, Score: 0.99}),
wantBlock: false,
wantDetail: "",
},
{
name: "标准:不采信整体 block 建议",
level: "standard",
response: resp(SuggestionBlock),
wantBlock: false,
wantDetail: "",
},
{
name: "严格:辱骂不限置信度拦截(最严档)",
level: "strict",
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeAbuse, Score: 0.2}),
wantBlock: true,
wantDetail: ForbiddenTypeAbuse,
},
{
name: "严格:无意义内容拦截",
level: "strict",
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeMeaningless, Score: 0.5}),
wantBlock: true,
wantDetail: ForbiddenTypeMeaningless,
},
{
name: "严格:采信整体 block 建议(无明细维度)",
level: "strict",
response: resp(SuggestionBlock),
wantBlock: true,
wantDetail: "",
},
{
name: "严格:仅 review 且无维度命中则放行",
level: "strict",
response: resp(SuggestionReview),
wantBlock: false,
wantDetail: "",
},
{
name: "全部正常文本放行",
level: "strict",
response: resp(SuggestionPass),
wantBlock: false,
wantDetail: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
decision, detail := Decide(tc.level, tc.response)
assert.Equal(t, tc.wantBlock, decision.Blocked())
if tc.wantDetail == "" {
assert.Nil(t, detail)
return
}
assert.NotNil(t, detail)
assert.Equal(t, tc.wantDetail, detail.Label)
})
}
}
func TestDecideNilResponse(t *testing.T) {
// 空结果(审核未返回)不应被当成命中,避免把「拿不到结论」误判为违规。
decision, detail := Decide("strict", nil)
assert.Equal(t, DecisionAllow, decision)
assert.Nil(t, detail)
}
func TestDecideUnknownLevelFallsBackToStrict(t *testing.T) {
// 脏数据 / 未识别取值必须回退「严格」,方向保守:
// 宁可沿用升级前的严格判定,也不要因为认不出档位就静默放宽。
unknown := resp(SuggestionPass, LabelScore{Label: ForbiddenTypeSpam, Score: 0.9})
decision, _ := Decide("", unknown)
assert.Equal(t, DecisionBlock, decision)
decision, _ = Decide("bogus", unknown)
assert.Equal(t, DecisionBlock, decision)
}
func TestStrictnessHelpers(t *testing.T) {
assert.True(t, IsValidStrictness("lax"))
assert.True(t, IsValidStrictness("standard"))
assert.True(t, IsValidStrictness("strict"))
assert.False(t, IsValidStrictness(""))
assert.False(t, IsValidStrictness("STRICT"))
assert.False(t, IsValidStrictness("off"))
assert.Equal(t, StrictnessStrict, ParseStrictness(""))
assert.Equal(t, StrictnessStrict, ParseStrictness("nope"))
assert.Equal(t, StrictnessLax, ParseStrictness("lax"))
assert.Equal(t, StrictnessStandard, ParseStrictness("standard"))
}
func TestQiniuParserFeedsPolicy(t *testing.T) {
// 策略层完全依赖 parser 填好的 Details / ProviderSuggestion,这里做一次端到端校验:
// 审核服务给出 review(自身判定为放行)但带 abuse 维度,严格档仍应拦得住。
raw := []byte(`{
"code": 200,
"message": "ok",
"result": {
"suggestion": "review",
"scenes": {
"antispam": {
"suggestion": "review",
"details": [
{"label": "normal", "score": 0.02, "contexts": []},
{"label": "abuse", "score": 0.94, "contexts": [{"context": "示例片段", "positions": [{"startPos": 0, "endPos": 4}]}]}
]
}
}
}
}`)
parsed, err := QiniuTextCensorParser(raw)
assert.NoError(t, err)
// normal 维度必须被过滤掉,只保留真正命中的维度。
assert.Len(t, parsed.Details, 1)
assert.Equal(t, ForbiddenTypeAbuse, parsed.Details[0].Label)
assert.Equal(t, 0.94, parsed.Details[0].Score)
assert.Equal(t, SuggestionReview, parsed.ProviderSuggestion)
assert.Equal(t, "示例片段", parsed.Hint)
// 审核服务自己判 review(Pass=true),但档位策略仍可覆盖它。
assert.True(t, parsed.Pass)
decision, detail := Decide("strict", parsed)
assert.Equal(t, DecisionBlock, decision)
assert.NotNil(t, detail)
assert.Equal(t, ForbiddenTypeAbuse, detail.Label)
decision, _ = Decide("standard", parsed)
assert.Equal(t, DecisionBlock, decision)
decision, _ = Decide("lax", parsed)
assert.False(t, decision.Blocked())
}
func TestQiniuParserProviderSuggestionFallback(t *testing.T) {
// 外层 suggestion 缺失时回退到 antispam 场景内的建议,避免整体建议丢失后
// 严格档的「采信整体 block」分支失效。
raw := []byte(`{"code":200,"result":{"scenes":{"antispam":{"suggestion":"block","details":[]}}}}`)
parsed, err := QiniuTextCensorParser(raw)
assert.NoError(t, err)
assert.Equal(t, SuggestionBlock, parsed.ProviderSuggestion)
assert.Empty(t, parsed.Details)
// 严格档:无具体维度,但整体建议为 block → 拦截。
decision, detail := Decide("strict", parsed)
assert.Equal(t, DecisionBlock, decision)
assert.Nil(t, detail)
// 标准档不采信整体建议 → 放行(这是「少误伤」的核心差异)。
decision, _ = Decide("standard", parsed)
assert.False(t, decision.Blocked())
}
func TestBlockedLabelsIncludesBaselineAtEveryLevel(t *testing.T) {
// 供设置页展示拦截范围:底线维度必须出现在每一个档位的清单里。
for _, level := range []string{"lax", "standard", "strict"} {
labels := BlockedLabels(level)
assert.Contains(t, labels, ForbiddenTypePolitics, "level=%s", level)
assert.Contains(t, labels, ForbiddenTypeTerrorism, "level=%s", level)
assert.Contains(t, labels, ForbiddenTypePorn, "level=%s", level)
assert.Contains(t, labels, ForbiddenTypeCustomized, "level=%s", level)
// 不重复展示底线维度。
seen := map[ForbiddenType]int{}
for _, label := range labels {
seen[label]++
}
for label, count := range seen {
assert.Equal(t, 1, count, "label %s 重复出现", label)
}
}
// 宽松档只含底线四个维度(涉政 / 暴恐 / 色情 / 自定义词库)。
assert.Len(t, BlockedLabels("lax"), 4)
assert.Contains(t, BlockedLabels("standard"), ForbiddenTypeAbuse)
assert.NotContains(t, BlockedLabels("standard"), ForbiddenTypeSpam)
assert.Contains(t, BlockedLabels("strict"), ForbiddenTypeSpam)
}
+39 -14
View File
@@ -128,27 +128,52 @@ func QiniuTextCensorParser(raw []byte) (*TextCensorResponse, error) {
var hint string
var detailKey string
var confidence float64
for _, detail := range responseJSON.Result.Scenes.Antispam.Details {
if detail.Label == "normal" {
// 收集全部非 normal 维度的判定明细,供严格度策略按维度裁决(见 policy.go)。
// 只取首个维度的做法会丢掉其余维度信息,无法做到「只拦高置信度的辱骂」。
details := make([]LabelScore, 0, len(responseJSON.Result.Scenes.Antispam.Details))
for _, d := range responseJSON.Result.Scenes.Antispam.Details {
if d.Label == "normal" {
continue
}
// Get the first context as the hint, forbidden type, confidence.
for _, context := range detail.Contexts {
hint = context.Context
details = append(details, LabelScore{
Label: formatQiniuForbiddenType(d.Label),
Score: d.Score,
})
// 保留原有语义:首个非 normal 维度决定 ForbiddenType / Hint / Confidence。
if detailKey == "" {
for _, context := range d.Contexts {
hint = context.Context
}
detailKey = d.Label
confidence = d.Score
}
detailKey = detail.Label
confidence = detail.Score
break
}
pass := responseJSON.IsPass()
// 整体建议优先取外层,缺失时回退到 antispam 场景的建议。
suggestion := responseJSON.Result.Suggestion
if suggestion == "" {
suggestion = responseJSON.Result.Scenes.Antispam.Suggestion
}
// 建议字段整体缺失、但服务自身判定为不放行时,把整体建议补成 block:
// 否则策略层会因「没有明细维度 + 没有建议」把它当成未命中而放行,
// 与改造前的 fail-closed 行为不一致。
if suggestion == "" && !pass {
suggestion = SuggestionBlock
}
return &TextCensorResponse{
SourceName: "qiniu",
Pass: responseJSON.IsPass(),
ForbiddenType: formatQiniuForbiddenType(detailKey),
Hint: hint,
Confidence: confidence,
RawResponse: raw,
SourceName: "qiniu",
Pass: pass,
ForbiddenType: formatQiniuForbiddenType(detailKey),
Hint: hint,
Confidence: confidence,
RawResponse: raw,
Details: details,
ProviderSuggestion: suggestion,
}, nil
}
+35 -3
View File
@@ -24,6 +24,10 @@ const (
ForbiddenTypeContraband ForbiddenType = "contraband"
ForbiddenTypeMeaningless ForbiddenType = "meaningless"
ForbiddenTypeHarmful ForbiddenType = "harmful"
// ForbiddenTypeCustomized 命中审核服务侧的自定义词库(阿里云控制台配置,
// 七牛不支持服务端词库因此不会返回该维度)。属运营方显式配置的规则,
// 在 policy.go 中被归入底线集合,任何档位都不放行。
ForbiddenTypeCustomized ForbiddenType = "customized"
)
func (f ForbiddenType) String() string {
@@ -38,9 +42,25 @@ func (f ForbiddenType) String() string {
ForbiddenTypeContraband: "违禁",
ForbiddenTypeMeaningless: "无意义",
ForbiddenTypeHarmful: "不良场景",
ForbiddenTypeCustomized: "自定义词库",
}[f]
}
// LabelScore 审核服务针对单个维度(label)给出的判定与置信度。
//
// 各服务商都会返回「多维度 + 每个维度一个分值」的结果,过去只取首个非 normal 维度,
// 丢掉了其余维度的信息,导致无法按维度做差异化处置(例如只拦高置信度的辱骂)。
//
// ⚠️ Score 已统一归一化到 0~1,与提供方无关:七牛本身即 0~1。
// 阿里云内容安全 2.0 不返回逐标签置信度,只返回整条内容的 riskLevel,
// 由 AliyunTextCensorParser 折算为 0~1(high=1 / medium=0.6 / low=0.3)后由该响应的
// 所有标签共用 —— 这是有损的近似,详见 AliyunTextCensorParser 与 aliyunRiskLevelScore 的注释。
// policy.go 的阈值因此对两家服务商都成立(对阿里云的语义为「按风险级别分级」)。
type LabelScore struct {
Label ForbiddenType `json:"label"`
Score float64 `json:"score"`
}
type TextCensorResponse struct {
SourceName string `json:"source_name"`
Pass bool `json:"pass"`
@@ -48,6 +68,12 @@ type TextCensorResponse struct {
Hint string `json:"hint"`
Confidence float64 `json:"confidence"`
RawResponse json.RawMessage `json:"raw_response"`
// Details 全部非 normal 维度的判定明细,供严格度策略按维度裁决。
// 仅在回放缓存结果时为 nil(见 censor.go),此时 Pass 已足以表达判定。
Details []LabelScore `json:"details,omitempty"`
// ProviderSuggestion 审核服务自身给出的管控建议(block / review / pass)。
// 它不携带具体维度,无法做置信度收敛,因此只在严格档被采信(见 policy.go)。
ProviderSuggestion string `json:"provider_suggestion,omitempty"`
}
func (r *TextCensorResponse) ToJSON() []byte {
@@ -55,10 +81,16 @@ func (r *TextCensorResponse) ToJSON() []byte {
return jsonBytes
}
func (r *TextCensorResponse) ErrorMessage() string {
// ErrorMessageFor 生成针对指定命中维度的拒绝提示。
//
// 与已移除的 ErrorMessage 的区别:后者固定使用首个非 normal 维度,而实际触发拦截的
// 可能是另一个维度(策略层按档位裁决),两者不一致时用户会看到对不上的原因。
// 因此拦截时统一用本方法,按真正命中的维度出文案。
// detail 为 nil(即由审核服务的整体 block 建议触发)时退化为不带维度的基础文案。
func (r *TextCensorResponse) ErrorMessageFor(detail *LabelScore) string {
errorMessage := "内容安全检查不通过"
if r.ForbiddenType != "" {
errorMessage += fmt.Sprintf(" [%s]", r.ForbiddenType.String())
if detail != nil && detail.Label != "" {
errorMessage += fmt.Sprintf(" [%s]", detail.Label.String())
}
if r.Hint != "" {
errorMessage += fmt.Sprintf(",相关标签:%q", r.Hint)
@@ -0,0 +1,71 @@
// 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/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)
}
// 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
}
+13
View File
@@ -18,6 +18,7 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/security/moderation"
)
var (
@@ -210,6 +211,18 @@ func FuncMap() []template.FuncMap {
"langName": func(lang string) string {
return i18n.Name(lang)
},
// moderationStrictness 返回账号「最终生效」的审核严格度档位(lax / standard / strict)。
//
// 设置页下拉框必须用它回显,不能直接用 LoggedUser.ModerationStrictness:
// 存量账号该字段为空值(表示"跟随站点默认"),三个 <option> 的 eq 判定全部为假,
// 浏览器会默认选中第一个选项(宽松),而实际生效的是站点默认档位(通常为严格)——
// 界面会告诉箱主"你是宽松",与真实拦截行为相反。
// 走 moderation.StrictnessFor 解析,保证回显与 internal/security/censor/policy.go
// 的裁决口径是同一份事实来源(含站点默认与非法值回退)。
"moderationStrictness": func(raw string) string {
return string(moderation.StrictnessFor(raw))
},
}}
})
return funcMap