Commit e486fd7214f90fb8f7340063e6686e4d202603cc

Authored by “wangming”
1 parent 6eab5f32

feat: implement image moderation service for file uploads

- Updated the file upload process to include image moderation before uploading to OSS.
- Added configuration options for enabling/disabling image moderation and specifying the moderation service endpoint.
- Enhanced the FileService to handle local file storage and moderation logic, ensuring compliance with content policies.
- Updated .env and appsettings.json to reflect new configuration settings for the image moderation feature.
antis-ncc-admin/.env.development
... ... @@ -2,8 +2,8 @@
2 2  
3 3 VUE_CLI_BABEL_TRANSPILE_MODULES = true
4 4 # VUE_APP_BASE_API = 'https://erp.lvqianmeiye.com'
5   -VUE_APP_BASE_API = 'http://erp_test.lvqianmeiye.com'
6   -# VUE_APP_BASE_API = 'http://localhost:2011'
  5 +# VUE_APP_BASE_API = 'http://erp_test.lvqianmeiye.com'
  6 +VUE_APP_BASE_API = 'http://localhost:2011'
7 7 # VUE_APP_BASE_API = 'http://localhost:2011'
8 8 VUE_APP_IMG_API = ''
9 9 VUE_APP_BASE_WSS = 'ws://192.168.110.45:2011/websocket'
... ...
docs/阿里云图片审核集成方案.md 0 → 100644
  1 +# 阿里云图片审核集成方案
  2 +
  3 +**文档日期**:2026年1月
  4 +**目标**:在图片上传流程中集成阿里云内容安全(图片审核)服务,自动识别涉黄、涉暴等违规内容
  5 +
  6 +---
  7 +
  8 +## 一、当前上传流程梳理
  9 +
  10 +### 1.1 标准文件上传流程(Uploader)
  11 +
  12 +```
  13 +1. 接收文件(IFormFile)
  14 +2. 验证文件类型(AllowFileType)
  15 +3. 生成文件路径和文件名(yyyyMMdd_xxx.ext)
  16 +4. 【当前】先上传到服务器本地
  17 +5. 【当前】从服务器本地上传到OSS
  18 +6. 【当前】OSS上传成功 → 删除本地文件
  19 +7. 【当前】OSS上传失败 → 保留本地文件
  20 +8. 返回URL(OSS成功用OSS URL,失败用本地URL)
  21 +```
  22 +
  23 +**接口位置**:`FileService.cs` → `Uploader(string type, IFormFile file)`
  24 +**接口路径**:`POST /api/File/Uploader/{type}`
  25 +
  26 +---
  27 +
  28 +### 1.2 Base64图片上传流程(UploadBase64Image)
  29 +
  30 +```
  31 +1. 接收Base64数据
  32 +2. 解析Base64数据(ParseBase64Data)
  33 +3. 验证图片格式(IsValidImageFormat)
  34 +4. 生成文件路径和文件名
  35 +5. 【当前】直接上传到OSS(不经过本地)
  36 +6. 返回OSS访问URL
  37 +```
  38 +
  39 +**接口位置**:`FileService.cs` → `UploadBase64Image([FromBody] Base64ImageUploadInput input)`
  40 +**接口路径**:`POST /api/File/UploadBase64Image`
  41 +
  42 +**注意**:Base64上传当前是直接上传到OSS,没有本地备份流程。
  43 +
  44 +---
  45 +
  46 +### 1.3 关键代码位置
  47 +
  48 +| 方法 | 位置 | 行数范围 | 说明 |
  49 +|------|------|----------|------|
  50 +| `Uploader` | `FileService.cs` | 101-159 | 标准文件上传主方法 |
  51 +| `UploadBase64Image` | `FileService.cs` | 1114-1209 | Base64图片上传主方法 |
  52 +| `UploadFileToLocalThenOSS` | `FileService.cs` | 540-630 | 先本地后OSS上传逻辑 |
  53 +| `GetOSSAccessUrl` | `FileService.cs` | 716-780 | 获取OSS访问URL |
  54 +
  55 +---
  56 +
  57 +## 二、阿里云图片审核服务说明
  58 +
  59 +### 2.1 服务名称
  60 +
  61 +- **服务名称**:内容安全(Content Moderation)
  62 +- **产品名称**:阿里云内容安全
  63 +- **API接口**:图片同步检测 `/green/image/scan`
  64 +
  65 +### 2.2 检测能力
  66 +
  67 +| 检测类型 | 说明 | 风险等级 |
  68 +|---------|------|----------|
  69 +| **涉黄** | 色情、低俗、性感等 | 高 |
  70 +| **涉暴** | 暴力、血腥、恐怖等 | 高 |
  71 +| **广告** | 二维码、广告文字等 | 中 |
  72 +| **违规文字** | OCR识别图片中的文字并审核 | 中 |
  73 +| **其他** | 政治敏感、违禁品等 | 高 |
  74 +
  75 +### 2.3 接口信息
  76 +
  77 +**接口地址**:`https://green.cn-shanghai.aliyuncs.com/green/image/scan`
  78 +
  79 +**请求方式**:POST
  80 +
  81 +**Content-Type**:`application/json`
  82 +
  83 +**认证方式**:AccessKey签名认证(与OSS使用相同的AccessKey)
  84 +
  85 +**响应格式**:JSON
  86 +
  87 +---
  88 +
  89 +## 三、集成方案设计
  90 +
  91 +### 3.1 集成位置
  92 +
  93 +**最佳集成点**:在**保存到本地之后、上传到OSS之前**进行审核
  94 +
  95 +**原因**:
  96 +1. ✅ 审核需要图片数据,本地已有文件,可直接读取
  97 +2. ✅ 审核通过后再上传OSS,避免违规内容上传到OSS
  98 +3. ✅ 审核不通过时,保留本地文件,不上传OSS
  99 +4. ✅ 不影响现有流程,只是增加审核步骤
  100 +
  101 +### 3.2 改造后的流程
  102 +
  103 +#### 3.2.1 标准文件上传流程(改造后)
  104 +
  105 +```
  106 +1. 接收文件(IFormFile)
  107 +2. 验证文件类型(AllowFileType)
  108 +3. 生成文件路径和文件名(yyyyMMdd_xxx.ext)
  109 +4. 先上传到服务器本地
  110 +5. 【新增】调用阿里云图片审核接口
  111 +6. 【新增】审核不通过 → 保留本地文件,返回错误提示(不上传OSS)
  112 +7. 【新增】审核通过 → 继续流程
  113 +8. 从服务器本地上传到OSS
  114 +9. OSS上传成功 → 删除本地文件
  115 +10. OSS上传失败 → 保留本地文件
  116 +11. 返回URL(OSS成功用OSS URL,失败用本地URL)
  117 +```
  118 +
  119 +#### 3.2.2 Base64图片上传流程(改造后)
  120 +
  121 +```
  122 +1. 接收Base64数据
  123 +2. 解析Base64数据(ParseBase64Data)
  124 +3. 验证图片格式(IsValidImageFormat)
  125 +4. 生成文件路径和文件名
  126 +5. 【新增】先保存Base64数据到服务器本地(临时文件)
  127 +6. 【新增】调用阿里云图片审核接口
  128 +7. 【新增】审核不通过 → 保留本地临时文件,返回错误提示(不上传OSS)
  129 +8. 【新增】审核通过 → 继续流程
  130 +9. 从服务器本地上传到OSS
  131 +10. OSS上传成功 → 删除本地临时文件
  132 +11. OSS上传失败 → 保留本地临时文件
  133 +12. 返回OSS访问URL
  134 +```
  135 +
  136 +---
  137 +
  138 +## 四、技术实现方案
  139 +
  140 +### 4.1 创建图片审核服务类
  141 +
  142 +**文件位置**:`netcore/src/Modularity/System/NCC.System/Service/Common/ImageModerationService.cs`
  143 +
  144 +**功能**:
  145 +- 封装阿里云图片审核API调用
  146 +- 处理审核结果解析
  147 +- 统一异常处理
  148 +
  149 +**接口定义**:
  150 +```csharp
  151 +public interface IImageModerationService
  152 +{
  153 + /// <summary>
  154 + /// 图片审核(同步检测)
  155 + /// </summary>
  156 + /// <param name="imageBytes">图片字节数组</param>
  157 + /// <param name="imageUrl">图片URL(可选,如果提供URL则优先使用URL审核)</param>
  158 + /// <returns>审核结果</returns>
  159 + Task<ImageModerationResult> ScanImageAsync(byte[] imageBytes, string imageUrl = null);
  160 +
  161 + /// <summary>
  162 + /// 图片审核(从本地文件路径)
  163 + /// </summary>
  164 + /// <param name="filePath">本地文件路径</param>
  165 + /// <returns>审核结果</returns>
  166 + Task<ImageModerationResult> ScanImageFromFileAsync(string filePath);
  167 +}
  168 +```
  169 +
  170 +**审核结果模型**:
  171 +```csharp
  172 +public class ImageModerationResult
  173 +{
  174 + /// <summary>
  175 + /// 是否通过审核
  176 + /// </summary>
  177 + public bool IsPass { get; set; }
  178 +
  179 + /// <summary>
  180 + /// 审核建议(pass:通过,review:需要人工审核,block:拒绝)
  181 + /// </summary>
  182 + public string Suggestion { get; set; }
  183 +
  184 + /// <summary>
  185 + /// 风险等级(normal:正常,low:低风险,medium:中风险,high:高风险)
  186 + /// </summary>
  187 + public string RiskLevel { get; set; }
  188 +
  189 + /// <summary>
  190 + /// 违规类型列表
  191 + /// </summary>
  192 + public List<string> Labels { get; set; }
  193 +
  194 + /// <summary>
  195 + /// 错误信息(审核失败时的错误描述)
  196 + /// </summary>
  197 + public string ErrorMessage { get; set; }
  198 +
  199 + /// <summary>
  200 + /// 审核详情(JSON格式的原始响应)
  201 + /// </summary>
  202 + public string Details { get; set; }
  203 +}
  204 +```
  205 +
  206 +---
  207 +
  208 +### 4.2 配置项添加
  209 +
  210 +**配置文件**:`appsettings.json`
  211 +
  212 +**新增配置项**:
  213 +```json
  214 +{
  215 + "NCC_App": {
  216 + "AliyunOSS": {
  217 + "AccessKeyId": "...",
  218 + "AccessKeySecret": "...",
  219 + "Endpoint": "...",
  220 + "Region": "..."
  221 + },
  222 + "ImageModeration": {
  223 + "Enabled": true,
  224 + "Endpoint": "https://green.cn-shanghai.aliyuncs.com",
  225 + "Region": "cn-shanghai",
  226 + "AccessKeyId": "", // 如果为空,使用AliyunOSS的AccessKeyId
  227 + "AccessKeySecret": "", // 如果为空,使用AliyunOSS的AccessKeySecret
  228 + "Scenes": ["porn", "terrorism", "ad", "qrcode", "live", "logo"], // 审核场景
  229 + "SuggestionLevel": "block", // 审核建议级别:pass/review/block
  230 + "RiskLevel": "high" // 风险等级阈值:normal/low/medium/high
  231 + }
  232 + }
  233 +}
  234 +```
  235 +
  236 +**配置说明**:
  237 +- `Enabled`:是否启用图片审核(可配置开关)
  238 +- `Endpoint`:内容安全服务端点(默认:`https://green.cn-shanghai.aliyuncs.com`)
  239 +- `Region`:服务区域(默认:`cn-shanghai`)
  240 +- `AccessKeyId/AccessKeySecret`:如果为空,复用OSS的AccessKey
  241 +- `Scenes`:审核场景列表
  242 +- `SuggestionLevel`:审核建议级别,`block`表示拒绝,`review`表示需要人工审核,`pass`表示通过
  243 +- `RiskLevel`:风险等级阈值,超过此等级视为违规
  244 +
  245 +---
  246 +
  247 +### 4.3 服务注册
  248 +
  249 +**文件位置**:`Startup.cs`
  250 +
  251 +**注册代码**:
  252 +```csharp
  253 +#region 阿里云图片审核
  254 +
  255 +var imageModerationEnabled = App.Configuration["NCC_App:ImageModeration:Enabled"] == "true";
  256 +if (imageModerationEnabled)
  257 +{
  258 + services.AddScoped<IImageModerationService, ImageModerationService>();
  259 +}
  260 +
  261 +#endregion
  262 +```
  263 +
  264 +---
  265 +
  266 +### 4.4 FileService 改造
  267 +
  268 +#### 4.4.1 Uploader 方法改造
  269 +
  270 +**改造位置**:`FileService.cs` → `Uploader` 方法
  271 +
  272 +**改造逻辑**:
  273 +```csharp
  274 +[HttpPost("Uploader/{type}")]
  275 +[AllowAnonymous]
  276 +public async Task<dynamic> Uploader(string type, IFormFile file)
  277 +{
  278 + // ... 现有代码:验证文件类型、生成路径和文件名 ...
  279 +
  280 + // 先上传到本地
  281 + var (ossSuccess, localPath, ossPath) = await UploadFileToLocalThenOSS(
  282 + file,
  283 + _filePath,
  284 + ossFilePath,
  285 + _fileName,
  286 + forceStoreType);
  287 +
  288 + // 【新增】图片审核逻辑
  289 + var imageModerationEnabled = _configuration["NCC_App:ImageModeration:Enabled"] == "true";
  290 + if (imageModerationEnabled && IsImageFile(fileType))
  291 + {
  292 + try
  293 + {
  294 + var moderationService = _serviceProvider.GetService<IImageModerationService>();
  295 + if (moderationService != null && !string.IsNullOrEmpty(localPath) && File.Exists(localPath))
  296 + {
  297 + var moderationResult = await moderationService.ScanImageFromFileAsync(localPath);
  298 +
  299 + if (!moderationResult.IsPass)
  300 + {
  301 + // 审核不通过,保留本地文件,返回错误提示(不上传OSS)
  302 + throw NCCException.Oh($"图片审核未通过:{moderationResult.ErrorMessage ?? "图片包含违规内容"}");
  303 + }
  304 + }
  305 + }
  306 + catch (NCCException)
  307 + {
  308 + // 审核失败异常,直接抛出
  309 + throw;
  310 + }
  311 + catch (Exception ex)
  312 + {
  313 + // 审核服务异常,根据配置决定是否继续上传(降级策略)
  314 + var failOnError = _configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  315 + if (failOnError)
  316 + {
  317 + throw NCCException.Oh("图片审核服务暂时不可用,请稍后重试");
  318 + }
  319 + // 否则继续上传(降级策略)
  320 + }
  321 + }
  322 +
  323 + // ... 现有代码:返回URL ...
  324 +}
  325 +```
  326 +
  327 +**关键点**:
  328 +- ✅ 审核在本地文件保存之后、OSS上传之前
  329 +- ✅ 审核不通过时保留本地文件,返回错误(不上传OSS)
  330 +- ✅ 审核通过后上传OSS,OSS上传成功则删除本地文件
  331 +- ✅ 审核服务异常时,可选择降级策略(继续上传或拒绝上传)
  332 +
  333 +---
  334 +
  335 +#### 4.4.2 UploadBase64Image 方法改造
  336 +
  337 +**改造位置**:`FileService.cs` → `UploadBase64Image` 方法
  338 +
  339 +**改造逻辑**:
  340 +```csharp
  341 +[HttpPost("UploadBase64Image")]
  342 +[AllowAnonymous]
  343 +public async Task<dynamic> UploadBase64Image([FromBody] Base64ImageUploadInput input)
  344 +{
  345 + // ... 现有代码:解析Base64、验证格式、生成路径 ...
  346 +
  347 + // 【新增】先保存Base64数据到本地临时文件
  348 + string tempLocalPath = null;
  349 + try
  350 + {
  351 + var tempLocalDir = Path.Combine(FileVariable.TempFilePath, "moderation");
  352 + if (!Directory.Exists(tempLocalDir))
  353 + {
  354 + Directory.CreateDirectory(tempLocalDir);
  355 + }
  356 +
  357 + tempLocalPath = Path.Combine(tempLocalDir, fileName);
  358 + await File.WriteAllBytesAsync(tempLocalPath, imageData);
  359 +
  360 + // 【新增】图片审核逻辑
  361 + var imageModerationEnabled = _configuration["NCC_App:ImageModeration:Enabled"] == "true";
  362 + if (imageModerationEnabled)
  363 + {
  364 + try
  365 + {
  366 + var moderationService = _serviceProvider.GetService<IImageModerationService>();
  367 + if (moderationService != null)
  368 + {
  369 + var moderationResult = await moderationService.ScanImageFromFileAsync(tempLocalPath);
  370 +
  371 + if (!moderationResult.IsPass)
  372 + {
  373 + // 审核不通过,保留临时文件,返回错误提示(不上传OSS)
  374 + throw NCCException.Oh($"图片审核未通过:{moderationResult.ErrorMessage ?? "图片包含违规内容"}");
  375 + }
  376 + }
  377 + }
  378 + catch (NCCException)
  379 + {
  380 + throw;
  381 + }
  382 + catch (Exception ex)
  383 + {
  384 + // 审核服务异常,根据配置决定是否继续上传(降级策略)
  385 + var failOnError = _configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  386 + if (failOnError)
  387 + {
  388 + throw NCCException.Oh("图片审核服务暂时不可用,请稍后重试");
  389 + }
  390 + // 否则继续上传(降级策略)
  391 + }
  392 + }
  393 +
  394 + // 从临时文件上传到OSS
  395 + using (var stream = new FileStream(tempLocalPath, FileMode.Open))
  396 + {
  397 + await _oSSServiceFactory.Create("aliyun").PutObjectAsync(bucketName, ossPath, stream);
  398 + }
  399 +
  400 + // OSS上传成功,删除临时文件
  401 + if (File.Exists(tempLocalPath))
  402 + {
  403 + File.Delete(tempLocalPath);
  404 + }
  405 +
  406 + // ... 返回URL ...
  407 + }
  408 + catch
  409 + {
  410 + // 异常时清理临时文件
  411 + if (!string.IsNullOrEmpty(tempLocalPath) && File.Exists(tempLocalPath))
  412 + {
  413 + try { File.Delete(tempLocalPath); } catch { }
  414 + }
  415 + throw;
  416 + }
  417 +}
  418 +```
  419 +
  420 +**关键点**:
  421 +- ✅ Base64上传改为先保存到本地临时文件
  422 +- ✅ 审核通过后再上传到OSS,OSS上传成功则删除临时文件
  423 +- ✅ 审核不通过时保留临时文件,返回错误(不上传OSS)
  424 +
  425 +---
  426 +
  427 +### 4.5 辅助方法
  428 +
  429 +#### 4.5.1 IsImageFile 方法
  430 +
  431 +**功能**:判断文件是否为图片类型
  432 +
  433 +**代码**:
  434 +```csharp
  435 +[NonAction]
  436 +private bool IsImageFile(string fileType)
  437 +{
  438 + var imageTypes = new[] { "jpg", "jpeg", "png", "gif", "bmp", "webp" };
  439 + return imageTypes.Contains(fileType.ToLower());
  440 +}
  441 +```
  442 +
  443 +---
  444 +
  445 +## 五、审核策略配置
  446 +
  447 +### 5.1 审核建议级别(SuggestionLevel)
  448 +
  449 +| 级别 | 说明 | 处理方式 |
  450 +|------|------|----------|
  451 +| `pass` | 通过 | 允许上传 |
  452 +| `review` | 需要人工审核 | 可配置:允许上传或拒绝上传 |
  453 +| `block` | 拒绝 | 拒绝上传,返回错误 |
  454 +
  455 +**配置建议**:
  456 +- **严格模式**:`SuggestionLevel: "block"`,`block`和`review`都拒绝
  457 +- **宽松模式**:`SuggestionLevel: "review"`,仅`block`拒绝,`review`允许上传
  458 +
  459 +---
  460 +
  461 +### 5.2 风险等级阈值(RiskLevel)
  462 +
  463 +| 等级 | 说明 | 处理方式 |
  464 +|------|------|----------|
  465 +| `normal` | 正常 | 允许上传 |
  466 +| `low` | 低风险 | 可配置:允许上传或拒绝上传 |
  467 +| `medium` | 中风险 | 可配置:允许上传或拒绝上传 |
  468 +| `high` | 高风险 | 拒绝上传 |
  469 +
  470 +**配置建议**:
  471 +- **严格模式**:`RiskLevel: "high"`,仅`high`拒绝
  472 +- **中等模式**:`RiskLevel: "medium"`,`medium`和`high`都拒绝
  473 +- **宽松模式**:`RiskLevel: "low"`,`low`、`medium`、`high`都拒绝
  474 +
  475 +---
  476 +
  477 +### 5.3 审核场景(Scenes)
  478 +
  479 +**可选场景**:
  480 +- `porn`:涉黄检测
  481 +- `terrorism`:涉暴涉恐检测
  482 +- `ad`:广告检测
  483 +- `qrcode`:二维码检测
  484 +- `live`:不良场景检测
  485 +- `logo`:Logo检测
  486 +
  487 +**配置建议**:
  488 +```json
  489 +"Scenes": ["porn", "terrorism", "ad", "qrcode"]
  490 +```
  491 +
  492 +---
  493 +
  494 +## 六、异常处理策略
  495 +
  496 +### 6.1 审核服务异常
  497 +
  498 +**场景**:审核服务不可用、网络异常、API调用失败等
  499 +
  500 +**处理策略**(可配置):
  501 +1. **严格模式**:审核服务异常时拒绝上传
  502 +2. **宽松模式**:审核服务异常时允许上传(推荐)
  503 +
  504 +**实现**:
  505 +```csharp
  506 +catch (Exception ex)
  507 +{
  508 + // 根据配置决定是否继续上传
  509 + var failOnError = _configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  510 + if (failOnError)
  511 + {
  512 + throw NCCException.Oh("图片审核服务暂时不可用,请稍后重试");
  513 + }
  514 + // 否则继续上传(降级策略)
  515 +}
  516 +```
  517 +
  518 +---
  519 +
  520 +### 6.2 审核超时
  521 +
  522 +**场景**:审核接口响应时间过长
  523 +
  524 +**处理策略**:
  525 +- 设置超时时间(如:5秒)
  526 +- 超时后根据配置决定:继续上传或拒绝上传
  527 +
  528 +**实现**:
  529 +```csharp
  530 +using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
  531 +{
  532 + try
  533 + {
  534 + var moderationResult = await moderationService.ScanImageFromFileAsync(localPath)
  535 + .WithCancellation(cts.Token);
  536 + }
  537 + catch (OperationCanceledException)
  538 + {
  539 + // 超时处理,根据配置决定是否继续上传
  540 + var failOnError = _configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  541 + if (failOnError)
  542 + {
  543 + throw NCCException.Oh("图片审核超时,请稍后重试");
  544 + }
  545 + }
  546 +}
  547 +```
  548 +
  549 +---
  550 +
  551 +## 七、性能优化
  552 +
  553 +### 7.1 异步审核
  554 +
  555 +✅ 已使用 `async/await`,不会阻塞主线程
  556 +
  557 +### 7.2 审核缓存(可选)
  558 +
  559 +**场景**:相同图片重复上传
  560 +
  561 +**实现**:
  562 +- 计算图片MD5值
  563 +- 缓存审核结果(Redis或内存缓存)
  564 +- 相同图片直接使用缓存结果
  565 +
  566 +**注意**:需要评估缓存成本和收益
  567 +
  568 +---
  569 +
  570 +### 7.3 批量审核(可选)
  571 +
  572 +**场景**:一次上传多张图片
  573 +
  574 +**实现**:
  575 +- 使用阿里云批量审核接口 `/green/image/batchscan`
  576 +- 减少API调用次数
  577 +
  578 +**注意**:当前流程是单张上传,暂不需要
  579 +
  580 +---
  581 +
  582 +## 八、日志记录
  583 +
  584 +**说明**:企业内部使用,暂不需要日志记录功能。
  585 +
  586 +---
  587 +
  588 +## 九、测试方案
  589 +
  590 +### 9.1 功能测试
  591 +
  592 +1. **正常图片上传**:
  593 + - ✅ 审核通过,正常上传到OSS
  594 + - ✅ 返回OSS URL
  595 +
  596 +2. **违规图片上传**:
  597 + - ✅ 审核不通过,拒绝上传
  598 + - ✅ 返回错误提示
  599 + - ✅ 本地文件已删除
  600 +
  601 +3. **审核服务异常**:
  602 + - ✅ 根据配置决定是否继续上传
  603 +
  604 +---
  605 +
  606 +### 9.2 性能测试
  607 +
  608 +1. **审核耗时**:
  609 + - 目标:单张图片审核耗时 < 2秒
  610 + - 测试:上传100张图片,统计平均耗时
  611 +
  612 +2. **并发测试**:
  613 + - 目标:支持10个并发上传
  614 + - 测试:同时上传10张图片
  615 +
  616 +---
  617 +
  618 +### 9.3 边界测试
  619 +
  620 +1. **大图片**:
  621 + - 测试:上传10MB图片
  622 + - 验证:审核是否正常
  623 +
  624 +2. **小图片**:
  625 + - 测试:上传1KB图片
  626 + - 验证:审核是否正常
  627 +
  628 +3. **特殊格式**:
  629 + - 测试:上传WebP、GIF动图
  630 + - 验证:审核是否支持
  631 +
  632 +---
  633 +
  634 +## 十、配置清单
  635 +
  636 +### 10.1 必需配置
  637 +
  638 +| 配置项 | 位置 | 说明 |
  639 +|--------|------|------|
  640 +| `ImageModeration:Enabled` | `appsettings.json` | 是否启用图片审核 |
  641 +| `ImageModeration:Endpoint` | `appsettings.json` | 内容安全服务端点 |
  642 +| `ImageModeration:Region` | `appsettings.json` | 服务区域 |
  643 +| `ImageModeration:AccessKeyId` | `appsettings.json` | AccessKey ID(可选,可复用OSS) |
  644 +| `ImageModeration:AccessKeySecret` | `appsettings.json` | AccessKey Secret(可选,可复用OSS) |
  645 +
  646 +---
  647 +
  648 +### 10.2 可选配置
  649 +
  650 +| 配置项 | 默认值 | 说明 |
  651 +|--------|--------|------|
  652 +| `ImageModeration:Scenes` | `["porn", "terrorism", "ad", "qrcode"]` | 审核场景 |
  653 +| `ImageModeration:SuggestionLevel` | `"block"` | 审核建议级别 |
  654 +| `ImageModeration:RiskLevel` | `"high"` | 风险等级阈值 |
  655 +| `ImageModeration:FailOnError` | `"false"` | 审核服务异常时是否拒绝上传 |
  656 +| `ImageModeration:Timeout` | `5` | 审核超时时间(秒) |
  657 +
  658 +---
  659 +
  660 +## 十一、实施步骤
  661 +
  662 +### 11.1 第一阶段:基础集成
  663 +
  664 +1. ✅ 创建 `ImageModerationService` 服务类
  665 +2. ✅ 添加配置项到 `appsettings.json`
  666 +3. ✅ 在 `Startup.cs` 注册服务
  667 +4. ✅ 改造 `Uploader` 方法,集成审核逻辑
  668 +5. ✅ 测试标准文件上传流程
  669 +
  670 +**预计工作量**:2-3人天
  671 +
  672 +---
  673 +
  674 +### 11.2 第二阶段:Base64上传改造
  675 +
  676 +1. ✅ 改造 `UploadBase64Image` 方法
  677 +2. ✅ 添加临时文件保存逻辑
  678 +3. ✅ 集成审核逻辑
  679 +4. ✅ 测试Base64上传流程
  680 +
  681 +**预计工作量**:1-2人天
  682 +
  683 +---
  684 +
  685 +### 11.3 第三阶段:优化与完善
  686 +
  687 +1. ✅ 优化异常处理
  688 +2. ✅ 性能测试与优化
  689 +3. ✅ 文档完善
  690 +
  691 +**预计工作量**:0.5人天
  692 +
  693 +---
  694 +
  695 +## 十二、风险评估
  696 +
  697 +### 12.1 技术风险
  698 +
  699 +| 风险 | 影响 | 应对措施 |
  700 +|------|------|----------|
  701 +| 审核服务不可用 | 高 | 实现降级策略,允许配置是否继续上传 |
  702 +| 审核超时 | 中 | 设置超时时间,超时后根据配置决定 |
  703 +| 审核误判 | 中 | 提供人工审核机制,支持人工复查 |
  704 +| 性能影响 | 低 | 异步审核,不阻塞主流程 |
  705 +
  706 +---
  707 +
  708 +### 12.2 业务风险
  709 +
  710 +| 风险 | 影响 | 应对措施 |
  711 +|------|------|----------|
  712 +| 正常图片被误判 | 中 | 提供申诉机制,人工审核 |
  713 +| 违规图片漏检 | 高 | 定期优化审核策略,人工抽检 |
  714 +| 审核成本 | 低 | 按量计费,成本可控 |
  715 +
  716 +---
  717 +
  718 +## 十三、后续优化建议
  719 +
  720 +### 13.1 人工审核机制(可选)
  721 +
  722 +- 审核结果为 `review` 时,进入人工审核队列
  723 +- 提供管理后台,支持人工审核
  724 +- 审核通过后允许上传
  725 +
  726 +---
  727 +
  728 +### 13.2 审核结果统计
  729 +
  730 +- 统计审核通过率
  731 +- 统计违规类型分布
  732 +- 生成审核报告
  733 +
  734 +---
  735 +
  736 +### 13.3 白名单机制
  737 +
  738 +- 支持配置白名单(特定用户或IP)
  739 +- 白名单用户跳过审核
  740 +
  741 +---
  742 +
  743 +## 十四、总结
  744 +
  745 +### 14.1 集成方案要点
  746 +
  747 +1. ✅ **集成位置**:本地保存之后、OSS上传之前
  748 +2. ✅ **审核服务**:创建独立的 `ImageModerationService` 服务类
  749 +3. ✅ **配置灵活**:支持开关、审核级别、异常处理策略等配置
  750 +4. ✅ **降级策略**:审核服务异常时可选择继续上传或拒绝上传
  751 +5. ✅ **文件处理**:审核不通过保留本地文件,审核通过后上传OSS并删除本地文件
  752 +
  753 +---
  754 +
  755 +### 14.2 改造影响
  756 +
  757 +- ✅ **最小化影响**:仅在上传流程中增加审核步骤
  758 +- ✅ **向后兼容**:可通过配置开关控制是否启用审核
  759 +- ✅ **性能可控**:异步审核,不阻塞主流程
  760 +
  761 +---
  762 +
  763 +**文档版本**:v1.0
  764 +**最后更新**:2026年1月
  765 +**状态**:待实施
