Files
tamabox/internal/security/censor/aliyun.go
T

554 lines
20 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「文本审核增强版」(TextModeration)。
//
// 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
//
// ⚠️ SDK 里 TextModerationResponseBodyData.Labels 的内联注释是旧表且不完整(缺涉政标签),以官方为准。
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 接入地域,SDK 据此解析出 green-cip.cn-shanghai.aliyuncs.com。
aliyunRegionID = "cn-shanghai"
// aliyunTextService 服务场景。选 comment_detection:官方定位「评论/弹幕/公聊/转发」,
// 覆盖涉黄/涉政/涉暴底线与广告导流。三条链路共用且接口层不带场景,
// 若要按场景细分(如私聊改 chat_detection)需给审核入口补 scene 参数。
aliyunTextService = "comment_detection"
// aliyunContentRuneLimit 单次 content 硬上限(官方 600),按 550 切分留安全余量。
aliyunContentRuneLimit = 550
// aliyunChunkOverlapRunes 相邻分片重叠字符数:防止风险词正好落在切分点而被漏判。
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 客户端。设 RegionId 而非 Endpoint:SDK 的 EndpointMap 会解析出
// green-cip.cn-shanghai.aliyuncs.com。超时单位为毫秒。
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 按 rune 切成不超过单次上限的分片。
// 必须切分:2.0 单次硬上限 600 字符,而本站上限为 提问 1000 / 回答·追问·私聊 2000 /
// 投票描述 5000 字;超长直发会被拒,在 fail-closed 下等于「长内容一律发不出去」。
// 分片带重叠以消除「风险词正好跨切分点」的漏判盲区。
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 字符串)。
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 归一化后的可落库响应:在线调用与缓存回放两条路径的唯一输入格式,
// 最终都交给 AliyunTextCensorParser 解析。
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 把多个分片结果合并成一份归一化响应:标签取并集、
// 风险级别取最高(未知级别按最高处理)、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(在线调用与缓存回放共用)。
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 落库的缓存)必须报错,交由 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(如命中尚未映射的新标签),
// 不补会被策略层当成未命中放行,与改造前的 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(取值以官方
// 《文本审核增强版标签处理建议参考》为准;SDK 内联的标签表是旧版且不完整,勿采信)。
// 政治 / 暴恐 / 色情 / 自定义属底线维度,任何档位都拦。
// 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 / no permission 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 首次出现「账号侧未就绪」失败时告警一次(sync.Once 避免刷屏)。
// 这类问题(未开通 / RAM 未授权 / 密钥无效)不会自愈,会永久卡死 fail-closed 的审核链路,
// 表现为「全站内容发不出去」,而日志里原本只是一条普通 ERRO、极易被当成偶发网络抖动。
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.")
})
}