using System; using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using AlibabaCloud.OpenApiClient.Models; using AlibabaCloud.SDK.Green20220302; using AlibabaCloud.SDK.Green20220302.Models; using AlibabaCloud.TeaUtil.Models; using Tea; using NCC.Common.Configuration; using NCC.Dependency; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using OSS = AlibabaCloud.OSS.V2; namespace NCC.Extend { /// /// 图片审核服务(阿里云内容安全),仅内部调用,不暴露 API /// public class ImageModerationService : ITransient { private readonly HttpClient _httpClient; private readonly string _endpoint; private readonly string _accessKeyId; private readonly string _accessKeySecret; private readonly string _region; private readonly bool _enabled; private readonly bool _isEnhancedVersion; // 是否为增强版(green-cip) /// /// 初始化图片审核服务 /// public ImageModerationService() { _httpClient = new HttpClient(); _enabled = App.Configuration["NCC_App:ImageModeration:Enabled"] == "true"; _endpoint = App.Configuration["NCC_App:ImageModeration:Endpoint"] ?? "https://green.cn-shanghai.aliyuncs.com"; _region = App.Configuration["NCC_App:ImageModeration:Region"] ?? "cn-shanghai"; // 优先使用ImageModeration配置,如果没有则使用AliyunOSS的配置 _accessKeyId = App.Configuration["NCC_App:ImageModeration:AccessKeyId"]; if (string.IsNullOrEmpty(_accessKeyId)) { _accessKeyId = App.Configuration["NCC_App:AliyunOSS:AccessKeyId"]; } _accessKeySecret = App.Configuration["NCC_App:ImageModeration:AccessKeySecret"]; if (string.IsNullOrEmpty(_accessKeySecret)) { _accessKeySecret = App.Configuration["NCC_App:AliyunOSS:AccessKeySecret"]; } // 判断是否为增强版(endpoint 包含 green-cip) _isEnhancedVersion = _endpoint.Contains("green-cip"); } /// /// 图片审核结果 /// public class ModerationResult { public bool Passed { get; set; } public string Message { get; set; } public string RawResponse { get; set; } public object Details { get; set; } } /// /// 图片审核(从本地文件路径) /// /// 本地文件路径 /// 审核结果详情 public async Task ScanImageFromFileAsync(string filePath) { if (!_enabled) { return new ModerationResult { Passed = true, Message = "审核未启用,直接通过" }; } if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { return new ModerationResult { Passed = false, Message = "文件不存在或路径为空" }; } try { var imageBytes = await File.ReadAllBytesAsync(filePath); return await ScanImageAsync(imageBytes); } catch (Exception ex) { // 审核异常时,根据配置决定是否通过 var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = $"审核异常:{ex.Message}", RawResponse = ex.ToString() }; } } /// /// 图片审核(从字节数组) /// /// 图片字节数组 /// 审核结果详情 public async Task ScanImageAsync(byte[] imageBytes) { if (!_enabled) { return new ModerationResult { Passed = true, Message = "审核未启用,直接通过" }; } if (imageBytes == null || imageBytes.Length == 0) { return new ModerationResult { Passed = false, Message = "图片数据为空" }; } // 检查 AccessKey 是否配置 if (string.IsNullOrEmpty(_accessKeyId) || string.IsNullOrEmpty(_accessKeySecret)) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = "图片审核服务未配置 AccessKey,无法调用审核接口", RawResponse = "AccessKeyId 或 AccessKeySecret 未配置" }; } try { // 判断是否为增强版,使用不同的调用方式 if (_isEnhancedVersion) { return await ScanImageEnhancedAsync(imageBytes); } else { return await ScanImageLegacyAsync(imageBytes); } } catch (Exception ex) { // 审核异常时,根据配置决定是否通过 var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = $"审核异常:{ex.Message}", RawResponse = ex.ToString() }; } } /// /// 创建增强版内容安全 Client(Green 20220302) /// /// Green Client 实例 private static Client CreateGreenClient(string accessKeyId, string accessKeySecret, string endpoint) { var endpointHost = endpoint?.Replace("https://", "").Replace("http://", "") ?? "green-cip.cn-shanghai.aliyuncs.com"; var config = new Config { AccessKeyId = accessKeyId, AccessKeySecret = accessKeySecret, Endpoint = endpointHost }; return new Client(config); } /// /// 图片审核(增强版 - green-cip) /// 使用官方 SDK:DescribeUploadToken → 上传临时 OSS → ImageModeration /// /// 图片字节数组 /// 审核结果详情 private async Task ScanImageEnhancedAsync(byte[] imageBytes) { try { var client = CreateGreenClient(_accessKeyId, _accessKeySecret, _endpoint); var runtime = new RuntimeOptions(); // 1. 获取临时 OSS 上传凭证 DescribeUploadTokenResponse tokenResponse = null; await Task.Run(() => { tokenResponse = client.DescribeUploadToken(); }).ConfigureAwait(false); if (tokenResponse?.Body?.Code != 200) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = $"获取临时 OSS 凭证失败:{tokenResponse?.Body?.Msg ?? "未知错误"}", RawResponse = tokenResponse != null ? JsonConvert.SerializeObject(tokenResponse.Body) : "null" }; } var tokenData = tokenResponse.Body.Data; if (tokenData == null) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = "临时 OSS 凭证数据为空", RawResponse = JsonConvert.SerializeObject(tokenResponse.Body) }; } var bucketName = tokenData.BucketName; var fileNamePrefix = tokenData.FileNamePrefix ?? ""; var ossEndpoint = tokenData.OssInternetEndPoint ?? ""; if (string.IsNullOrEmpty(ossEndpoint)) ossEndpoint = tokenData.OssInternalEndPoint ?? ""; if (!ossEndpoint.StartsWith("http", StringComparison.OrdinalIgnoreCase)) ossEndpoint = "https://" + ossEndpoint; // 从 token 返回的 OSS endpoint 推导 region,与签名一致(避免 Invalid signing region) var ossRegion = _region; const string ossCnPrefix = "oss-cn-"; var ossCnIdx = ossEndpoint.IndexOf(ossCnPrefix, StringComparison.OrdinalIgnoreCase); if (ossCnIdx >= 0) { var start = ossCnIdx + ossCnPrefix.Length; // "oss-cn-" 后为 "shanghai" 等,region 为 "cn-xxx" var end = ossEndpoint.IndexOf(".", start, StringComparison.Ordinal); var regionSuffix = end > start ? ossEndpoint.Substring(start, end - start) : ossEndpoint.Substring(start); ossRegion = "cn-" + regionSuffix; } // 2. 使用 OSS.V2 + STS 凭证上传图片到临时 OSS var objectName = fileNamePrefix + Guid.NewGuid().ToString("N") + ".jpg"; var ossCfg = OSS.Configuration.LoadDefault(); ossCfg.CredentialsProvider = new OSS.Credentials.StaticCredentialsProvider( tokenData.AccessKeyId, tokenData.AccessKeySecret, tokenData.SecurityToken); ossCfg.Region = ossRegion; ossCfg.Endpoint = ossEndpoint; using (var ossClient = new OSS.Client(ossCfg)) using (var bodyStream = new MemoryStream(imageBytes)) { await ossClient.PutObjectAsync(new OSS.Models.PutObjectRequest { Bucket = bucketName, Key = objectName, Body = bodyStream }).ConfigureAwait(false); } // 3. 调用增强版审核接口 ImageModeration var serviceParameters = new Dictionary { { "ossBucketName", bucketName }, { "ossObjectName", objectName }, { "dataId", Guid.NewGuid().ToString() } }; // 中国区(cn-shanghai 等)使用 baselineCheck,国际区使用 baselineCheck_global var serviceName = _region != null && _region.StartsWith("cn-", StringComparison.OrdinalIgnoreCase) ? "baselineCheck" : "baselineCheck_global"; var request = new ImageModerationRequest { Service = serviceName, ServiceParameters = JsonConvert.SerializeObject(serviceParameters) }; ImageModerationResponse moderationResponse = null; await Task.Run(() => { moderationResponse = client.ImageModerationWithOptions(request, runtime); }).ConfigureAwait(false); var rawResponse = moderationResponse != null ? JsonConvert.SerializeObject(moderationResponse.Body) : "null"; if (moderationResponse?.Body == null) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = "审核接口无响应", RawResponse = rawResponse }; } var code = moderationResponse.Body.Code; if (code != 200) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = moderationResponse.Body.Msg ?? $"审核接口返回错误:{code}", RawResponse = rawResponse, Details = moderationResponse.Body }; } var data = moderationResponse.Body.Data; if (data == null) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = "审核结果数据为空", RawResponse = rawResponse, Details = moderationResponse.Body }; } var results = data.Result; var riskLevel = data.RiskLevel ?? ""; if (results == null || results.Count == 0) { return new ModerationResult { Passed = true, Message = "没有审核结果,默认通过", RawResponse = rawResponse, Details = data }; } var highRiskLabels = new List(); var allResults = new List(); foreach (var item in results) { var label = item.Label ?? ""; var confidence = item.Confidence; var itemRiskLevel = item.RiskLevel ?? ""; var description = item.Description ?? ""; allResults.Add(new { label = label, confidence = confidence, riskLevel = itemRiskLevel, description = description }); if (itemRiskLevel == "high" || (!string.IsNullOrEmpty(label) && !label.StartsWith("nonLabel"))) highRiskLabels.Add($"{label}({description})"); } if (highRiskLabels.Count > 0 || riskLevel == "high") { return new ModerationResult { Passed = false, Message = $"图片审核未通过,风险等级:{riskLevel},违规标签:{string.Join(", ", highRiskLabels)}", RawResponse = rawResponse, Details = new { riskLevel = riskLevel, highRiskLabels = highRiskLabels, allResults = allResults } }; } return new ModerationResult { Passed = true, Message = "图片审核通过", RawResponse = rawResponse, Details = new { riskLevel = riskLevel, allResults = allResults } }; } catch (TeaException teaEx) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; var msg = teaEx.Message ?? "增强版审核 Tea 异常"; if (teaEx.Data != null && teaEx.Data.Contains("Recommend")) msg += ";诊断:" + teaEx.Data["Recommend"]; return new ModerationResult { Passed = !failOnError, Message = msg, RawResponse = teaEx.ToString() }; } catch (Exception ex) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = $"增强版审核异常:{ex.Message}", RawResponse = ex.ToString() }; } } /// /// 图片审核(老版 - green.xxx) /// /// 图片字节数组 /// 审核结果详情 private async Task ScanImageLegacyAsync(byte[] imageBytes) { // 构建请求URL var url = $"{_endpoint}/green/image/scan"; // 构建请求体 var requestBody = new { scenes = new[] { "porn", "terrorism", "ad", "qrcode" }, tasks = new[] { new { dataId = Guid.NewGuid().ToString(), url = Convert.ToBase64String(imageBytes) } } }; var jsonContent = JsonConvert.SerializeObject(requestBody); var contentBytes = Encoding.UTF8.GetBytes(jsonContent); var content = new ByteArrayContent(contentBytes); content.Headers.TryAddWithoutValidation("Content-Type", "application/json"); // 计算 Content-MD5(请求体的 MD5,然后 Base64) string contentMd5; using (var md5 = MD5.Create()) { var hashBytes = md5.ComputeHash(contentBytes); contentMd5 = Convert.ToBase64String(hashBytes); } // 生成请求时间(RFC 1123 格式) var dateStr = DateTime.UtcNow.ToString("r"); // 生成签名随机数 var signatureNonce = Guid.NewGuid().ToString(); // 构建规范化的请求头(x-acs- 开头的头,按字典序排序) var acsHeaders = new SortedDictionary { { "x-acs-signature-method", "HMAC-SHA1" }, { "x-acs-signature-nonce", signatureNonce }, { "x-acs-signature-version", "1.0" }, { "x-acs-version", "2018-05-09" } }; // 构建 CanonicalizedHeaders(格式:key:value\n) var canonicalizedHeaders = new StringBuilder(); foreach (var header in acsHeaders) { canonicalizedHeaders.Append($"{header.Key}:{header.Value}\n"); } // 构建 CanonicalizedResource(请求路径) var uri = new Uri(url); var canonicalizedResource = uri.AbsolutePath; // 构建待签名字符串 var stringToSign = new StringBuilder(); stringToSign.Append("POST\n"); // HTTP-Verb stringToSign.Append("application/json\n"); // Accept stringToSign.Append($"{contentMd5}\n"); // Content-MD5 stringToSign.Append("application/json\n"); // Content-Type stringToSign.Append($"{dateStr}\n"); // Date stringToSign.Append(canonicalizedHeaders.ToString()); // CanonicalizedHeaders stringToSign.Append(canonicalizedResource); // CanonicalizedResource // 计算签名(HMAC-SHA1) string signature; using (var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(_accessKeySecret))) { var signatureBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign.ToString())); signature = Convert.ToBase64String(signatureBytes); } // 构建 Authorization 头 var authorization = $"acs {_accessKeyId}:{signature}"; // 添加 Content-MD5 到内容头(Content-MD5 是内容头,不是请求头) content.Headers.TryAddWithoutValidation("Content-MD5", contentMd5); // 创建请求并添加所有必需的请求头 using (var request = new HttpRequestMessage(HttpMethod.Post, url)) { request.Content = content; // 添加标准 HTTP 头 request.Headers.Add("Accept", "application/json"); request.Headers.Add("Date", dateStr); // 添加阿里云协议头 foreach (var header in acsHeaders) { request.Headers.Add(header.Key, header.Value); } // 添加 Authorization 头 request.Headers.Add("Authorization", authorization); var response = await _httpClient.SendAsync(request); var responseContent = await response.Content.ReadAsStringAsync(); // 保存原始响应 var rawResponse = responseContent; if (!response.IsSuccessStatusCode) { // HTTP请求失败,根据配置决定是否通过 var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = $"HTTP请求失败:{response.StatusCode}", RawResponse = rawResponse }; } // 解析响应 var result = JsonConvert.DeserializeObject(responseContent); if (result == null) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = "响应解析失败", RawResponse = rawResponse }; } // 阿里云业务错误:未开通内容安全、无权限等(code 596 或 success: false) var apiCode = result["code"]?.Value(); var success = result["success"]?.Value(); var apiMsg = result["msg"]?.ToString() ?? ""; if (apiCode == 596 || success == false) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; var friendlyMsg = apiCode == 596 ? "内容安全服务未开通或当前账号无权限,请到阿里云控制台开通「内容安全」并确认当前 AccessKey 有权限" : (apiMsg.Length > 0 ? apiMsg : "审核接口返回失败"); return new ModerationResult { Passed = !failOnError, Message = friendlyMsg, RawResponse = rawResponse, Details = result }; } // 检查审核结果 var data = result["data"] as JArray; if (data == null || data.Count == 0) { var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; return new ModerationResult { Passed = !failOnError, Message = "审核结果数据为空", RawResponse = rawResponse, Details = result }; } var taskResult = data[0]; var results = taskResult["results"] as JArray; if (results == null || results.Count == 0) { return new ModerationResult { Passed = true, Message = "没有审核结果,默认通过", RawResponse = rawResponse, Details = taskResult }; } // 检查所有场景的审核结果 var blockScenes = new List(); var allSuggestions = new List(); foreach (var item in results) { var suggestion = item["suggestion"]?.ToString(); var scene = item["scene"]?.ToString(); allSuggestions.Add(new { scene = scene, suggestion = suggestion, label = item["label"]?.ToString(), rate = item["rate"]?.ToString() }); if (suggestion == "block") { blockScenes.Add(scene ?? "unknown"); } } if (blockScenes.Count > 0) { return new ModerationResult { Passed = false, Message = $"图片审核未通过,违规场景:{string.Join(", ", blockScenes)}", RawResponse = rawResponse, Details = new { blockScenes = blockScenes, allResults = allSuggestions } }; } return new ModerationResult { Passed = true, Message = "图片审核通过", RawResponse = rawResponse, Details = new { allResults = allSuggestions } }; } } } }