... ...
netcore/src/Application/NCC.API.Core/Startup.cs
1   -using NCC.Common.Cache;
  1 +using NCC.Common.Cache;
2 2 using NCC.Common.Core.Filter;
3 3 using NCC.Data.SqlSugar.Extensions;
4 4 using NCC.JsonSerialization;
... ... @@ -138,6 +138,13 @@ namespace NCC.API.Core
138 138  
139 139 #endregion
140 140  
  141 + #region 阿里云图片审核
  142 +
  143 + // 始终注册服务,服务内部会根据配置判断是否启用
  144 + services.AddScoped<NCC.Extend.ImageModerationService>();
  145 +
  146 + #endregion
  147 +
141 148 #region 微信
142 149 services.AddSenparcGlobalServices(App.Configuration)//Senparc.CO2NET 全局注册
143 150 .AddSenparcWeixinServices(App.Configuration);//Senparc.Weixin 注册(如果使用Senparc.Weixin SDK则添加)
... ...
netcore/src/Application/NCC.API/appsettings.json
... ... @@ -218,6 +218,14 @@
218 218 "Region": "cn-chengdu",
219 219 "CustomDomain": "https://lvqian-erip.oss-cn-chengdu.aliyuncs.com"
220 220 },
  221 + "ImageModeration": {
  222 + "Enabled": "true",
  223 + "Endpoint": "https://green.cn-shanghai.aliyuncs.com",
  224 + "Region": "cn-chengdu",
  225 + "AccessKeyId": "",
  226 + "AccessKeySecret": "",
  227 + "FailOnError": "false"
  228 + },
