Files
tamabox/internal/security/censor/aliyun.go
T
2026-09-10 18:04:19 +08:00

616 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package 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: &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 = 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 "<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 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.")
})
}