diff --git a/antis-ncc-admin/.env.development b/antis-ncc-admin/.env.development index 198955a..6462393 100644 --- a/antis-ncc-admin/.env.development +++ b/antis-ncc-admin/.env.development @@ -2,8 +2,8 @@ VUE_CLI_BABEL_TRANSPILE_MODULES = true # VUE_APP_BASE_API = 'https://erp.lvqianmeiye.com' -VUE_APP_BASE_API = 'http://erp_test.lvqianmeiye.com' -# VUE_APP_BASE_API = 'http://localhost:2011' +# VUE_APP_BASE_API = 'http://erp_test.lvqianmeiye.com' +VUE_APP_BASE_API = 'http://localhost:2011' # VUE_APP_BASE_API = 'http://localhost:2011' VUE_APP_IMG_API = '' VUE_APP_BASE_WSS = 'ws://192.168.110.45:2011/websocket' diff --git a/docs/阿里云图片审核集成方案.md b/docs/阿里云图片审核集成方案.md new file mode 100644 index 0000000..1d8d97e --- /dev/null +++ b/docs/阿里云图片审核集成方案.md @@ -0,0 +1,765 @@ +# 阿里云图片审核集成方案 + +**文档日期**:2026年1月 +**目标**:在图片上传流程中集成阿里云内容安全(图片审核)服务,自动识别涉黄、涉暴等违规内容 + +--- + +## 一、当前上传流程梳理 + +### 1.1 标准文件上传流程(Uploader) + +``` +1. 接收文件(IFormFile) +2. 验证文件类型(AllowFileType) +3. 生成文件路径和文件名(yyyyMMdd_xxx.ext) +4. 【当前】先上传到服务器本地 +5. 【当前】从服务器本地上传到OSS +6. 【当前】OSS上传成功 → 删除本地文件 +7. 【当前】OSS上传失败 → 保留本地文件 +8. 返回URL(OSS成功用OSS URL,失败用本地URL) +``` + +**接口位置**:`FileService.cs` → `Uploader(string type, IFormFile file)` +**接口路径**:`POST /api/File/Uploader/{type}` + +--- + +### 1.2 Base64图片上传流程(UploadBase64Image) + +``` +1. 接收Base64数据 +2. 解析Base64数据(ParseBase64Data) +3. 验证图片格式(IsValidImageFormat) +4. 生成文件路径和文件名 +5. 【当前】直接上传到OSS(不经过本地) +6. 返回OSS访问URL +``` + +**接口位置**:`FileService.cs` → `UploadBase64Image([FromBody] Base64ImageUploadInput input)` +**接口路径**:`POST /api/File/UploadBase64Image` + +**注意**:Base64上传当前是直接上传到OSS,没有本地备份流程。 + +--- + +### 1.3 关键代码位置 + +| 方法 | 位置 | 行数范围 | 说明 | +|------|------|----------|------| +| `Uploader` | `FileService.cs` | 101-159 | 标准文件上传主方法 | +| `UploadBase64Image` | `FileService.cs` | 1114-1209 | Base64图片上传主方法 | +| `UploadFileToLocalThenOSS` | `FileService.cs` | 540-630 | 先本地后OSS上传逻辑 | +| `GetOSSAccessUrl` | `FileService.cs` | 716-780 | 获取OSS访问URL | + +--- + +## 二、阿里云图片审核服务说明 + +### 2.1 服务名称 + +- **服务名称**:内容安全(Content Moderation) +- **产品名称**:阿里云内容安全 +- **API接口**:图片同步检测 `/green/image/scan` + +### 2.2 检测能力 + +| 检测类型 | 说明 | 风险等级 | +|---------|------|----------| +| **涉黄** | 色情、低俗、性感等 | 高 | +| **涉暴** | 暴力、血腥、恐怖等 | 高 | +| **广告** | 二维码、广告文字等 | 中 | +| **违规文字** | OCR识别图片中的文字并审核 | 中 | +| **其他** | 政治敏感、违禁品等 | 高 | + +### 2.3 接口信息 + +**接口地址**:`https://green.cn-shanghai.aliyuncs.com/green/image/scan` + +**请求方式**:POST + +**Content-Type**:`application/json` + +**认证方式**:AccessKey签名认证(与OSS使用相同的AccessKey) + +**响应格式**:JSON + +--- + +## 三、集成方案设计 + +### 3.1 集成位置 + +**最佳集成点**:在**保存到本地之后、上传到OSS之前**进行审核 + +**原因**: +1. ✅ 审核需要图片数据,本地已有文件,可直接读取 +2. ✅ 审核通过后再上传OSS,避免违规内容上传到OSS +3. ✅ 审核不通过时,保留本地文件,不上传OSS +4. ✅ 不影响现有流程,只是增加审核步骤 + +### 3.2 改造后的流程 + +#### 3.2.1 标准文件上传流程(改造后) + +``` +1. 接收文件(IFormFile) +2. 验证文件类型(AllowFileType) +3. 生成文件路径和文件名(yyyyMMdd_xxx.ext) +4. 先上传到服务器本地 +5. 【新增】调用阿里云图片审核接口 +6. 【新增】审核不通过 → 保留本地文件,返回错误提示(不上传OSS) +7. 【新增】审核通过 → 继续流程 +8. 从服务器本地上传到OSS +9. OSS上传成功 → 删除本地文件 +10. OSS上传失败 → 保留本地文件 +11. 返回URL(OSS成功用OSS URL,失败用本地URL) +``` + +#### 3.2.2 Base64图片上传流程(改造后) + +``` +1. 接收Base64数据 +2. 解析Base64数据(ParseBase64Data) +3. 验证图片格式(IsValidImageFormat) +4. 生成文件路径和文件名 +5. 【新增】先保存Base64数据到服务器本地(临时文件) +6. 【新增】调用阿里云图片审核接口 +7. 【新增】审核不通过 → 保留本地临时文件,返回错误提示(不上传OSS) +8. 【新增】审核通过 → 继续流程 +9. 从服务器本地上传到OSS +10. OSS上传成功 → 删除本地临时文件 +11. OSS上传失败 → 保留本地临时文件 +12. 返回OSS访问URL +``` + +--- + +## 四、技术实现方案 + +### 4.1 创建图片审核服务类 + +**文件位置**:`netcore/src/Modularity/System/NCC.System/Service/Common/ImageModerationService.cs` + +**功能**: +- 封装阿里云图片审核API调用 +- 处理审核结果解析 +- 统一异常处理 + +**接口定义**: +```csharp +public interface IImageModerationService +{ + /// + /// 图片审核(同步检测) + /// + /// 图片字节数组 + /// 图片URL(可选,如果提供URL则优先使用URL审核) + /// 审核结果 + Task ScanImageAsync(byte[] imageBytes, string imageUrl = null); + + /// + /// 图片审核(从本地文件路径) + /// + /// 本地文件路径 + /// 审核结果 + Task ScanImageFromFileAsync(string filePath); +} +``` + +**审核结果模型**: +```csharp +public class ImageModerationResult +{ + /// + /// 是否通过审核 + /// + public bool IsPass { get; set; } + + /// + /// 审核建议(pass:通过,review:需要人工审核,block:拒绝) + /// + public string Suggestion { get; set; } + + /// + /// 风险等级(normal:正常,low:低风险,medium:中风险,high:高风险) + /// + public string RiskLevel { get; set; } + + /// + /// 违规类型列表 + /// + public List Labels { get; set; } + + /// + /// 错误信息(审核失败时的错误描述) + /// + public string ErrorMessage { get; set; } + + /// + /// 审核详情(JSON格式的原始响应) + /// + public string Details { get; set; } +} +``` + +--- + +### 4.2 配置项添加 + +**配置文件**:`appsettings.json` + +**新增配置项**: +```json +{ + "NCC_App": { + "AliyunOSS": { + "AccessKeyId": "...", + "AccessKeySecret": "...", + "Endpoint": "...", + "Region": "..." + }, + "ImageModeration": { + "Enabled": true, + "Endpoint": "https://green.cn-shanghai.aliyuncs.com", + "Region": "cn-shanghai", + "AccessKeyId": "", // 如果为空,使用AliyunOSS的AccessKeyId + "AccessKeySecret": "", // 如果为空,使用AliyunOSS的AccessKeySecret + "Scenes": ["porn", "terrorism", "ad", "qrcode", "live", "logo"], // 审核场景 + "SuggestionLevel": "block", // 审核建议级别:pass/review/block + "RiskLevel": "high" // 风险等级阈值:normal/low/medium/high + } + } +} +``` + +**配置说明**: +- `Enabled`:是否启用图片审核(可配置开关) +- `Endpoint`:内容安全服务端点(默认:`https://green.cn-shanghai.aliyuncs.com`) +- `Region`:服务区域(默认:`cn-shanghai`) +- `AccessKeyId/AccessKeySecret`:如果为空,复用OSS的AccessKey +- `Scenes`:审核场景列表 +- `SuggestionLevel`:审核建议级别,`block`表示拒绝,`review`表示需要人工审核,`pass`表示通过 +- `RiskLevel`:风险等级阈值,超过此等级视为违规 + +--- + +### 4.3 服务注册 + +**文件位置**:`Startup.cs` + +**注册代码**: +```csharp +#region 阿里云图片审核 + +var imageModerationEnabled = App.Configuration["NCC_App:ImageModeration:Enabled"] == "true"; +if (imageModerationEnabled) +{ + services.AddScoped(); +} + +#endregion +``` + +--- + +### 4.4 FileService 改造 + +#### 4.4.1 Uploader 方法改造 + +**改造位置**:`FileService.cs` → `Uploader` 方法 + +**改造逻辑**: +```csharp +[HttpPost("Uploader/{type}")] +[AllowAnonymous] +public async Task Uploader(string type, IFormFile file) +{ + // ... 现有代码:验证文件类型、生成路径和文件名 ... + + // 先上传到本地 + var (ossSuccess, localPath, ossPath) = await UploadFileToLocalThenOSS( + file, + _filePath, + ossFilePath, + _fileName, + forceStoreType); + + // 【新增】图片审核逻辑 + var imageModerationEnabled = _configuration["NCC_App:ImageModeration:Enabled"] == "true"; + if (imageModerationEnabled && IsImageFile(fileType)) + { + try + { + var moderationService = _serviceProvider.GetService(); + if (moderationService != null && !string.IsNullOrEmpty(localPath) && File.Exists(localPath)) + { + var moderationResult = await moderationService.ScanImageFromFileAsync(localPath); + + if (!moderationResult.IsPass) + { + // 审核不通过,保留本地文件,返回错误提示(不上传OSS) + throw NCCException.Oh($"图片审核未通过:{moderationResult.ErrorMessage ?? "图片包含违规内容"}"); + } + } + } + catch (NCCException) + { + // 审核失败异常,直接抛出 + throw; + } + catch (Exception ex) + { + // 审核服务异常,根据配置决定是否继续上传(降级策略) + var failOnError = _configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + if (failOnError) + { + throw NCCException.Oh("图片审核服务暂时不可用,请稍后重试"); + } + // 否则继续上传(降级策略) + } + } + + // ... 现有代码:返回URL ... +} +``` + +**关键点**: +- ✅ 审核在本地文件保存之后、OSS上传之前 +- ✅ 审核不通过时保留本地文件,返回错误(不上传OSS) +- ✅ 审核通过后上传OSS,OSS上传成功则删除本地文件 +- ✅ 审核服务异常时,可选择降级策略(继续上传或拒绝上传) + +--- + +#### 4.4.2 UploadBase64Image 方法改造 + +**改造位置**:`FileService.cs` → `UploadBase64Image` 方法 + +**改造逻辑**: +```csharp +[HttpPost("UploadBase64Image")] +[AllowAnonymous] +public async Task UploadBase64Image([FromBody] Base64ImageUploadInput input) +{ + // ... 现有代码:解析Base64、验证格式、生成路径 ... + + // 【新增】先保存Base64数据到本地临时文件 + string tempLocalPath = null; + try + { + var tempLocalDir = Path.Combine(FileVariable.TempFilePath, "moderation"); + if (!Directory.Exists(tempLocalDir)) + { + Directory.CreateDirectory(tempLocalDir); + } + + tempLocalPath = Path.Combine(tempLocalDir, fileName); + await File.WriteAllBytesAsync(tempLocalPath, imageData); + + // 【新增】图片审核逻辑 + var imageModerationEnabled = _configuration["NCC_App:ImageModeration:Enabled"] == "true"; + if (imageModerationEnabled) + { + try + { + var moderationService = _serviceProvider.GetService(); + if (moderationService != null) + { + var moderationResult = await moderationService.ScanImageFromFileAsync(tempLocalPath); + + if (!moderationResult.IsPass) + { + // 审核不通过,保留临时文件,返回错误提示(不上传OSS) + throw NCCException.Oh($"图片审核未通过:{moderationResult.ErrorMessage ?? "图片包含违规内容"}"); + } + } + } + catch (NCCException) + { + throw; + } + catch (Exception ex) + { + // 审核服务异常,根据配置决定是否继续上传(降级策略) + var failOnError = _configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + if (failOnError) + { + throw NCCException.Oh("图片审核服务暂时不可用,请稍后重试"); + } + // 否则继续上传(降级策略) + } + } + + // 从临时文件上传到OSS + using (var stream = new FileStream(tempLocalPath, FileMode.Open)) + { + await _oSSServiceFactory.Create("aliyun").PutObjectAsync(bucketName, ossPath, stream); + } + + // OSS上传成功,删除临时文件 + if (File.Exists(tempLocalPath)) + { + File.Delete(tempLocalPath); + } + + // ... 返回URL ... + } + catch + { + // 异常时清理临时文件 + if (!string.IsNullOrEmpty(tempLocalPath) && File.Exists(tempLocalPath)) + { + try { File.Delete(tempLocalPath); } catch { } + } + throw; + } +} +``` + +**关键点**: +- ✅ Base64上传改为先保存到本地临时文件 +- ✅ 审核通过后再上传到OSS,OSS上传成功则删除临时文件 +- ✅ 审核不通过时保留临时文件,返回错误(不上传OSS) + +--- + +### 4.5 辅助方法 + +#### 4.5.1 IsImageFile 方法 + +**功能**:判断文件是否为图片类型 + +**代码**: +```csharp +[NonAction] +private bool IsImageFile(string fileType) +{ + var imageTypes = new[] { "jpg", "jpeg", "png", "gif", "bmp", "webp" }; + return imageTypes.Contains(fileType.ToLower()); +} +``` + +--- + +## 五、审核策略配置 + +### 5.1 审核建议级别(SuggestionLevel) + +| 级别 | 说明 | 处理方式 | +|------|------|----------| +| `pass` | 通过 | 允许上传 | +| `review` | 需要人工审核 | 可配置:允许上传或拒绝上传 | +| `block` | 拒绝 | 拒绝上传,返回错误 | + +**配置建议**: +- **严格模式**:`SuggestionLevel: "block"`,`block`和`review`都拒绝 +- **宽松模式**:`SuggestionLevel: "review"`,仅`block`拒绝,`review`允许上传 + +--- + +### 5.2 风险等级阈值(RiskLevel) + +| 等级 | 说明 | 处理方式 | +|------|------|----------| +| `normal` | 正常 | 允许上传 | +| `low` | 低风险 | 可配置:允许上传或拒绝上传 | +| `medium` | 中风险 | 可配置:允许上传或拒绝上传 | +| `high` | 高风险 | 拒绝上传 | + +**配置建议**: +- **严格模式**:`RiskLevel: "high"`,仅`high`拒绝 +- **中等模式**:`RiskLevel: "medium"`,`medium`和`high`都拒绝 +- **宽松模式**:`RiskLevel: "low"`,`low`、`medium`、`high`都拒绝 + +--- + +### 5.3 审核场景(Scenes) + +**可选场景**: +- `porn`:涉黄检测 +- `terrorism`:涉暴涉恐检测 +- `ad`:广告检测 +- `qrcode`:二维码检测 +- `live`:不良场景检测 +- `logo`:Logo检测 + +**配置建议**: +```json +"Scenes": ["porn", "terrorism", "ad", "qrcode"] +``` + +--- + +## 六、异常处理策略 + +### 6.1 审核服务异常 + +**场景**:审核服务不可用、网络异常、API调用失败等 + +**处理策略**(可配置): +1. **严格模式**:审核服务异常时拒绝上传 +2. **宽松模式**:审核服务异常时允许上传(推荐) + +**实现**: +```csharp +catch (Exception ex) +{ + // 根据配置决定是否继续上传 + var failOnError = _configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + if (failOnError) + { + throw NCCException.Oh("图片审核服务暂时不可用,请稍后重试"); + } + // 否则继续上传(降级策略) +} +``` + +--- + +### 6.2 审核超时 + +**场景**:审核接口响应时间过长 + +**处理策略**: +- 设置超时时间(如:5秒) +- 超时后根据配置决定:继续上传或拒绝上传 + +**实现**: +```csharp +using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) +{ + try + { + var moderationResult = await moderationService.ScanImageFromFileAsync(localPath) + .WithCancellation(cts.Token); + } + catch (OperationCanceledException) + { + // 超时处理,根据配置决定是否继续上传 + var failOnError = _configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + if (failOnError) + { + throw NCCException.Oh("图片审核超时,请稍后重试"); + } + } +} +``` + +--- + +## 七、性能优化 + +### 7.1 异步审核 + +✅ 已使用 `async/await`,不会阻塞主线程 + +### 7.2 审核缓存(可选) + +**场景**:相同图片重复上传 + +**实现**: +- 计算图片MD5值 +- 缓存审核结果(Redis或内存缓存) +- 相同图片直接使用缓存结果 + +**注意**:需要评估缓存成本和收益 + +--- + +### 7.3 批量审核(可选) + +**场景**:一次上传多张图片 + +**实现**: +- 使用阿里云批量审核接口 `/green/image/batchscan` +- 减少API调用次数 + +**注意**:当前流程是单张上传,暂不需要 + +--- + +## 八、日志记录 + +**说明**:企业内部使用,暂不需要日志记录功能。 + +--- + +## 九、测试方案 + +### 9.1 功能测试 + +1. **正常图片上传**: + - ✅ 审核通过,正常上传到OSS + - ✅ 返回OSS URL + +2. **违规图片上传**: + - ✅ 审核不通过,拒绝上传 + - ✅ 返回错误提示 + - ✅ 本地文件已删除 + +3. **审核服务异常**: + - ✅ 根据配置决定是否继续上传 + +--- + +### 9.2 性能测试 + +1. **审核耗时**: + - 目标:单张图片审核耗时 < 2秒 + - 测试:上传100张图片,统计平均耗时 + +2. **并发测试**: + - 目标:支持10个并发上传 + - 测试:同时上传10张图片 + +--- + +### 9.3 边界测试 + +1. **大图片**: + - 测试:上传10MB图片 + - 验证:审核是否正常 + +2. **小图片**: + - 测试:上传1KB图片 + - 验证:审核是否正常 + +3. **特殊格式**: + - 测试:上传WebP、GIF动图 + - 验证:审核是否支持 + +--- + +## 十、配置清单 + +### 10.1 必需配置 + +| 配置项 | 位置 | 说明 | +|--------|------|------| +| `ImageModeration:Enabled` | `appsettings.json` | 是否启用图片审核 | +| `ImageModeration:Endpoint` | `appsettings.json` | 内容安全服务端点 | +| `ImageModeration:Region` | `appsettings.json` | 服务区域 | +| `ImageModeration:AccessKeyId` | `appsettings.json` | AccessKey ID(可选,可复用OSS) | +| `ImageModeration:AccessKeySecret` | `appsettings.json` | AccessKey Secret(可选,可复用OSS) | + +--- + +### 10.2 可选配置 + +| 配置项 | 默认值 | 说明 | +|--------|--------|------| +| `ImageModeration:Scenes` | `["porn", "terrorism", "ad", "qrcode"]` | 审核场景 | +| `ImageModeration:SuggestionLevel` | `"block"` | 审核建议级别 | +| `ImageModeration:RiskLevel` | `"high"` | 风险等级阈值 | +| `ImageModeration:FailOnError` | `"false"` | 审核服务异常时是否拒绝上传 | +| `ImageModeration:Timeout` | `5` | 审核超时时间(秒) | + +--- + +## 十一、实施步骤 + +### 11.1 第一阶段:基础集成 + +1. ✅ 创建 `ImageModerationService` 服务类 +2. ✅ 添加配置项到 `appsettings.json` +3. ✅ 在 `Startup.cs` 注册服务 +4. ✅ 改造 `Uploader` 方法,集成审核逻辑 +5. ✅ 测试标准文件上传流程 + +**预计工作量**:2-3人天 + +--- + +### 11.2 第二阶段:Base64上传改造 + +1. ✅ 改造 `UploadBase64Image` 方法 +2. ✅ 添加临时文件保存逻辑 +3. ✅ 集成审核逻辑 +4. ✅ 测试Base64上传流程 + +**预计工作量**:1-2人天 + +--- + +### 11.3 第三阶段:优化与完善 + +1. ✅ 优化异常处理 +2. ✅ 性能测试与优化 +3. ✅ 文档完善 + +**预计工作量**:0.5人天 + +--- + +## 十二、风险评估 + +### 12.1 技术风险 + +| 风险 | 影响 | 应对措施 | +|------|------|----------| +| 审核服务不可用 | 高 | 实现降级策略,允许配置是否继续上传 | +| 审核超时 | 中 | 设置超时时间,超时后根据配置决定 | +| 审核误判 | 中 | 提供人工审核机制,支持人工复查 | +| 性能影响 | 低 | 异步审核,不阻塞主流程 | + +--- + +### 12.2 业务风险 + +| 风险 | 影响 | 应对措施 | +|------|------|----------| +| 正常图片被误判 | 中 | 提供申诉机制,人工审核 | +| 违规图片漏检 | 高 | 定期优化审核策略,人工抽检 | +| 审核成本 | 低 | 按量计费,成本可控 | + +--- + +## 十三、后续优化建议 + +### 13.1 人工审核机制(可选) + +- 审核结果为 `review` 时,进入人工审核队列 +- 提供管理后台,支持人工审核 +- 审核通过后允许上传 + +--- + +### 13.2 审核结果统计 + +- 统计审核通过率 +- 统计违规类型分布 +- 生成审核报告 + +--- + +### 13.3 白名单机制 + +- 支持配置白名单(特定用户或IP) +- 白名单用户跳过审核 + +--- + +## 十四、总结 + +### 14.1 集成方案要点 + +1. ✅ **集成位置**:本地保存之后、OSS上传之前 +2. ✅ **审核服务**:创建独立的 `ImageModerationService` 服务类 +3. ✅ **配置灵活**:支持开关、审核级别、异常处理策略等配置 +4. ✅ **降级策略**:审核服务异常时可选择继续上传或拒绝上传 +5. ✅ **文件处理**:审核不通过保留本地文件,审核通过后上传OSS并删除本地文件 + +--- + +### 14.2 改造影响 + +- ✅ **最小化影响**:仅在上传流程中增加审核步骤 +- ✅ **向后兼容**:可通过配置开关控制是否启用审核 +- ✅ **性能可控**:异步审核,不阻塞主流程 + +--- + +**文档版本**:v1.0 +**最后更新**:2026年1月 +**状态**:待实施 diff --git a/netcore/src/Application/NCC.API.Core/Startup.cs b/netcore/src/Application/NCC.API.Core/Startup.cs index d497022..5c65de3 100644 --- a/netcore/src/Application/NCC.API.Core/Startup.cs +++ b/netcore/src/Application/NCC.API.Core/Startup.cs @@ -1,4 +1,4 @@ -using NCC.Common.Cache; +using NCC.Common.Cache; using NCC.Common.Core.Filter; using NCC.Data.SqlSugar.Extensions; using NCC.JsonSerialization; @@ -138,6 +138,13 @@ namespace NCC.API.Core #endregion + #region 阿里云图片审核 + + // 始终注册服务,服务内部会根据配置判断是否启用 + services.AddScoped(); + + #endregion + #region 微信 services.AddSenparcGlobalServices(App.Configuration)//Senparc.CO2NET 全局注册 .AddSenparcWeixinServices(App.Configuration);//Senparc.Weixin 注册(如果使用Senparc.Weixin SDK则添加) diff --git a/netcore/src/Application/NCC.API/appsettings.json b/netcore/src/Application/NCC.API/appsettings.json index 6f55748..5838145 100644 --- a/netcore/src/Application/NCC.API/appsettings.json +++ b/netcore/src/Application/NCC.API/appsettings.json @@ -218,6 +218,14 @@ "Region": "cn-chengdu", "CustomDomain": "https://lvqian-erip.oss-cn-chengdu.aliyuncs.com" }, + "ImageModeration": { + "Enabled": "true", + "Endpoint": "https://green.cn-shanghai.aliyuncs.com", + "Region": "cn-chengdu", + "AccessKeyId": "", + "AccessKeySecret": "", + "FailOnError": "false" + }, //================== 系统错误邮件报告反馈相关 ============================== --> //软件的错误报告 "ErrorReport": "false", diff --git a/netcore/src/Modularity/Extend/NCC.Extend/ImageModerationService.cs b/netcore/src/Modularity/Extend/NCC.Extend/ImageModerationService.cs new file mode 100644 index 0000000..c3d3ef8 --- /dev/null +++ b/netcore/src/Modularity/Extend/NCC.Extend/ImageModerationService.cs @@ -0,0 +1,659 @@ +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 NCC.Common.Configuration; +using NCC.Dependency; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +// 增强版 SDK 暂时注释,改用 HTTP 原生调用 +// using AlibabaCloud.SDK.Green20220302; +// using AlibabaCloud.SDK.Green20220302.Models; +// using AlibabaCloud.OSS.V2; +// using AlibabaCloud.OSS.V2.Models; + +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() + }; + } + } + + /// + /// 图片审核(增强版 - green-cip) + /// 使用 HTTP 原生调用,实现本地图片审核流程: + /// 1. DescribeUploadToken - 获取临时 OSS 凭证 + /// 2. 上传图片到临时 OSS + /// 3. ImageModeration - 调用审核接口 + /// + /// 图片字节数组 + /// 审核结果详情 + private async Task ScanImageEnhancedAsync(byte[] imageBytes) + { + try + { + var endpointHost = _endpoint.Replace("https://", "").Replace("http://", ""); + + // 1. 调用 DescribeUploadToken 获取临时 OSS 凭证 + var tokenUrl = $"{_endpoint}/DescribeUploadToken"; + var tokenResponse = await CallOpenApiAsync(tokenUrl, new Dictionary()); + var tokenResult = JsonConvert.DeserializeObject(tokenResponse); + + if (tokenResult == null || tokenResult["Code"]?.Value() != 200) + { + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + return new ModerationResult + { + Passed = !failOnError, + Message = $"获取临时 OSS 凭证失败:{tokenResult?["Msg"]?.ToString() ?? "未知错误"}", + RawResponse = tokenResponse + }; + } + + var tokenData = tokenResult["Data"]; + if (tokenData == null) + { + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + return new ModerationResult + { + Passed = !failOnError, + Message = "临时 OSS 凭证数据为空", + RawResponse = tokenResponse + }; + } + + var bucketName = tokenData["BucketName"]?.ToString(); + var fileNamePrefix = tokenData["FileNamePrefix"]?.ToString() ?? ""; + var ossAccessKeyId = tokenData["AccessKeyId"]?.ToString(); + var ossAccessKeySecret = tokenData["AccessKeySecret"]?.ToString(); + var ossSecurityToken = tokenData["SecurityToken"]?.ToString(); + var ossInternetEndPoint = tokenData["OssInternetEndPoint"]?.ToString(); + + if (string.IsNullOrEmpty(bucketName) || string.IsNullOrEmpty(ossInternetEndPoint)) + { + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + return new ModerationResult + { + Passed = !failOnError, + Message = "临时 OSS 凭证信息不完整", + RawResponse = tokenResponse + }; + } + + // 2. 上传图片到临时 OSS(使用 OSS PutObject API) + var objectName = $"{fileNamePrefix}{Guid.NewGuid()}.jpg"; + var ossPutUrl = $"https://{bucketName}.{ossInternetEndPoint}/{objectName}"; + + // 使用 STS Token 上传到 OSS(需要 OSS 签名) + var ossPutSuccess = await UploadToOSSTempAsync( + ossPutUrl, + imageBytes, + bucketName, + objectName, + ossAccessKeyId, + ossAccessKeySecret, + ossSecurityToken); + + if (!ossPutSuccess) + { + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + return new ModerationResult + { + Passed = !failOnError, + Message = "上传图片到临时 OSS 失败", + RawResponse = "OSS 上传失败" + }; + } + + // 3. 调用增强版审核接口 ImageModeration + var moderationUrl = $"{_endpoint}/ImageModeration"; + var serviceParameters = new Dictionary + { + { "ossBucketName", bucketName }, + { "ossObjectName", objectName }, + { "dataId", Guid.NewGuid().ToString() } + }; + + var moderationBody = new Dictionary + { + { "Service", "baselineCheck" }, + { "ServiceParameters", JsonConvert.SerializeObject(serviceParameters) } + }; + + var moderationResponse = await CallOpenApiAsync(moderationUrl, moderationBody); + var rawResponse = moderationResponse; + var moderationResult = JsonConvert.DeserializeObject(moderationResponse); + + if (moderationResult == null) + { + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + return new ModerationResult + { + Passed = !failOnError, + Message = "响应解析失败", + RawResponse = rawResponse + }; + } + + var code = moderationResult["Code"]?.Value(); + if (code != 200) + { + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + return new ModerationResult + { + Passed = !failOnError, + Message = moderationResult["Msg"]?.ToString() ?? $"审核接口返回错误:{code}", + RawResponse = rawResponse, + Details = moderationResult + }; + } + + var data = moderationResult["Data"]; + if (data == null) + { + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + return new ModerationResult + { + Passed = !failOnError, + Message = "审核结果数据为空", + RawResponse = rawResponse, + Details = moderationResult + }; + } + + // 增强版返回格式:Data.Result 是数组,每个元素有 Label、Confidence、RiskLevel + var results = data["Result"] as JArray; + var riskLevel = data["RiskLevel"]?.ToString(); + + 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"]?.ToString(); + var confidence = item["Confidence"]?.Value(); + var itemRiskLevel = item["RiskLevel"]?.ToString(); + var description = item["Description"]?.ToString(); + + allResults.Add(new + { + label = label, + confidence = confidence, + riskLevel = itemRiskLevel, + description = description + }); + + // 高风险或非 nonLabel 标签视为违规 + if (itemRiskLevel == "high" || (label != null && !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 (Exception ex) + { + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + return new ModerationResult + { + Passed = !failOnError, + Message = $"增强版审核异常:{ex.Message}", + RawResponse = ex.ToString() + }; + } + } + + /// + /// 调用阿里云 OpenAPI 3.0 接口(增强版使用 POP 签名) + /// 注意:增强版 API 使用 OpenAPI 3.0 风格,签名方式与老版 ROA 不同 + /// 建议使用官方 SDK,HTTP 原生调用需要实现完整的 POP 签名算法 + /// + private async Task CallOpenApiAsync(string url, Dictionary bodyParams) + { + // 增强版使用 OpenAPI 3.0 的 POP 签名,实现较复杂 + // 暂时返回提示,建议使用 SDK 或联系技术支持实现完整签名 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + if (failOnError) + { + throw new Exception("增强版 API 需要实现 OpenAPI 3.0 签名,当前暂不支持 HTTP 原生调用。请使用官方 SDK 或联系技术支持。"); + } + return "{\"Code\":500,\"Msg\":\"增强版 API 需要 SDK 支持,当前暂不支持 HTTP 原生调用\"}"; + } + + /// + /// 上传文件到临时 OSS(使用 STS Token) + /// + private async Task UploadToOSSTempAsync(string url, byte[] data, string bucket, string objectName, string accessKeyId, string accessKeySecret, string securityToken) + { + // OSS PutObject 需要实现签名,使用 STS Token + // 暂时返回 false,建议使用 OSS SDK + return false; + } + + /// + /// 图片审核(老版 - 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 + } + }; + } + } + } +} diff --git a/netcore/src/Modularity/Extend/NCC.Extend/NCC.Extend.csproj b/netcore/src/Modularity/Extend/NCC.Extend/NCC.Extend.csproj index 4351943..2b077fd 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/NCC.Extend.csproj +++ b/netcore/src/Modularity/Extend/NCC.Extend/NCC.Extend.csproj @@ -14,6 +14,8 @@ + + diff --git a/netcore/src/Modularity/System/NCC.System/Service/Common/FileService.cs b/netcore/src/Modularity/System/NCC.System/Service/Common/FileService.cs index 29dbac4..f08c434 100644 --- a/netcore/src/Modularity/System/NCC.System/Service/Common/FileService.cs +++ b/netcore/src/Modularity/System/NCC.System/Service/Common/FileService.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using NCC.Common.Configuration; using NCC.Common.Core.Captcha.General; using NCC.Common.Core.Manager; @@ -41,16 +42,18 @@ namespace NCC.System.Service.Common private readonly IConfiguration _configuration; private readonly IUserManager _userManager; private readonly IOSSServiceFactory _oSSServiceFactory; + private readonly IServiceProvider _serviceProvider; /// /// 初始化一个类型的新实例 /// - public FileService(IGeneralCaptcha captchaHandle, IConfiguration configuration, IUserManager userManager, IOSSServiceFactory oSSServiceFactory) + public FileService(IGeneralCaptcha captchaHandle, IConfiguration configuration, IUserManager userManager, IOSSServiceFactory oSSServiceFactory, IServiceProvider serviceProvider) { _captchaHandle = captchaHandle; _configuration = configuration; _userManager = userManager; _oSSServiceFactory = oSSServiceFactory; + _serviceProvider = serviceProvider; } /// @@ -123,13 +126,160 @@ namespace NCC.System.Service.Common ossFilePath = dateFolder; } - // 先上传到本地,再上传到OSS - var (ossSuccess, localPath, ossPath) = await UploadFileToLocalThenOSS( - file, - _filePath, // 本地存储路径 - ossFilePath, // OSS存储路径 - _fileName, - forceStoreType); + // 先只保存到本地(不上传OSS),用于审核 + string localPath = null; + bool ossSuccess = false; + string ossPath = null; + + // 保存到本地 + var targetPath = forceStoreType == "aliyun-oss" ? FileVariable.TemporaryFilePath : _filePath; + if (!Directory.Exists(targetPath)) + { + Directory.CreateDirectory(targetPath); + } + localPath = Path.Combine(targetPath, _fileName); + using (var localStream = File.Create(localPath)) + { + await file.CopyToAsync(localStream); + } + + // 【新增】图片审核逻辑(仅对图片文件进行审核) + bool moderationExecuted = false; + bool moderationPassed = true; + string moderationMessage = null; + object moderationDetails = null; + string moderationRawResponse = null; + var imageModerationEnabled = _configuration["NCC_App:ImageModeration:Enabled"] == "true"; + + if (IsImageFile(fileType)) + { + if (imageModerationEnabled) + { + try + { + // 通过类型名获取服务(避免直接引用NCC.Extend) + var moderationServiceType = Type.GetType("NCC.Extend.ImageModerationService, NCC.Extend"); + if (moderationServiceType != null) + { + var moderationService = _serviceProvider.GetService(moderationServiceType); + if (moderationService != null) + { + // 使用反射调用方法 + var scanMethod = moderationServiceType.GetMethod("ScanImageFromFileAsync", + global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic); + if (scanMethod != null) + { + // 调用方法并获取Task + var taskObj = scanMethod.Invoke(moderationService, new object[] { localPath }); + if (taskObj != null) + { + // 使用dynamic来await Task + dynamic task = taskObj; + var moderationResult = await task; + moderationExecuted = true; + + // 使用dynamic获取结果属性 + moderationPassed = moderationResult.Passed; + moderationMessage = moderationResult.Message?.ToString(); + moderationRawResponse = moderationResult.RawResponse?.ToString(); + moderationDetails = moderationResult.Details; + + if (!moderationPassed) + { + // 审核不通过,保留本地文件,返回错误提示(不上传OSS) + throw NCCException.Oh(moderationMessage ?? "图片审核未通过:图片包含违规内容"); + } + } + else + { + moderationMessage = "图片审核服务调用失败"; + } + } + else + { + moderationMessage = "图片审核方法未找到"; + } + } + else + { + moderationMessage = "图片审核服务未注册"; + } + } + else + { + moderationMessage = "图片审核服务类型未找到"; + } + } + catch (NCC.FriendlyException.AppFriendlyException) + { + // 审核失败异常,直接抛出 + throw; + } + catch (Exception ex) + { + // 审核服务异常,根据配置决定是否继续上传 + moderationExecuted = true; + moderationMessage = $"图片审核异常:{ex.Message}"; + moderationRawResponse = ex.ToString(); + var failOnError = _configuration["NCC_App:ImageModeration:FailOnError"] == "true"; + if (failOnError) + { + throw NCCException.Oh("图片审核服务暂时不可用,请稍后重试"); + } + // 否则继续上传(降级策略) + moderationPassed = true; // 降级策略:异常时允许上传 + } + } + else + { + moderationMessage = "图片审核未启用"; + } + } + else + { + moderationMessage = "非图片文件,无需审核"; + } + + // 审核通过后,上传OSS(如果需要) + if (forceStoreType == "aliyun-oss") + { + try + { + var bucketName = KeyVariable.BucketName; + ossPath = $"{ossFilePath.TrimEnd('/').TrimEnd('\\')}/{_fileName}"; + + using (var localFileStream = File.OpenRead(localPath)) + { + await _oSSServiceFactory.Create("aliyun").PutObjectAsync(bucketName, ossPath, localFileStream); + } + + ossSuccess = true; + + // OSS上传成功,删除本地文件 + if (File.Exists(localPath)) + { + try + { + File.Delete(localPath); + localPath = null; + } + catch + { + // 删除失败不影响结果 + } + } + } + catch + { + // OSS上传失败,保留本地文件 + ossSuccess = false; + } + } + else + { + // 非OSS类型,本地存储视为成功 + ossSuccess = true; + } // 根据OSS上传结果返回URL string fileUrl; @@ -154,8 +304,25 @@ namespace NCC.System.Service.Common fileUrl = localUrl; } - // 返回格式:name(原始文件名), fileId(生成的文件名), url(OSS地址或本地地址), localUrl(本地存储访问路径), localPath(实际本地文件存储路径) - return new { name = file.FileName, fileId = _fileName, url = fileUrl, localUrl = localUrl, localPath = localPath }; + // 返回格式:name(原始文件名), fileId(生成的文件名), url(OSS地址或本地地址), localUrl(本地存储访问路径), localPath(实际本地文件存储路径), moderation(审核信息) + return new + { + name = file.FileName, + fileId = _fileName, + url = fileUrl, + localUrl = localUrl, + localPath = localPath, + moderation = new + { + enabled = imageModerationEnabled, + executed = moderationExecuted, + passed = moderationPassed, + message = moderationMessage, + isImage = IsImageFile(fileType), + rawResponse = moderationRawResponse, + details = moderationDetails + } + }; } /// @@ -883,6 +1050,18 @@ namespace NCC.System.Service.Common /// /// 文件后缀名 /// + /// + /// 判断文件是否为图片类型 + /// + /// 文件类型(扩展名) + /// 是否为图片 + [NonAction] + private bool IsImageFile(string fileType) + { + var imageTypes = new[] { "jpg", "jpeg", "png", "gif", "bmp", "webp" }; + return imageTypes.Contains(fileType.ToLower()); + } + private bool AllowImageType(string fileExtension) { var allowExtension = KeyVariable.AllowImageType;