221 229 //================== 系统错误邮件报告反馈相关 ============================== -->
222 230 //软件的错误报告
223 231 "ErrorReport": "false",
... ...
netcore/src/Modularity/Extend/NCC.Extend/ImageModerationService.cs 0 → 100644
  1 +using System;
  2 +using System.Collections.Generic;
  3 +using System.IO;
  4 +using System.Net.Http;
  5 +using System.Security.Cryptography;
  6 +using System.Text;
  7 +using System.Threading.Tasks;
  8 +using NCC.Common.Configuration;
  9 +using NCC.Dependency;
  10 +using Newtonsoft.Json;
  11 +using Newtonsoft.Json.Linq;
  12 +// 增强版 SDK 暂时注释,改用 HTTP 原生调用
  13 +// using AlibabaCloud.SDK.Green20220302;
  14 +// using AlibabaCloud.SDK.Green20220302.Models;
  15 +// using AlibabaCloud.OSS.V2;
  16 +// using AlibabaCloud.OSS.V2.Models;
  17 +
  18 +namespace NCC.Extend
  19 +{
  20 + /// <summary>
  21 + /// 图片审核服务(阿里云内容安全),仅内部调用,不暴露 API
  22 + /// </summary>
  23 + public class ImageModerationService : ITransient
  24 + {
  25 + private readonly HttpClient _httpClient;
  26 + private readonly string _endpoint;
  27 + private readonly string _accessKeyId;
  28 + private readonly string _accessKeySecret;
  29 + private readonly string _region;
  30 + private readonly bool _enabled;
  31 + private readonly bool _isEnhancedVersion; // 是否为增强版(green-cip)
  32 +
  33 + /// <summary>
  34 + /// 初始化图片审核服务
  35 + /// </summary>
  36 + public ImageModerationService()
  37 + {
  38 + _httpClient = new HttpClient();
  39 + _enabled = App.Configuration["NCC_App:ImageModeration:Enabled"] == "true";
  40 + _endpoint = App.Configuration["NCC_App:ImageModeration:Endpoint"]
  41 + ?? "https://green.cn-shanghai.aliyuncs.com";
  42 + _region = App.Configuration["NCC_App:ImageModeration:Region"]
  43 + ?? "cn-shanghai";
  44 +
  45 + // 优先使用ImageModeration配置,如果没有则使用AliyunOSS的配置
  46 + _accessKeyId = App.Configuration["NCC_App:ImageModeration:AccessKeyId"];
  47 + if (string.IsNullOrEmpty(_accessKeyId))
  48 + {
  49 + _accessKeyId = App.Configuration["NCC_App:AliyunOSS:AccessKeyId"];
  50 + }
  51 +
  52 + _accessKeySecret = App.Configuration["NCC_App:ImageModeration:AccessKeySecret"];
  53 + if (string.IsNullOrEmpty(_accessKeySecret))
  54 + {
  55 + _accessKeySecret = App.Configuration["NCC_App:AliyunOSS:AccessKeySecret"];
  56 + }
  57 +
  58 + // 判断是否为增强版(endpoint 包含 green-cip)
  59 + _isEnhancedVersion = _endpoint.Contains("green-cip");
  60 + }
  61 +
  62 + /// <summary>
  63 + /// 图片审核结果
  64 + /// </summary>
  65 + public class ModerationResult
  66 + {
  67 + public bool Passed { get; set; }
  68 + public string Message { get; set; }
  69 + public string RawResponse { get; set; }
  70 + public object Details { get; set; }
  71 + }
  72 +
  73 + /// <summary>
  74 + /// 图片审核(从本地文件路径)
  75 + /// </summary>
  76 + /// <param name="filePath">本地文件路径</param>
  77 + /// <returns>审核结果详情</returns>
  78 + public async Task<ModerationResult> ScanImageFromFileAsync(string filePath)
  79 + {
  80 + if (!_enabled)
  81 + {
  82 + return new ModerationResult
  83 + {
  84 + Passed = true,
  85 + Message = "审核未启用,直接通过"
  86 + };
  87 + }
  88 +
  89 + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
  90 + {
  91 + return new ModerationResult
  92 + {
  93 + Passed = false,
  94 + Message = "文件不存在或路径为空"
  95 + };
  96 + }
  97 +
  98 + try
  99 + {
  100 + var imageBytes = await File.ReadAllBytesAsync(filePath);
  101 + return await ScanImageAsync(imageBytes);
  102 + }
  103 + catch (Exception ex)
  104 + {
  105 + // 审核异常时,根据配置决定是否通过
  106 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  107 + return new ModerationResult
  108 + {
  109 + Passed = !failOnError,
  110 + Message = $"审核异常:{ex.Message}",
  111 + RawResponse = ex.ToString()
  112 + };
  113 + }
  114 + }
  115 +
  116 + /// <summary>
  117 + /// 图片审核(从字节数组)
  118 + /// </summary>
  119 + /// <param name="imageBytes">图片字节数组</param>
  120 + /// <returns>审核结果详情</returns>
  121 + public async Task<ModerationResult> ScanImageAsync(byte[] imageBytes)
  122 + {
  123 + if (!_enabled)
  124 + {
  125 + return new ModerationResult
  126 + {
  127 + Passed = true,
  128 + Message = "审核未启用,直接通过"
  129 + };
  130 + }
  131 +
  132 + if (imageBytes == null || imageBytes.Length == 0)
  133 + {
  134 + return new ModerationResult
  135 + {
  136 + Passed = false,
  137 + Message = "图片数据为空"
  138 + };
  139 + }
  140 +
  141 + // 检查 AccessKey 是否配置
  142 + if (string.IsNullOrEmpty(_accessKeyId) || string.IsNullOrEmpty(_accessKeySecret))
  143 + {
  144 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  145 + return new ModerationResult
  146 + {
  147 + Passed = !failOnError,
  148 + Message = "图片审核服务未配置 AccessKey,无法调用审核接口",
  149 + RawResponse = "AccessKeyId 或 AccessKeySecret 未配置"
  150 + };
  151 + }
  152 +
  153 + try
  154 + {
  155 + // 判断是否为增强版,使用不同的调用方式
  156 + if (_isEnhancedVersion)
  157 + {
  158 + return await ScanImageEnhancedAsync(imageBytes);
  159 + }
  160 + else
  161 + {
  162 + return await ScanImageLegacyAsync(imageBytes);
  163 + }
  164 + }
  165 + catch (Exception ex)
  166 + {
  167 + // 审核异常时,根据配置决定是否通过
  168 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  169 + return new ModerationResult
  170 + {
  171 + Passed = !failOnError,
  172 + Message = $"审核异常:{ex.Message}",
  173 + RawResponse = ex.ToString()
  174 + };
  175 + }
  176 + }
  177 +
  178 + /// <summary>
  179 + /// 图片审核(增强版 - green-cip)
  180 + /// 使用 HTTP 原生调用,实现本地图片审核流程:
  181 + /// 1. DescribeUploadToken - 获取临时 OSS 凭证
  182 + /// 2. 上传图片到临时 OSS
  183 + /// 3. ImageModeration - 调用审核接口
  184 + /// </summary>
  185 + /// <param name="imageBytes">图片字节数组</param>
  186 + /// <returns>审核结果详情</returns>
  187 + private async Task<ModerationResult> ScanImageEnhancedAsync(byte[] imageBytes)
  188 + {
  189 + try
  190 + {
  191 + var endpointHost = _endpoint.Replace("https://", "").Replace("http://", "");
  192 +
  193 + // 1. 调用 DescribeUploadToken 获取临时 OSS 凭证
  194 + var tokenUrl = $"{_endpoint}/DescribeUploadToken";
  195 + var tokenResponse = await CallOpenApiAsync(tokenUrl, new Dictionary<string, string>());
  196 + var tokenResult = JsonConvert.DeserializeObject<JObject>(tokenResponse);
  197 +
  198 + if (tokenResult == null || tokenResult["Code"]?.Value<int>() != 200)
  199 + {
  200 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  201 + return new ModerationResult
  202 + {
  203 + Passed = !failOnError,
  204 + Message = $"获取临时 OSS 凭证失败:{tokenResult?["Msg"]?.ToString() ?? "未知错误"}",
  205 + RawResponse = tokenResponse
  206 + };
  207 + }
  208 +
  209 + var tokenData = tokenResult["Data"];
  210 + if (tokenData == null)
  211 + {
  212 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  213 + return new ModerationResult
  214 + {
  215 + Passed = !failOnError,
  216 + Message = "临时 OSS 凭证数据为空",
  217 + RawResponse = tokenResponse
  218 + };
  219 + }
  220 +
  221 + var bucketName = tokenData["BucketName"]?.ToString();
  222 + var fileNamePrefix = tokenData["FileNamePrefix"]?.ToString() ?? "";
  223 + var ossAccessKeyId = tokenData["AccessKeyId"]?.ToString();
  224 + var ossAccessKeySecret = tokenData["AccessKeySecret"]?.ToString();
  225 + var ossSecurityToken = tokenData["SecurityToken"]?.ToString();
  226 + var ossInternetEndPoint = tokenData["OssInternetEndPoint"]?.ToString();
  227 +
  228 + if (string.IsNullOrEmpty(bucketName) || string.IsNullOrEmpty(ossInternetEndPoint))
  229 + {
  230 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  231 + return new ModerationResult
  232 + {
  233 + Passed = !failOnError,
  234 + Message = "临时 OSS 凭证信息不完整",
  235 + RawResponse = tokenResponse
  236 + };
  237 + }
  238 +
  239 + // 2. 上传图片到临时 OSS(使用 OSS PutObject API)
  240 + var objectName = $"{fileNamePrefix}{Guid.NewGuid()}.jpg";
  241 + var ossPutUrl = $"https://{bucketName}.{ossInternetEndPoint}/{objectName}";
  242 +
  243 + // 使用 STS Token 上传到 OSS(需要 OSS 签名)
  244 + var ossPutSuccess = await UploadToOSSTempAsync(
  245 + ossPutUrl,
  246 + imageBytes,
  247 + bucketName,
  248 + objectName,
  249 + ossAccessKeyId,
  250 + ossAccessKeySecret,
  251 + ossSecurityToken);
  252 +
  253 + if (!ossPutSuccess)
  254 + {
  255 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  256 + return new ModerationResult
  257 + {
  258 + Passed = !failOnError,
  259 + Message = "上传图片到临时 OSS 失败",
  260 + RawResponse = "OSS 上传失败"
  261 + };
  262 + }
  263 +
  264 + // 3. 调用增强版审核接口 ImageModeration
  265 + var moderationUrl = $"{_endpoint}/ImageModeration";
  266 + var serviceParameters = new Dictionary<string, object>
  267 + {
  268 + { "ossBucketName", bucketName },
  269 + { "ossObjectName", objectName },
  270 + { "dataId", Guid.NewGuid().ToString() }
  271 + };
  272 +
  273 + var moderationBody = new Dictionary<string, string>
  274 + {
  275 + { "Service", "baselineCheck" },
  276 + { "ServiceParameters", JsonConvert.SerializeObject(serviceParameters) }
  277 + };
  278 +
  279 + var moderationResponse = await CallOpenApiAsync(moderationUrl, moderationBody);
  280 + var rawResponse = moderationResponse;
  281 + var moderationResult = JsonConvert.DeserializeObject<JObject>(moderationResponse);
  282 +
  283 + if (moderationResult == null)
  284 + {
  285 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  286 + return new ModerationResult
  287 + {
  288 + Passed = !failOnError,
  289 + Message = "响应解析失败",
  290 + RawResponse = rawResponse
  291 + };
  292 + }
  293 +
  294 + var code = moderationResult["Code"]?.Value<int>();
  295 + if (code != 200)
  296 + {
  297 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  298 + return new ModerationResult
  299 + {
  300 + Passed = !failOnError,
  301 + Message = moderationResult["Msg"]?.ToString() ?? $"审核接口返回错误:{code}",
  302 + RawResponse = rawResponse,
  303 + Details = moderationResult
  304 + };
  305 + }
  306 +
  307 + var data = moderationResult["Data"];
  308 + if (data == null)
  309 + {
  310 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  311 + return new ModerationResult
  312 + {
  313 + Passed = !failOnError,
  314 + Message = "审核结果数据为空",
  315 + RawResponse = rawResponse,
  316 + Details = moderationResult
  317 + };
  318 + }
  319 +
  320 + // 增强版返回格式:Data.Result 是数组,每个元素有 Label、Confidence、RiskLevel
  321 + var results = data["Result"] as JArray;
  322 + var riskLevel = data["RiskLevel"]?.ToString();
  323 +
  324 + if (results == null || results.Count == 0)
  325 + {
  326 + return new ModerationResult
  327 + {
  328 + Passed = true,
  329 + Message = "没有审核结果,默认通过",
  330 + RawResponse = rawResponse,
  331 + Details = data
  332 + };
  333 + }
  334 +
  335 + // 检查风险等级和标签
  336 + var highRiskLabels = new List<string>();
  337 + var allResults = new List<object>();
  338 +
  339 + foreach (var item in results)
  340 + {
  341 + var label = item["Label"]?.ToString();
  342 + var confidence = item["Confidence"]?.Value<double?>();
  343 + var itemRiskLevel = item["RiskLevel"]?.ToString();
  344 + var description = item["Description"]?.ToString();
  345 +
  346 + allResults.Add(new
  347 + {
  348 + label = label,
  349 + confidence = confidence,
  350 + riskLevel = itemRiskLevel,
  351 + description = description
  352 + });
  353 +
  354 + // 高风险或非 nonLabel 标签视为违规
  355 + if (itemRiskLevel == "high" || (label != null && !label.StartsWith("nonLabel")))
  356 + {
  357 + highRiskLabels.Add($"{label}({description})");
  358 + }
  359 + }
  360 +
  361 + if (highRiskLabels.Count > 0 || riskLevel == "high")
  362 + {
  363 + return new ModerationResult
  364 + {
  365 + Passed = false,
  366 + Message = $"图片审核未通过,风险等级:{riskLevel},违规标签:{string.Join(", ", highRiskLabels)}",
  367 + RawResponse = rawResponse,
  368 + Details = new
  369 + {
  370 + riskLevel = riskLevel,
  371 + highRiskLabels = highRiskLabels,
  372 + allResults = allResults
  373 + }
  374 + };
  375 + }
  376 +
  377 + return new ModerationResult
  378 + {
  379 + Passed = true,
  380 + Message = "图片审核通过",
  381 + RawResponse = rawResponse,
  382 + Details = new
  383 + {
  384 + riskLevel = riskLevel,
  385 + allResults = allResults
  386 + }
  387 + };
  388 + }
  389 + catch (Exception ex)
  390 + {
  391 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  392 + return new ModerationResult
  393 + {
  394 + Passed = !failOnError,
  395 + Message = $"增强版审核异常:{ex.Message}",
  396 + RawResponse = ex.ToString()
  397 + };
  398 + }
  399 + }
  400 +
  401 + /// <summary>
  402 + /// 调用阿里云 OpenAPI 3.0 接口(增强版使用 POP 签名)
  403 + /// 注意:增强版 API 使用 OpenAPI 3.0 风格,签名方式与老版 ROA 不同
  404 + /// 建议使用官方 SDK,HTTP 原生调用需要实现完整的 POP 签名算法
  405 + /// </summary>
  406 + private async Task<string> CallOpenApiAsync(string url, Dictionary<string, string> bodyParams)
  407 + {
  408 + // 增强版使用 OpenAPI 3.0 的 POP 签名,实现较复杂
  409 + // 暂时返回提示,建议使用 SDK 或联系技术支持实现完整签名
  410 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  411 + if (failOnError)
  412 + {
  413 + throw new Exception("增强版 API 需要实现 OpenAPI 3.0 签名,当前暂不支持 HTTP 原生调用。请使用官方 SDK 或联系技术支持。");
  414 + }
  415 + return "{\"Code\":500,\"Msg\":\"增强版 API 需要 SDK 支持,当前暂不支持 HTTP 原生调用\"}";
  416 + }
  417 +
  418 + /// <summary>
  419 + /// 上传文件到临时 OSS(使用 STS Token)
  420 + /// </summary>
  421 + private async Task<bool> UploadToOSSTempAsync(string url, byte[] data, string bucket, string objectName, string accessKeyId, string accessKeySecret, string securityToken)
  422 + {
  423 + // OSS PutObject 需要实现签名,使用 STS Token
  424 + // 暂时返回 false,建议使用 OSS SDK
  425 + return false;
  426 + }
  427 +
  428 + /// <summary>
  429 + /// 图片审核(老版 - green.xxx)
  430 + /// </summary>
  431 + /// <param name="imageBytes">图片字节数组</param>
  432 + /// <returns>审核结果详情</returns>
  433 + private async Task<ModerationResult> ScanImageLegacyAsync(byte[] imageBytes)
  434 + {
  435 + // 构建请求URL
  436 + var url = $"{_endpoint}/green/image/scan";
  437 +
  438 + // 构建请求体
  439 + var requestBody = new
  440 + {
  441 + scenes = new[] { "porn", "terrorism", "ad", "qrcode" },
  442 + tasks = new[]
  443 + {
  444 + new
  445 + {
  446 + dataId = Guid.NewGuid().ToString(),
  447 + url = Convert.ToBase64String(imageBytes)
  448 + }
  449 + }
  450 + };
  451 +
  452 + var jsonContent = JsonConvert.SerializeObject(requestBody);
  453 + var contentBytes = Encoding.UTF8.GetBytes(jsonContent);
  454 + var content = new ByteArrayContent(contentBytes);
  455 + content.Headers.TryAddWithoutValidation("Content-Type", "application/json");
  456 +
  457 + // 计算 Content-MD5(请求体的 MD5,然后 Base64)
  458 + string contentMd5;
  459 + using (var md5 = MD5.Create())
  460 + {
  461 + var hashBytes = md5.ComputeHash(contentBytes);
  462 + contentMd5 = Convert.ToBase64String(hashBytes);
  463 + }
  464 +
  465 + // 生成请求时间(RFC 1123 格式)
  466 + var dateStr = DateTime.UtcNow.ToString("r");
  467 +
  468 + // 生成签名随机数
  469 + var signatureNonce = Guid.NewGuid().ToString();
  470 +
  471 + // 构建规范化的请求头(x-acs- 开头的头,按字典序排序)
  472 + var acsHeaders = new SortedDictionary<string, string>
  473 + {
  474 + { "x-acs-signature-method", "HMAC-SHA1" },
  475 + { "x-acs-signature-nonce", signatureNonce },
  476 + { "x-acs-signature-version", "1.0" },
  477 + { "x-acs-version", "2018-05-09" }
  478 + };
  479 +
  480 + // 构建 CanonicalizedHeaders(格式:key:value\n)
  481 + var canonicalizedHeaders = new StringBuilder();
  482 + foreach (var header in acsHeaders)
  483 + {
  484 + canonicalizedHeaders.Append($"{header.Key}:{header.Value}\n");
  485 + }
  486 +
  487 + // 构建 CanonicalizedResource(请求路径)
  488 + var uri = new Uri(url);
  489 + var canonicalizedResource = uri.AbsolutePath;
  490 +
  491 + // 构建待签名字符串
  492 + var stringToSign = new StringBuilder();
  493 + stringToSign.Append("POST\n"); // HTTP-Verb
  494 + stringToSign.Append("application/json\n"); // Accept
  495 + stringToSign.Append($"{contentMd5}\n"); // Content-MD5
  496 + stringToSign.Append("application/json\n"); // Content-Type
  497 + stringToSign.Append($"{dateStr}\n"); // Date
  498 + stringToSign.Append(canonicalizedHeaders.ToString()); // CanonicalizedHeaders
  499 + stringToSign.Append(canonicalizedResource); // CanonicalizedResource
  500 +
  501 + // 计算签名(HMAC-SHA1)
  502 + string signature;
  503 + using (var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(_accessKeySecret)))
  504 + {
  505 + var signatureBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign.ToString()));
  506 + signature = Convert.ToBase64String(signatureBytes);
  507 + }
  508 +
  509 + // 构建 Authorization 头
  510 + var authorization = $"acs {_accessKeyId}:{signature}";
  511 +
  512 + // 添加 Content-MD5 到内容头(Content-MD5 是内容头,不是请求头)
  513 + content.Headers.TryAddWithoutValidation("Content-MD5", contentMd5);
  514 +
  515 + // 创建请求并添加所有必需的请求头
  516 + using (var request = new HttpRequestMessage(HttpMethod.Post, url))
  517 + {
  518 + request.Content = content;
  519 +
  520 + // 添加标准 HTTP 头
  521 + request.Headers.Add("Accept", "application/json");
  522 + request.Headers.Add("Date", dateStr);
  523 +
  524 + // 添加阿里云协议头
  525 + foreach (var header in acsHeaders)
  526 + {
  527 + request.Headers.Add(header.Key, header.Value);
  528 + }
  529 +
  530 + // 添加 Authorization 头
  531 + request.Headers.Add("Authorization", authorization);
  532 +
  533 + var response = await _httpClient.SendAsync(request);
  534 + var responseContent = await response.Content.ReadAsStringAsync();
  535 +
  536 + // 保存原始响应
  537 + var rawResponse = responseContent;
  538 +
  539 + if (!response.IsSuccessStatusCode)
  540 + {
  541 + // HTTP请求失败,根据配置决定是否通过
  542 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  543 + return new ModerationResult
  544 + {
  545 + Passed = !failOnError,
  546 + Message = $"HTTP请求失败:{response.StatusCode}",
  547 + RawResponse = rawResponse
  548 + };
  549 + }
  550 +
  551 + // 解析响应
  552 + var result = JsonConvert.DeserializeObject<JObject>(responseContent);
  553 + if (result == null)
  554 + {
  555 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  556 + return new ModerationResult
  557 + {
  558 + Passed = !failOnError,
  559 + Message = "响应解析失败",
  560 + RawResponse = rawResponse
  561 + };
  562 + }
  563 +
  564 + // 阿里云业务错误:未开通内容安全、无权限等(code 596 或 success: false)
  565 + var apiCode = result["code"]?.Value<int>();
  566 + var success = result["success"]?.Value<bool>();
  567 + var apiMsg = result["msg"]?.ToString() ?? "";
  568 + if (apiCode == 596 || success == false)
  569 + {
  570 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  571 + var friendlyMsg = apiCode == 596
  572 + ? "内容安全服务未开通或当前账号无权限,请到阿里云控制台开通「内容安全」并确认当前 AccessKey 有权限"
  573 + : (apiMsg.Length > 0 ? apiMsg : "审核接口返回失败");
  574 + return new ModerationResult
  575 + {
  576 + Passed = !failOnError,
  577 + Message = friendlyMsg,
  578 + RawResponse = rawResponse,
  579 + Details = result
  580 + };
  581 + }
  582 +
  583 + // 检查审核结果
  584 + var data = result["data"] as JArray;
  585 + if (data == null || data.Count == 0)
  586 + {
  587 + var failOnError = App.Configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  588 + return new ModerationResult
  589 + {
  590 + Passed = !failOnError,
  591 + Message = "审核结果数据为空",
  592 + RawResponse = rawResponse,
  593 + Details = result
  594 + };
  595 + }
  596 +
  597 + var taskResult = data[0];
  598 + var results = taskResult["results"] as JArray;
  599 + if (results == null || results.Count == 0)
  600 + {
  601 + return new ModerationResult
  602 + {
  603 + Passed = true,
  604 + Message = "没有审核结果,默认通过",
  605 + RawResponse = rawResponse,
  606 + Details = taskResult
  607 + };
  608 + }
  609 +
  610 + // 检查所有场景的审核结果
  611 + var blockScenes = new List<string>();
  612 + var allSuggestions = new List<object>();
  613 + foreach (var item in results)
  614 + {
  615 + var suggestion = item["suggestion"]?.ToString();
  616 + var scene = item["scene"]?.ToString();
  617 + allSuggestions.Add(new
  618 + {
  619 + scene = scene,
  620 + suggestion = suggestion,
  621 + label = item["label"]?.ToString(),
  622 + rate = item["rate"]?.ToString()
  623 + });
  624 +
  625 + if (suggestion == "block")
  626 + {
  627 + blockScenes.Add(scene ?? "unknown");
  628 + }
  629 + }
  630 +
  631 + if (blockScenes.Count > 0)
  632 + {
  633 + return new ModerationResult
  634 + {
  635 + Passed = false,
  636 + Message = $"图片审核未通过,违规场景:{string.Join(", ", blockScenes)}",
  637 + RawResponse = rawResponse,
  638 + Details = new
  639 + {
  640 + blockScenes = blockScenes,
  641 + allResults = allSuggestions
  642 + }
  643 + };
  644 + }
  645 +
  646 + return new ModerationResult
  647 + {
  648 + Passed = true,
  649 + Message = "图片审核通过",
  650 + RawResponse = rawResponse,
  651 + Details = new
  652 + {
  653 + allResults = allSuggestions
  654 + }
  655 + };
  656 + }
  657 + }
  658 + }
  659 +}
