fix anwser token and database issus

This commit is contained in:
tamakyi
2026-09-15 11:06:09 +08:00
parent e683e798aa
commit adcc42cc8c
33 changed files with 1011 additions and 802 deletions
+52 -114
View File
@@ -4,20 +4,14 @@
package censor
// 阿里云文本审核通道 —— 内容安全 2.0「文本审核增强版」。
// 阿里云文本审核通道 —— 内容安全 2.0「文本审核增强版」(TextModeration)。
//
// 迁移记录(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 只要求实名认证
// (个人可开通),且标签体系对底线维度(涉政/暴恐/色情/自定义词库)覆盖完整。
// 1.0(/green/text/scan)需企业实名认证,个人账号永久返回 code=596,故迁 2.0(仅需实名认证);
// 2.0 标签对底线维度(涉政/暴恐/色情/自定义词库)覆盖完整。官方参考:
// - API:https://help.aliyun.com/practice_detail/434034
// - 标签:https://help.aliyun.com/practice_detail/465315
//
// 官方文档:
// - 文本审核增强版通用服务 API:https://help.aliyun.com/practice_detail/434034
// - 标签处理建议参考: https://help.aliyun.com/practice_detail/465315
//
// ⚠️ 不要把 SDK 里 `TextModerationResponseBodyData.Labels` 的内联注释当作权威标签表:
// 那份列表是旧的且不完整(没有 political 相关标签),以官方《标签处理建议参考》为准。
// ⚠️ SDK 里 TextModerationResponseBodyData.Labels 的内联注释是旧表且不完整(缺涉政标签),以官方为准。
import (
"context"
@@ -35,38 +29,28 @@ import (
)
const (
// aliyunRegionID 内容安全 2.0 的接入地域,SDK 据此解析出
// green-cip.cn-shanghai.aliyuncs.com(见绿网 SDK 的 EndpointMap)。
// aliyunRegionID 接入地域,SDK 据此解析出 green-cip.cn-shanghai.aliyuncs.com。
aliyunRegionID = "cn-shanghai"
// aliyunTextService 文本审核增强版的服务场景。
//
// 选 comment_detection(公聊评论内容检测):官方定位「评论/弹幕/公聊/转发」,
// 明确覆盖涉黄/涉政/涉暴等底线违规与广告导流,是本站(公开问答为主、私聊为辅)
// 最通用的单一场景。本通道被提问/回答、投票、私聊三条链路共用,接口层不带场景信息;
// 若要按场景细分(例如私聊改用 chat_detection),需给审核入口补 scene 参数。
// aliyunTextService 服务场景。选 comment_detection:官方定位「评论/弹幕/公聊/转发」,
// 覆盖涉黄/涉政/涉暴底线与广告导流。三条链路共用且接口层不带场景,
// 若要按场景细分(如私聊改 chat_detection)需给审核入口补 scene 参数。
aliyunTextService = "comment_detection"
// aliyunContentRuneLimit 2.0 通用服务单次 content 的硬上限。
// 官方文档:content 限定在 600 字符以内。这里按 550 切分以留出安全余量。
// aliyunContentRuneLimit 单次 content 硬上限(官方 600),按 550 切分留安全余量。
aliyunContentRuneLimit = 550
// aliyunChunkOverlapRunes 相邻分片的重叠字符数。
// 切分点可能正好落在一个风险词中间,两段各自都看不出问题;重叠可消除该盲区。
// 代价是少量重复计费(文本审核标准 7.5 元/万次,可忽略)。
// aliyunChunkOverlapRunes 相邻分片重叠字符数:防止风险词正好落在切分点而被漏判。
aliyunChunkOverlapRunes = 50
aliyunReadTimeoutMs = 10000
aliyunConnectTimeoutMs = 5000
// aliyunMaxRawLogBytes 错误信息里回显的原始响应长度上限(按字符计),
// 避免异常响应体过大时把日志刷屏。
// aliyunMaxRawLogBytes 错误信息里回显的原始响应长度上限(按字符计),避免日志刷屏。
aliyunMaxRawLogBytes = 512
// aliyunTextModerationAPI 归一化响应的格式标记。
//
// 解析器据此拒绝「非本格式」的载荷(例如迁移前内容安全 1.0 的响应),
// 让 censor.go 的缓存回放能安全地退回一次实时调用,
// aliyunTextModerationAPI 归一化响应的格式标记:解析器据此拒绝非本格式载荷
// (如迁移前 1.0 的响应),使 censor.go 的缓存回放能安全退回实时调用,
// 而不是把无法识别的历史缓存误判成「通过」。
aliyunTextModerationAPI = "TextModeration"
)
@@ -82,8 +66,7 @@ func NewAliyunTextCensor(accessKey, accessKeySecret string) *AliyunTextCensor {
}
}
// 包级复用的 client:审核请求高频且密钥在启动后不变,
// 用 sync.Once 懒加载一次并设置超时,避免每次审核都重建连接与对象。
// 包级复用 client:密钥启动后不变,用 sync.Once 懒加载一次,避免每次审核都重建连接。
var (
aliyunGreenOnce sync.Once
aliyunGreenClient *green20220302.Client
@@ -93,11 +76,8 @@ var (
warnAliyunAccountIssueDoOnce sync.Once
)
// newAliyunClient 构造内容安全 2.0 客户端。
//
// 不设置 Endpoint 而设置 RegionId:SDK 内置 EndpointMap 会把 cn-shanghai
// 解析成 green-cip.cn-shanghai.aliyuncs.com。超时单位是毫秒
// (见 darabonba-openapi 的 client.ReadTimeout → 请求参数 readTimeout)。
// newAliyunClient 构造 2.0 客户端。设 RegionId 而非 Endpoint:SDK 的 EndpointMap 会解析出
// green-cip.cn-shanghai.aliyuncs.com。超时单位为毫秒。
func newAliyunClient(accessKey, accessKeySecret string) (*green20220302.Client, error) {
regionID := aliyunRegionID
readTimeout := aliyunReadTimeoutMs
@@ -123,10 +103,8 @@ func (c *AliyunTextCensor) getClient() (*green20220302.Client, error) {
return aliyunGreenClient, aliyunGreenErr
}
// newAliyunTextModerationRequest 构造 TextModeration 请求。
//
// 单独抽出便于测试断言请求形态:Service 必须是选定场景,
// ServiceParameters 是 JSON 字符串 {"content": ...}。
// 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 {
@@ -137,13 +115,10 @@ func newAliyunTextModerationRequest(text string) (*green20220302.TextModerationR
SetServiceParameters(string(serviceParameters)), nil
}
// splitAliyunText 把待审文本切成不超过 2.0 单次上限的分片(按 rune 切,避免切坏多字节字符)。
//
// 为什么必须切分:2.0 通用服务单次 content 硬上限 600 字符,而本站内容上限为
// 提问 1000 / 回答·追问·私聊 2000 / 投票描述 5000 字,长文本必然超限。
// 把超长文本直接发给接口会被拒,进而在 fail-closed 下变成「长内容一律发不出去」。
//
// 分片带 aliyunChunkOverlapRunes 的重叠,消除「风险词正好跨切分点」的漏判盲区。
// splitAliyunText 按 rune 切成不超过单次上限的分片。
// 必须切分:2.0 单次硬上限 600 字符,而本站上限为 提问 1000 / 回答·追问·私聊 2000 /
// 投票描述 5000 字;超长直发会被拒,在 fail-closed 下等于「长内容一律发不出去」。
// 分片带重叠以消除「风险词正好跨切分点」的漏判盲区。
func splitAliyunText(text string) []string {
runes := []rune(text)
if len(runes) <= aliyunContentRuneLimit {
@@ -165,19 +140,16 @@ func splitAliyunText(text string) []string {
return chunks
}
// aliyunTextModerationReason 是 2.0 响应中 Data.Reason 的结构(该字段本身是 JSON 字符串)。
//
// 只声明用到的字段:riskLevel 风险级别、riskTips 子标签、riskWords 命中词。
// aliyunTextModerationReason 2.0 响应中 Data.Reason 的结构(该字段本身是 JSON 字符串)。
type aliyunTextModerationReason struct {
RiskLevel string `json:"riskLevel"`
RiskTips string `json:"riskTips"`
RiskWords string `json:"riskWords"`
}
// parseAliyunReason 解析 Data.Reason。
//
// 字段缺失或非法 JSON 时返回零值 —— 忽略解析错误是刻意的:Reason 是辅助字段,
// 不应因为它的格式变化导致整条审核失败。风险级别拿不到时的保守处理见 aliyunRiskLevelScore。
// parseAliyunReason 解析 Data.Reason。字段缺失或非法 JSON 时返回零值 ——
// 忽略解析错误是刻意的:Reason 是辅助字段,不应因其格式变化导致整条审核失败
// (风险级别拿不到时的保守处理见 aliyunRiskLevelScore)。
func parseAliyunReason(raw string) aliyunTextModerationReason {
var reason aliyunTextModerationReason
if strings.TrimSpace(raw) == "" {
@@ -254,11 +226,8 @@ func aliyunHighestRiskLevel(levels []string) string {
return best
}
// aliyunTextModerationRaw 归一化后的可落库响应。
//
// 它是「在线调用」与「缓存回放」两条路径的唯一输入格式:
// Censor 把(可能多分片的)2.0 响应合并成它并序列化,随后交给 AliyunTextCensorParser;
// censor.go 命中落库缓存时,也把这段 JSON 交给同一个 parser。
// aliyunTextModerationRaw 归一化后的可落库响应:在线调用与缓存回放两条路径的唯一输入格式,
// 最终都交给 AliyunTextCensorParser 解析。
type aliyunTextModerationRaw struct {
// API 格式标记,取值恒为 aliyunTextModerationAPI,供解析器甄别旧格式载荷。
API string `json:"api"`
@@ -266,7 +235,7 @@ type aliyunTextModerationRaw struct {
Code int32 `json:"code"`
Message string `json:"message"`
RequestIDs []string `json:"requestIds,omitempty"`
// Labels 已过滤掉 nonLabel / normal,且已去重。
// Labels 已过滤 nonLabel / normal 并去重。
Labels []string `json:"labels,omitempty"`
RiskLevel string `json:"riskLevel,omitempty"`
Hint string `json:"hint,omitempty"`
@@ -275,8 +244,7 @@ type aliyunTextModerationRaw struct {
// aliyunEmptyCleanRaw 空内容对应的归一化响应(无标签 → 通过)。见 Censor 的空内容分支。
var aliyunEmptyCleanRaw = []byte(`{"api":"TextModeration","statusCode":200,"code":200,"message":"OK"}`)
// aliyunChunkResult 单个分片审核结果里我们关心的最小信息集。
// 抽出这一层是为了让「多分片合并」这段逻辑可以脱离 SDK 与网络单独测试。
// aliyunChunkResult 单个分片结果的最小信息集,使「多分片合并」可脱离 SDK 单独测试。
type aliyunChunkResult struct {
StatusCode int32
Code int32
@@ -288,9 +256,8 @@ type aliyunChunkResult struct {
}
// extractAliyunChunkResult 从 SDK 响应里提取分片结果。
//
// code 非 200/0 表示请求被阿里云拒绝(服务未开通、限流、参数错误等),
// 必须报错而不是当成「未命中」—— 否则 fail-closed 会被悄悄变成 fail-open。
// 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")
@@ -338,12 +305,8 @@ func extractAliyunChunkResult(resp *green20220302.TextModerationResponse) (aliyu
return result, nil
}
// mergeAliyunChunkResults 把多个分片的结果合并成一份归一化响应。
//
// 合并口径:
// - 标签取并集(任一分片命中即视为整条内容命中);
// - 风险级别取最高(high > medium > low > none),未知级别按最高处理;
// - Hint 取首个非空,与「首个命中维度决定提示文案」的既有语义保持一致。
// mergeAliyunChunkResults 把多个分片结果合并成一份归一化响应:标签取并集、
// 风险级别取最高(未知级别按最高处理)、Hint 取首个非空。
func mergeAliyunChunkResults(results []aliyunChunkResult) aliyunTextModerationRaw {
merged := aliyunTextModerationRaw{
API: aliyunTextModerationAPI,
@@ -440,17 +403,14 @@ func aliyunRawDigest(raw []byte) string {
return s
}
// AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse。
//
// 在线调用与缓存回放共用本函数(见 aliyunTextModerationRaw 的说明)。
// AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse(在线调用与缓存回放共用)。
func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
var parsed aliyunTextModerationRaw
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, errors.Wrapf(err, "unmarshal response (raw=%s)", aliyunRawDigest(raw))
}
// 格式标记不符:典型是迁移前内容安全 1.0 落库的缓存(形如 {"code":200,"data":[...]})。
// 这种载荷无法按 2.0 语义解释,必须报错,交由 censor.go 退回一次实时调用;
// 格式标记不符(典型是迁移前 1.0 落库的缓存)必须报错,交由 censor.go 退回实时调用;
// 若当成「无标签 → 通过」,历史缓存就会变成绕过审核的后门。
if parsed.API != aliyunTextModerationAPI {
return nil, errors.Errorf("unsupported aliyun response format (api=%q raw=%s)",
@@ -460,14 +420,12 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
// 2.0 不返回逐标签置信度,只有整条内容的 riskLevel,所有标签共用折算后的分值。
score := aliyunRiskLevelScore(parsed.RiskLevel)
// Pass 取「审核服务未给出任何风险标签」。注意它不是最终裁决 ——
// 是否拦截由 policy.go 按箱主严格度决定。
// Pass 只表示「未给出风险标签」,不是最终裁决(是否拦截由 policy.go 按严格度决定)。
pass := len(parsed.Labels) == 0
suggestion := aliyunRiskLevelSuggestion(parsed.RiskLevel)
// 与七牛保持一致:审核服务判为「非通过」却没给出建议时补成 block。
// 典型场景是命中了尚未映射的新标签(既没有明细维度、也没有 riskLevel),
// 不补的话策略层会把它当成未命中放行,与改造前的 fail-closed 行为不一致。
// 与七牛一致:判为「非通过」却没给建议时补成 block(如命中尚未映射的新标签),
// 不补会被策略层当成未命中放行,与改造前的 fail-closed 行为不一致。
if suggestion == "" && !pass {
suggestion = SuggestionBlock
}
@@ -480,8 +438,7 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
for _, label := range parsed.Labels {
mapped := formatAliyunForbiddenType(label)
if mapped == "" {
// 未映射的标签不进入明细:它无法参与按维度裁决。
// 原始标签仍完整保留在 RawResponse 中供审计。
// 未映射的标签无法参与按维度裁决,不进明细(原始标签仍保留在 RawResponse 供审计)。
continue
}
@@ -506,25 +463,10 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
}, 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
// 不会由本通道产生,七牛仍可能返回它。
// formatAliyunForbiddenType 把 2.0 标签映射为 ForbiddenType(取值以官方
// 《文本审核增强版标签处理建议参考》为准;SDK 内联的标签表是旧版且不完整,勿采信)。
// 政治 / 暴恐 / 色情 / 自定义属底线维度,任何档位都拦。
// 2.0 没有「灌水(flood)」维度(同类归入 spam),故 ForbiddenTypeFlood 不由本通道产生。
func formatAliyunForbiddenType(typ string) ForbiddenType {
switch strings.ToLower(strings.TrimSpace(typ)) {
case "political_content", "regional":
@@ -553,10 +495,9 @@ func formatAliyunForbiddenType(typ string) ForbiddenType {
}
}
// aliyunAccountIssueSignals 「账号侧未就绪」的特征信号(小写匹配),覆盖结构化 code 与消息文本两条路径:
// - 596 / "not opened":服务未开通
// - Forbidden:RAM 未授权
// - InvalidAccessKeyId / SignatureDoesNotMatch:密钥错误
// aliyunAccountIssueSignals 「账号侧未就绪」的特征信号(小写匹配),覆盖结构化 code 与消息文本:
// 596 / "not opened" 服务未开通;Forbidden / no permission RAM 未授权;
// InvalidAccessKeyId / SignatureDoesNotMatch 密钥错误。
var aliyunAccountIssueSignals = []string{
"596",
"not opened",
@@ -592,12 +533,9 @@ func aliyunIsAccountIssue(err error) bool {
return false
}
// warnAliyunAccountIssueOnce 在首次出现「账号侧未就绪」类失败时告警一次。
//
// 这类问题(服务未开通 / RAM 未授权 / 密钥无效)不会自愈,会把 fail-closed 的审核链路
// 永久卡死:外部表现是「全站内容都发不出去」,或(auto 模式下)内容静默落到下一个通道,
// 但日志里原本只是一条普通 ERRO,极易被当成偶发网络抖动而忽略。
// 因此第一次遇到就打出可操作的指引,并用 sync.Once 约束为只打一次,避免按请求刷屏。
// warnAliyunAccountIssueOnce 首次出现「账号侧未就绪」失败时告警一次(sync.Once 避免刷屏)。
// 这类问题(未开通 / RAM 未授权 / 密钥无效)不会自愈,会永久卡死 fail-closed 的审核链路,
// 表现为「全站内容发不出去」,而日志里原本只是一条普通 ERRO、极易被当成偶发网络抖动。
func warnAliyunAccountIssueOnce(ctx context.Context, err error) {
if !aliyunIsAccountIssue(err) {
return
+30 -33
View File
@@ -24,13 +24,10 @@ var (
warnNoCensorCredentialsOnce sync.Once
)
// censorChain 返回按配置选定的审核通道列表,顺序即优先级:
// 靠前的通道判定「通过」即返回;未通过则继续交给后面的通道复核。
//
// 取值来源 [security] text_censor_source(见 conf.CensorSource*):
// - qiniu → 只用七牛(凭据齐备性已由 conf.Init 启动校验)
// - aliyun → 只用阿里云(同上)
// - 其它(含留空 / auto)→ 七牛优先、阿里云兜底,与引入该配置前完全一致
// censorChain 返回按 [security] text_censor_source 选定的通道列表,顺序即优先级:
// 靠前通道判定通过即返回,未通过则交给后面的通道复核。
// qiniu / aliyun 只用对应通道(凭据齐备性已由 conf.Init 启动校验);
// 其它取值(含留空 / auto)为七牛优先、阿里云兜底,与引入该配置前一致。
func censorChain() []TextCensor {
qiniu := NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret)
aliyun := NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret)
@@ -45,11 +42,9 @@ func censorChain() []TextCensor {
}
}
// warnIfNoCensorCredentials 在「配置里一条可用凭据都没有」时告警一次。
//
// 这种情况下审核调用必然失败,而调用方是 fail-closed(拒绝提交),表现为
// 全站发不出内容/私信——现象隐蔽、只在日志可见,因此第一次触发就留下明确线索。
// 显式选定通道但缺凭据的情况已由 conf.Init 直接挡住,这里兜的是 auto 的漏网情况。
// 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 != ""
@@ -62,35 +57,35 @@ func warnIfNoCensorCredentials(ctx context.Context) {
})
}
// Text checks the text for sensitive content.
// It will save the censor log to the database and invoke the callback function.
// Text 检查文本是否含敏感内容,并把审核结果落库(31 天内可回放复用)。
func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
if !conf.Security.EnableTextCensor {
return &TextCensorResponse{Pass: true}, nil
}
// 缓存键与送审内容统一用归一化文本(已剔除可信域名链接):否则缓存按原文命中、
// 实际送审的却是归一化结果 —— 审计对不上,且归一化后相同的原文会各占一条缓存、重复调 API。
normalized := RemoveTrustedURL(text)
var responses []*TextCensorResponse
warnIfNoCensorCredentials(ctx)
for _, censor := range censorChain() {
sourceName := censor.String()
// Try to get the censor from the database log.
censorLog, err := db.CensorLogs.GetByText(ctx, sourceName, text, time.Now().Add(-censorCacheNoMoreThan))
censorLog, err := db.CensorLogs.GetByText(ctx, sourceName, normalized, time.Now().Add(-censorCacheNoMoreThan))
if err != nil {
if !errors.Is(err, db.ErrCensorLogsNotFound) {
logrus.WithContext(ctx).WithError(err).Error("Failed to get censor log")
}
} else {
raw := censorLog.RawResponse
// We got the previous censor log cache, seems like we saved money for the API call.
switch censorLog.SourceName {
case "qiniu":
// 七牛缓存命中:只有「通过」才可直接复用(省一次 API 调用)。
// 未通过时不在此下最终结论,而是交给链上的下一个通道复核(auto 模式下即阿里云,
// 这是引入通道配置前就有的行为)。
// text_censor_source = qiniu 时链上只有七牛:continue 后循环结束,
// 仍会由函数末尾返回这份七牛结果,判定不会丢失。
// 只有「通过」才直接复用(省一次 API 调用);未通过则交给链上下一个通道复核
// (auto 模式下即阿里云,引入通道配置前就是如此)。
// text_censor_source=qiniu 时链上只有七牛,continue 后循环结束,
// 仍由函数末尾返回这份结果,判定不会丢失。
response, err := QiniuTextCensorParser(raw)
if err == nil {
if response.Pass {
@@ -102,14 +97,19 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
}
case "aliyun":
// 阿里云缓存命中:能解析就直接复用(省一次 API 调用)。
// 解析失败必须退回一次实时调用,而不是把错误往上抛:
// 最典型的失败源是「迁移到内容安全 2.0 之前落库的 1.0 缓存」,
// 它无法按 2.0 语义解释;若在此返回错误,fail-closed 会让这些
// 历史文本在缓存过期(31 天)前一直发不出去。
// 能解析就复用;解析失败必须退回实时调用而不是把错误往上抛:
// 最典型的失败源是迁到内容安全 2.0 前落库的 1.0 缓存,无法按 2.0 语义解释,
// 若在此报错,fail-closed 会让这些历史文本在缓存过期前一直发不出去。
// 与七牛一致:仅「通过」才直接返回,未通过则交给链上下一个通道复核
// (阿里云现为链尾,continue 后循环结束,仍由函数末尾返回同一份结果)。
response, err := AliyunTextCensorParser(raw)
if err == nil {
return response, nil
if response.Pass {
return response, nil
}
responses = append(responses, response)
continue
}
logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).
Warn("Failed to replay cached aliyun censor result; falling back to a live call")
@@ -119,19 +119,16 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
}
}
// response, err := censor.Censor(ctx, text)
censorText := RemoveTrustedURL(text)
response, err := censor.Censor(ctx, censorText)
response, err := censor.Censor(ctx, normalized)
if err != nil {
logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).Error("Failed to censor text")
continue
}
responses = append(responses, response)
// Save the censor log to the database.
if err := db.CensorLogs.Create(ctx, db.CreateCensorLogOptions{
SourceName: sourceName,
Input: text,
Input: normalized,
Pass: response.Pass,
RawResponse: response.RawResponse,
}); err != nil {
+26 -73
View File
@@ -5,40 +5,21 @@
package censor
// Strictness 内容审核严格度档位。
//
// 背景:审核服务(本项目为七牛云文本审核)对「辱骂」等维度的召回偏激进,
// 正常提问/对话经常被判为不通过而被整条拒绝。但我们既不能让审核服务判得更准,
// 也不能让它判得更松(七牛文本审核不支持白名单,自定义词库需提交工单),
// 唯一能控制的是「审核命中之后到底拦不拦」。
//
// 因此把「是否拦截」从审核服务自身的 Pass 判定中剥离出来,改为由本档位策略裁决,
// 让箱主在「少误伤」与「少漏放」之间自行取舍。
// 审核服务调不松、不可配白名单,可控的只有「命中后拦不拦」,故把拦截从 Pass 判定剥离、改由本档位裁决。
type Strictness string
const (
// StrictnessLax 宽松:仅拦截底线维度,其余一律放行。误伤最小,漏放最多。
// StrictnessLax 宽松:仅拦底线维度,误伤最小、漏放最多。
StrictnessLax Strictness = "lax"
// StrictnessStandard 标准:底线维度 + 高置信度的辱骂/违禁。
// StrictnessStandard 标准:底线 + 高置信度的辱骂/违禁。
StrictnessStandard Strictness = "standard"
// StrictnessStrict 严格:底线维度 + 全部非正常维度(不限置信度)+ 采信审核服务的 block 建议。
//
// 这是三个档位中最严的一档,且是唯一保留「采信审核服务整体 block 建议」的档位。
// 改造前的拦截条件是 `!Pass`(即 涉政/暴恐/色情 命中,或审核服务建议为 block),
// 该条件可被本档完全覆盖,但本档会额外拦截全部带维度的非正常内容(如 review 的辱骂)。
// 换言之:严格档 ⊇ 改造前的行为,是「至少与改造前一样严」,并非完全等价。
// StrictnessStrict 严格:底线 + 全部非正常维度(不限置信度)+ 采信服务 block 建议。
// 严格档 ⊇ 改造前行为(改造前为 !Pass),「至少与改造前一样严」,并非完全等价。
StrictnessStrict Strictness = "strict"
)
// DefaultStrictness 缺省档位。
//
// 取 strict 的取舍:本能力只用于「让箱主把审核调松」,从不用于「在用户未选择时把审核调松」。
// 存量账号从未设置过该值,缺省落到最严档,保证部署本功能这一动作本身不会让任何账号
// 的拦截面变小(合规上更保守);想减少误伤的箱主再显式切到 标准 / 宽松。
//
// ⚠️ 注意方向:因为严格档比改造前更严(见 StrictnessStrict 注释),缺省为 strict 意味着
// 部署后默认拦截面会略有扩大 —— 这是刻意的取舍(已于 2026-09-10 确认维持 strict):
// 合规上宁紧勿松,且「调松」应当是箱主的显式动作,部署本身不应悄悄放宽任何账号。
// 若哪天希望默认更少误伤,把下面这行改成 StrictnessStandard 即可(一行常量)。
// DefaultStrictness 缺省取 strict:部署不应悄悄放宽任何账号,「调松」必须是箱主显式动作;
// 想默认更少误伤可改 Standard。
const DefaultStrictness = StrictnessStrict
// Suggestion 审核服务自身给出的管控建议取值。
@@ -48,14 +29,9 @@ const (
SuggestionPass = "pass"
)
// baselineBlockLabels 底线拦截维度:涉政 / 暴恐 / 色情 / 自定义词库。
//
// 这是合规底线:任何档位下都强制生效、不受置信度门槛约束、不可关闭,
// 也不能通过任何用户配置绕过 —— 见 Decide 中它始终排在最先、无条件返回拦截。
//
// ForbiddenTypeCustomized 归入底线的原因:它是运营方在审核服务控制台(阿里云)
// 显式配置的词库命中,属「用户明写的规则」,与箱主自定义屏蔽词同一性质,
// 不应被档位调低后放行。运营方若要放开,正确的做法是调整控制台词库,而不是调档位。
// baselineBlockLabels 底线拦截维度:涉政/暴恐/色情/自定义词库。
// 合规底线:任何档位强制生效、不受置信度约束、不可关闭、不可绕过(见 Decide 最先判定)。
// Customized 归入底线:它是控制台显式配置的词库,要放开应改控制台词库而非调档位。
var baselineBlockLabels = map[ForbiddenType]struct{}{
ForbiddenTypePolitics: {},
ForbiddenTypeTerrorism: {},
@@ -63,35 +39,23 @@ var baselineBlockLabels = map[ForbiddenType]struct{}{
ForbiddenTypeCustomized: {},
}
// abuseScoreFloor 标准档位下「辱骂」维度生效所需的最低置信度。
//
// 辱骂是审核服务误伤最集中的维度(普通吐槽、语气较冲的表达常被判为辱骂),
// 因此标准档要求高置信度才拦截;严格档则为任意置信度。
//
// 注:置信度的来源因服务商而异。七牛直接给 0~1 的 score;阿里云内容安全 2.0 只给
// 整条内容的 riskLevel,折算为 1 / 0.6 / 0.3(high / medium / low),因此本阈值
// 在阿里云下的实际语义是「标准档只拦 riskLevel=high 的辱骂」。
// abuseScoreFloor 标准档下「辱骂」生效所需最低置信度。
// 辱骂误伤最集中(吐槽常被判辱骂),故标准档要求高置信度;严格档不限。阿里云 2.0 只给 riskLevel,下语义为「只拦 high」。
const abuseScoreFloor = 0.9
// levelPolicy 单一档位的拦截规则。
type levelPolicy struct {
// blockLabels 该档位额外拦截的维度 -> 生效所需最低置信度(0 表示不限置信度)。
// 注意:底线维度不在此表中,由 Decide 无条件前置判定。
// blockLabels 该档位额外拦截的维度 -> 生效最低置信度(0 表示不限)。底线维度不在此表,由 Decide 前置判定。
blockLabels map[ForbiddenType]float64
// blockOnSuggestion 是否采信审核服务自身的 block 建议。
//
// 该建议不携带具体维度(可能是任何原因),无法做置信度收敛,误伤面最大,
// 也是此前正常对话被整条拒绝的主要来源,因此仅严格档启用。
// blockOnSuggestion 是否采信服务自身的 block 建议:不携带维度、无法收敛置信度,误伤面最大,仅严格档启用。
blockOnSuggestion bool
}
var strictnessPolicies = map[Strictness]levelPolicy{
// 宽松:只留底线,其余全部放行。
StrictnessLax: {
blockLabels: map[ForbiddenType]float64{},
blockOnSuggestion: false,
},
// 标准:底线 + 高置信度辱骂 + 违禁。
StrictnessStandard: {
blockLabels: map[ForbiddenType]float64{
ForbiddenTypeAbuse: abuseScoreFloor,
@@ -99,7 +63,6 @@ var strictnessPolicies = map[Strictness]levelPolicy{
},
blockOnSuggestion: false,
},
// 严格:底线 + 全部非正常维度(不限置信度)+ 采信审核服务 block 建议。
StrictnessStrict: {
blockLabels: map[ForbiddenType]float64{
ForbiddenTypeSpam: 0,
@@ -127,8 +90,8 @@ const (
// Blocked 是否应当拒绝提交。
func (d Decision) Blocked() bool { return d == DecisionBlock }
// IsValidStrictness 判断取值是否为合法档位,供后台保存前校验。
// 非法取值一律拒绝写入,避免脏数据把账号静默切到非预期档位。
// IsValidStrictness 校验档位取值合法性,供后台保存前校验;
// 非法值一律拒绝写入,避免脏数据把账号静默切到非预期档位。
func IsValidStrictness(s string) bool {
switch Strictness(s) {
case StrictnessLax, StrictnessStandard, StrictnessStrict:
@@ -138,8 +101,8 @@ func IsValidStrictness(s string) bool {
}
}
// ParseStrictness 归一化严格度取值,未知/空值回退到 DefaultStrictness(保守方向,即最严)。
// 方向刻意选保守:宁可沿用严格判定,也不要因为识别失败把账号静默放到最松档。
// ParseStrictness 归一化严格度,未知/空值回退 DefaultStrictness(最严方向);
// 宁可沿用严格判定,也不要因识别失败静默放到最松档。
func ParseStrictness(s string) Strictness {
if !IsValidStrictness(s) {
return DefaultStrictness
@@ -147,15 +110,9 @@ func ParseStrictness(s string) Strictness {
return Strictness(s)
}
// Decide 按档位裁决一次内容审核结果,返回裁决结果与触发拦截的维度(未命中具体维度时为 nil)。
//
// 判定顺序固定为「底线维度 → 档位维度 → 审核服务建议」,且不可调换:
// 1. 底线维度排在最前且无条件返回拦截,保证任何档位都无法把它关掉;
// 2. 档位维度带各自的置信度门槛,用于收敛误伤最集中的维度;
// 3. 审核服务自身的 block 建议排在最后,且仅严格档启用。
//
// Details 为空(例如审核服务返回了 suggestion=block 但没给出任何非 normal 维度)时,
// 只有开启 blockOnSuggestion 的档位会拦截。
// Decide 按档位裁决一次审核结果,返回裁决与触发拦截的维度(无具体维度时 nil)。
// 判定顺序固定不可调换:底线(无条件)→ 档位维度(带置信度门槛)→ 服务 block 建议(仅严格档)。
// Details 为空时仅开启 blockOnSuggestion 的档位会拦截。
func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
if r == nil {
return DecisionAllow, nil
@@ -163,7 +120,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
policy := strictnessPolicies[ParseStrictness(strictness)]
// 1. 底线:合规底线,任何档位强制生效、不可关闭。
// 1. 底线:任何档位强制生效、不可关闭。
for i := range r.Details {
detail := &r.Details[i]
if _, ok := baselineBlockLabels[detail.Label]; ok {
@@ -171,7 +128,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
}
}
// 2. 档位维度:命中所配维度且达到该维度的置信度门槛。
// 2. 档位维度:命中维度且达到置信度门槛。
for i := range r.Details {
detail := &r.Details[i]
scoreFloor, ok := policy.blockLabels[detail.Label]
@@ -184,7 +141,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
return DecisionBlock, detail
}
// 3. 审核服务自身的 block 建议:仅严格档采信。
// 3. 服务 block 建议:仅严格档采信。
if policy.blockOnSuggestion && r.ProviderSuggestion == SuggestionBlock {
return DecisionBlock, nil
}
@@ -192,12 +149,8 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
return DecisionAllow, nil
}
// BlockedLabels 返回指定档位最终会拦截的全部维度(含底线维度)。
//
// 用途:策略层的自描述 API —— 目前由 policy_test.go 用来守住「底线维度在每个档位都必须出现
// 且不重复」这条不变量。设置页展示的拦截范围是 i18n 静态文案
// (templates/user/moderation-strictness.html),尚未消费本函数;若将来要让 UI 直接由策略层
// 生成文案,这里是唯一事实来源。
// BlockedLabels 返回指定档位最终拦截的全部维度(含底线)。
// 不变量由 policy_test.go 守住(底线维度在每个档位都出现且不重复);设置页文案仍是 i18n 静态文本,未消费本函数。
func BlockedLabels(strictness string) []ForbiddenType {
policy := strictnessPolicies[ParseStrictness(strictness)]
+17 -29
View File
@@ -24,9 +24,8 @@ const (
ForbiddenTypeContraband ForbiddenType = "contraband"
ForbiddenTypeMeaningless ForbiddenType = "meaningless"
ForbiddenTypeHarmful ForbiddenType = "harmful"
// ForbiddenTypeCustomized 命中审核服务侧的自定义词库(阿里云控制台配置,
// 七牛不支持服务端词库因此不会返回该维度)。属运营方显式配置的规则,
// 在 policy.go 中被归入底线集合,任何档位都不放行。
// ForbiddenTypeCustomized 命中服务侧自定义词库(阿里云控制台配置,七牛无服务端词库)。
// 属运营方显式规则,在 policy.go 中归入底线集合,任何档位都不放行。
ForbiddenTypeCustomized ForbiddenType = "customized"
)
@@ -46,16 +45,12 @@ func (f ForbiddenType) String() string {
}[f]
}
// LabelScore 审核服务针对单个维度(label)给出的判定与置信度。
// LabelScore 单个维度(label)的判定与置信度。过去只取首个非 normal 维度,
// 丢掉了其余维度信息,无法按维度差异化处置(例如只拦高置信度的辱骂)。
//
// 各服务商都会返回「多维度 + 每个维度一个分值」的结果,过去只取首个非 normal 维度,
// 丢掉了其余维度的信息,导致无法按维度做差异化处置(例如只拦高置信度的辱骂)。
//
// ⚠️ Score 已统一归一化到 0~1,与提供方无关:七牛本身即 0~1。
// 阿里云内容安全 2.0 不返回逐标签置信度,只返回整条内容的 riskLevel,
// 由 AliyunTextCensorParser 折算为 0~1(high=1 / medium=0.6 / low=0.3)后由该响应的
// 所有标签共用 —— 这是有损的近似,详见 AliyunTextCensorParser 与 aliyunRiskLevelScore 的注释。
// policy.go 的阈值因此对两家服务商都成立(对阿里云的语义为「按风险级别分级」)。
// ⚠️ Score 统一归一化到 0~1:七牛本身即 0~1;阿里云 2.0 无逐标签置信度,只给整条内容的
// riskLevel,由 AliyunTextCensorParser 折算(high=1 / medium=0.6 / low=0.3)后该响应内所有
// 标签共用——有损近似。policy.go 的阈值因此对两家都成立(对阿里云语义为「按风险级别分级」)。
type LabelScore struct {
Label ForbiddenType `json:"label"`
Score float64 `json:"score"`
@@ -68,11 +63,9 @@ 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)。
Details []LabelScore `json:"details,omitempty"` // 全部非 normal 维度,供严格度策略裁决;由各 parser 从 RawResponse 重建,缓存回放同样有值(故改档位对历史缓存也生效)
// ProviderSuggestion 服务方管控建议(block / review / pass)。不携带维度、无法做
// 置信度收敛,因此只在严格档被采信(见 policy.go)。
ProviderSuggestion string `json:"provider_suggestion,omitempty"`
}
@@ -81,12 +74,9 @@ func (r *TextCensorResponse) ToJSON() []byte {
return jsonBytes
}
// ErrorMessageFor 生成针对指定命中维度的拒绝提示。
//
// 与已移除的 ErrorMessage 的区别:后者固定使用首个非 normal 维度,而实际触发拦截的
// 可能是另一个维度(策略层按档位裁决),两者不一致时用户会看到对不上的原因。
// 因此拦截时统一用本方法,按真正命中的维度出文案。
// detail 为 nil(即由审核服务的整体 block 建议触发)时退化为不带维度的基础文案。
// ErrorMessageFor 按真正命中的维度生成拒绝提示——已移除的 ErrorMessage 固定用首个非
// normal 维度,而触发拦截的可能是另一维度(策略层按档位裁决),会导致文案对不上。
// detail 为 nil(由服务方整体 block 建议触发)时退化为不带维度的基础文案。
func (r *TextCensorResponse) ErrorMessageFor(detail *LabelScore) string {
errorMessage := "内容安全检查不通过"
if detail != nil && detail.Label != "" {
@@ -110,8 +100,7 @@ var TrustedHosts = []string{
"acfun.cn",
"aixifan.com",
"github.com",
// 自有域名:旧域 tama.guru 保留至 2026-12 到期前的过渡期结束,
// 期间用户分享的旧链接仍需视为可信(不被判为广告);新域 shiroko.one 为主域。
// 旧域 tama.guru 保留至 2026-12 到期,过渡期内旧分享链接仍需视为可信。
"tama.guru",
"shiroko.one",
"337845818.best",
@@ -119,10 +108,9 @@ var TrustedHosts = []string{
"bilibili.com",
}
// RemoveTrustedURL removes the trusted URL from the text,
// in order to bypass the URL censoring.
// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于字符串结尾时才视为可信,
// 避免 github.com.evil.tld 被朴素替换误判为 github.com 之子域而漏审。
// RemoveTrustedURL 移除可信域名以免链接被判为广告。
// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于串尾时才视为可信,
// 避免 github.com.evil.tld 被朴素替换误判为 github.com 子域而漏审。
func RemoveTrustedURL(str string) string {
for _, host := range TrustedHosts {
for _, protocol := range []string{"http://", "https://"} {