554 lines
20 KiB
Go
554 lines
20 KiB
Go
// 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: ®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 按 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.")
|
||
})
|
||
}
|