... ...
netcore/src/Modularity/Extend/NCC.Extend/NCC.Extend.csproj
... ... @@ -14,6 +14,8 @@
14 14 <ProjectReference Include="..\NCC.Extend.Entitys\NCC.Extend.Entitys.csproj" />
15 15 </ItemGroup>
16 16 <ItemGroup>
  17 + <PackageReference Include="AlibabaCloud.OSS.V2" Version="*" />
  18 + <PackageReference Include="AlibabaCloud.SDK.Green20220302" Version="*" />
17 19 <PackageReference Include="EPPlus" Version="6.2.10" />
18 20 <PackageReference Include="Magicodes.IE.Excel" Version="2.7.4.5" />
19 21 </ItemGroup>
... ...
netcore/src/Modularity/System/NCC.System/Service/Common/FileService.cs
... ... @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authorization;
11 11 using Microsoft.AspNetCore.Http;
12 12 using Microsoft.AspNetCore.Mvc;
13 13 using Microsoft.Extensions.Configuration;
  14 +using Microsoft.Extensions.DependencyInjection;
14 15 using NCC.Common.Configuration;
15 16 using NCC.Common.Core.Captcha.General;
16 17 using NCC.Common.Core.Manager;
... ... @@ -41,16 +42,18 @@ namespace NCC.System.Service.Common
41 42 private readonly IConfiguration _configuration;
42 43 private readonly IUserManager _userManager;
43 44 private readonly IOSSServiceFactory _oSSServiceFactory;
  45 + private readonly IServiceProvider _serviceProvider;
