// 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 // 阿里云文本审核通道 —— 内容安全 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" "unicode/utf8" 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 { accessKey, accessKeySecret string } func NewAliyunTextCensor(accessKey, accessKeySecret string) *AliyunTextCensor { return &AliyunTextCensor{ accessKey: accessKey, accessKeySecret: accessKeySecret, } } // 包级复用的 client:审核请求高频且密钥在启动后不变, // 用 sync.Once 懒加载一次并设置超时,避免每次审核都重建连接与对象。 var ( aliyunGreenOnce sync.Once aliyunGreenClient *green20220302.Client aliyunGreenErr error // warnAliyunAccountIssueDoOnce 保证「账号侧未就绪」的告警只打一次,避免按请求刷屏。 warnAliyunAccountIssueDoOnce sync.Once ) // 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: ®ionID, 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 = newAliyunClient(c.accessKey, c.accessKeySecret) }) return aliyunGreenClient, aliyunGreenErr } // 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 } // 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} } 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 chunks } // 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 content moderation client") } // 空内容(例如原文只是一条可信链接,已被 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 merged response") } return AliyunTextCensorParser(raw) } func (*AliyunTextCensor) String() string { return "aliyun" } // aliyunRawDigest 返回原始响应的可读摘要(超长截断),供错误诊断使用。 func aliyunRawDigest(raw []byte) string { s := strings.TrimSpace(string(raw)) if s == "" { return "" } runes := []rune(s) if len(runes) > aliyunMaxRawLogBytes { return string(runes[:aliyunMaxRawLogBytes]) + "...(truncated)" } return s } // AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse。 // // 在线调用与缓存回放共用本函数(见 aliyunTextModerationRaw 的说明)。 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 退回一次实时调用; // 若当成「无标签 → 通过」,历史缓存就会变成绕过审核的后门。 if parsed.API != aliyunTextModerationAPI { return nil, errors.Errorf("unsupported aliyun response format (api=%q raw=%s)", parsed.API, aliyunRawDigest(raw)) } // 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 } details = append(details, LabelScore{Label: mapped, Score: score}) // 保留既有语义:首个命中的标签决定 ForbiddenType / Confidence。 if forbiddenType == "" { forbiddenType = mapped confidence = score } } return &TextCensorResponse{ 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 strings.ToLower(strings.TrimSpace(typ)) { case "political_content", "regional": return ForbiddenTypePolitics case "violence": return ForbiddenTypeTerrorism case "sexual_content", "sexuality": return ForbiddenTypePorn case "contraband": return ForbiddenTypeContraband 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.") }) }