44 46  
45 47 /// <summary>
46 48 /// 初始化一个<see cref="FileService"/>类型的新实例
47 49 /// </summary>
48   - public FileService(IGeneralCaptcha captchaHandle, IConfiguration configuration, IUserManager userManager, IOSSServiceFactory oSSServiceFactory)
  50 + public FileService(IGeneralCaptcha captchaHandle, IConfiguration configuration, IUserManager userManager, IOSSServiceFactory oSSServiceFactory, IServiceProvider serviceProvider)
49 51 {
50 52 _captchaHandle = captchaHandle;
51 53 _configuration = configuration;
52 54 _userManager = userManager;
53 55 _oSSServiceFactory = oSSServiceFactory;
  56 + _serviceProvider = serviceProvider;
54 57 }
55 58  
56 59 /// <summary>
... ... @@ -123,13 +126,160 @@ namespace NCC.System.Service.Common
123 126 ossFilePath = dateFolder;
124 127 }
125 128  
126   - // 先上传到本地,再上传到OSS
127   - var (ossSuccess, localPath, ossPath) = await UploadFileToLocalThenOSS(
128   - file,
129   - _filePath, // 本地存储路径
130   - ossFilePath, // OSS存储路径
131   - _fileName,
132   - forceStoreType);
  129 + // 先只保存到本地(不上传OSS),用于审核
  130 + string localPath = null;
  131 + bool ossSuccess = false;
  132 + string ossPath = null;
  133 +
  134 + // 保存到本地
  135 + var targetPath = forceStoreType == "aliyun-oss" ? FileVariable.TemporaryFilePath : _filePath;
  136 + if (!Directory.Exists(targetPath))
  137 + {
  138 + Directory.CreateDirectory(targetPath);
  139 + }
  140 + localPath = Path.Combine(targetPath, _fileName);
  141 + using (var localStream = File.Create(localPath))
  142 + {
  143 + await file.CopyToAsync(localStream);
  144 + }
  145 +
  146 + // 【新增】图片审核逻辑(仅对图片文件进行审核)
  147 + bool moderationExecuted = false;
  148 + bool moderationPassed = true;
  149 + string moderationMessage = null;
  150 + object moderationDetails = null;
  151 + string moderationRawResponse = null;
  152 + var imageModerationEnabled = _configuration["NCC_App:ImageModeration:Enabled"] == "true";
  153 +
  154 + if (IsImageFile(fileType))
  155 + {
  156 + if (imageModerationEnabled)
  157 + {
  158 + try
  159 + {
  160 + // 通过类型名获取服务(避免直接引用NCC.Extend)
  161 + var moderationServiceType = Type.GetType("NCC.Extend.ImageModerationService, NCC.Extend");
  162 + if (moderationServiceType != null)
  163 + {
  164 + var moderationService = _serviceProvider.GetService(moderationServiceType);
  165 + if (moderationService != null)
  166 + {
  167 + // 使用反射调用方法
  168 + var scanMethod = moderationServiceType.GetMethod("ScanImageFromFileAsync",
  169 + global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic);
  170 + if (scanMethod != null)
  171 + {
  172 + // 调用方法并获取Task
  173 + var taskObj = scanMethod.Invoke(moderationService, new object[] { localPath });
  174 + if (taskObj != null)
  175 + {
  176 + // 使用dynamic来await Task
  177 + dynamic task = taskObj;
  178 + var moderationResult = await task;
  179 + moderationExecuted = true;
  180 +
  181 + // 使用dynamic获取结果属性
  182 + moderationPassed = moderationResult.Passed;
  183 + moderationMessage = moderationResult.Message?.ToString();
  184 + moderationRawResponse = moderationResult.RawResponse?.ToString();
  185 + moderationDetails = moderationResult.Details;
  186 +
  187 + if (!moderationPassed)
  188 + {
  189 + // 审核不通过,保留本地文件,返回错误提示(不上传OSS)
  190 + throw NCCException.Oh(moderationMessage ?? "图片审核未通过:图片包含违规内容");
  191 + }
  192 + }
  193 + else
  194 + {
  195 + moderationMessage = "图片审核服务调用失败";
  196 + }
  197 + }
  198 + else
  199 + {
  200 + moderationMessage = "图片审核方法未找到";
  201 + }
  202 + }
  203 + else
  204 + {
  205 + moderationMessage = "图片审核服务未注册";
  206 + }
  207 + }
  208 + else
  209 + {
  210 + moderationMessage = "图片审核服务类型未找到";
  211 + }
  212 + }
  213 + catch (NCC.FriendlyException.AppFriendlyException)
  214 + {
  215 + // 审核失败异常,直接抛出
  216 + throw;
  217 + }
  218 + catch (Exception ex)
  219 + {
  220 + // 审核服务异常,根据配置决定是否继续上传
  221 + moderationExecuted = true;
  222 + moderationMessage = $"图片审核异常:{ex.Message}";
  223 + moderationRawResponse = ex.ToString();
  224 + var failOnError = _configuration["NCC_App:ImageModeration:FailOnError"] == "true";
  225 + if (failOnError)
  226 + {
  227 + throw NCCException.Oh("图片审核服务暂时不可用,请稍后重试");
  228 + }
  229 + // 否则继续上传(降级策略)
  230 + moderationPassed = true; // 降级策略:异常时允许上传
  231 + }
  232 + }
  233 + else
  234 + {
  235 + moderationMessage = "图片审核未启用";
  236 + }
  237 + }
  238 + else
  239 + {
  240 + moderationMessage = "非图片文件,无需审核";
  241 + }
  242 +
  243 + // 审核通过后,上传OSS(如果需要)
  244 + if (forceStoreType == "aliyun-oss")
  245 + {
  246 + try
  247 + {
  248 + var bucketName = KeyVariable.BucketName;
  249 + ossPath = $"{ossFilePath.TrimEnd('/').TrimEnd('\\')}/{_fileName}";
  250 +
  251 + using (var localFileStream = File.OpenRead(localPath))
  252 + {
  253 + await _oSSServiceFactory.Create("aliyun").PutObjectAsync(bucketName, ossPath, localFileStream);
  254 + }
  255 +
  256 + ossSuccess = true;
  257 +
  258 + // OSS上传成功,删除本地文件
  259 + if (File.Exists(localPath))
  260 + {
  261 + try
  262 + {
  263 + File.Delete(localPath);
  264 + localPath = null;
  265 + }
  266 + catch
  267 + {
  268 + // 删除失败不影响结果
  269 + }
  270 + }
  271 + }
  272 + catch
  273 + {
  274 + // OSS上传失败,保留本地文件
  275 + ossSuccess = false;
  276 + }
  277 + }
  278 + else
  279 + {
  280 + // 非OSS类型,本地存储视为成功
  281 + ossSuccess = true;
  282 + }
133 283  
134 284 // 根据OSS上传结果返回URL
135 285 string fileUrl;
... ... @@ -154,8 +304,25 @@ namespace NCC.System.Service.Common
154 304 fileUrl = localUrl;
155 305 }
156 306  
157   - // 返回格式:name(原始文件名), fileId(生成的文件名), url(OSS地址或本地地址), localUrl(本地存储访问路径), localPath(实际本地文件存储路径)
158   - return new { name = file.FileName, fileId = _fileName, url = fileUrl, localUrl = localUrl, localPath = localPath };
  307 + // 返回格式:name(原始文件名), fileId(生成的文件名), url(OSS地址或本地地址), localUrl(本地存储访问路径), localPath(实际本地文件存储路径), moderation(审核信息)
  308 + return new
  309 + {
  310 + name = file.FileName,
  311 + fileId = _fileName,
  312 + url = fileUrl,
  313 + localUrl = localUrl,
  314 + localPath = localPath,
  315 + moderation = new
  316 + {
  317 + enabled = imageModerationEnabled,
  318 + executed = moderationExecuted,
  319 + passed = moderationPassed,
  320 + message = moderationMessage,
  321 + isImage = IsImageFile(fileType),
  322 + rawResponse = moderationRawResponse,
  323 + details = moderationDetails
  324 + }
  325 + };
159 326 }
160 327  
161 328 /// <summary>
... ... @@ -883,6 +1050,18 @@ namespace NCC.System.Service.Common
883 1050 /// </summary>
884 1051 /// <param name="fileExtension">文件后缀名</param>
885 1052 /// <returns></returns>
  1053 + /// <summary>
  1054 + /// 判断文件是否为图片类型
  1055 + /// </summary>
  1056 + /// <param name="fileType">文件类型(扩展名)</param>
  1057 + /// <returns>是否为图片</returns>
  1058 + [NonAction]
  1059 + private bool IsImageFile(string fileType)
  1060 + {
  1061 + var imageTypes = new[] { "jpg", "jpeg", "png", "gif", "bmp", "webp" };
  1062 + return imageTypes.Contains(fileType.ToLower());
  1063 + }
  1064 +
886 1065 private bool AllowImageType(string fileExtension)
887 1066 {
888 1067 var allowExtension = KeyVariable.AllowImageType;
... ...