ImageAuditJob.cs 8.96 KB
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NCC.Common.Configuration;
using NCC.Dependency;
using NCC.Extend.Entitys.Enum;
using NCC.Extend.Entitys.lq_upload_record;
using SqlSugar;
using OSS = AlibabaCloud.OSS.V2;

namespace NCC.Extend
{
    /// <summary>
    /// 图片审核后台任务(Hangfire Job)
    /// </summary>
    public class ImageAuditJob : ITransient
    {
        private readonly ISqlSugarClient _db;
        private readonly IConfiguration _configuration;
        private readonly ImageModerationService _moderationService;
        private readonly ILogger<ImageAuditJob> _logger;

        /// <summary>
        /// 初始化图片审核任务
        /// </summary>
        public ImageAuditJob(
            ISqlSugarClient db,
            IConfiguration configuration,
            ImageModerationService moderationService,
            ILogger<ImageAuditJob> logger)
        {
            _db = db;
            _configuration = configuration;
            _moderationService = moderationService;
            _logger = logger;
        }

        /// <summary>
        /// 执行单条记录的图片审核
        /// </summary>
        /// <param name="uploadRecordId">上传记录 ID</param>
        public async Task ExecuteAsync(string uploadRecordId)
        {
            var entity = await _db.Queryable<LqUploadRecordEntity>()
                .Where(x => x.Id == uploadRecordId)
                .FirstAsync();

            if (entity == null || entity.AuditStatus != (int)ImageAuditStatusEnum.Pending)
                return;

            try
            {
                var imageBytes = await DownloadFromOssAsync(entity.ObjectKey);
                if (imageBytes == null || imageBytes.Length == 0)
                {
                    entity.AuditStatus = (int)ImageAuditStatusEnum.Failed;
                    entity.AuditReason = "从 OSS 下载文件失败或文件为空";
                    entity.AuditRawResponse = null;
                    entity.AuditTime = DateTime.Now;
                    await _db.Updateable(entity)
                        .UpdateColumns(e => new { e.AuditStatus, e.AuditReason, e.AuditRawResponse, e.AuditTime })
                        .ExecuteCommandAsync();
                    return;
                }

                var result = await _moderationService.ScanImageAsync(imageBytes, backgroundQueue: true);
                var rawSnap = TruncateRawResponse(result?.RawResponse);

                if (result.Passed)
                {
                    entity.AuditStatus = (int)ImageAuditStatusEnum.Pass;
                    entity.AuditReason = result.Message;
                    entity.AuditRawResponse = rawSnap;
                    entity.AuditTime = DateTime.Now;
                    await _db.Updateable(entity)
                        .UpdateColumns(e => new { e.AuditStatus, e.AuditReason, e.AuditRawResponse, e.AuditTime })
                        .ExecuteCommandAsync();
                }
                else
                {
                    entity.AuditStatus = (int)ImageAuditStatusEnum.Reject;
                    entity.AuditReason = result.Message;
                    entity.AuditRawResponse = rawSnap;
                    entity.AuditTime = DateTime.Now;

                    await SaveIllegalImageLocallyAsync(entity, imageBytes);

                    if (!string.IsNullOrEmpty(entity.IllegalLocalPath)
                        && !entity.IllegalLocalPath.StartsWith("保存失败", StringComparison.Ordinal))
                    {
                        var illegalApiPath = $"/api/Extend/OssDirectUpload/IllegalImage/{entity.Id}";
                        var pubBase = (_configuration["NCC_App:LocalFileBaseUrl"] ?? _configuration["NCC_App:Domain"] ?? "")
                            .Trim().TrimEnd('/');
                        entity.AccessUrl = string.IsNullOrEmpty(pubBase)
                            ? illegalApiPath
                            : $"{pubBase}{illegalApiPath}";
                        entity.ThumbnailPublicUrl = entity.AccessUrl;
                    }

                    await _db.Updateable(entity)
                        .UpdateColumns(e => new
                        {
                            e.AuditStatus,
                            e.AuditReason,
                            e.AuditRawResponse,
                            e.AuditTime,
                            e.IllegalLocalPath,
                            e.AccessUrl,
                            e.ThumbnailPublicUrl
                        })
                        .ExecuteCommandAsync();
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "图片审核任务异常,uploadRecordId={Id}", uploadRecordId);
                entity.AuditStatus = (int)ImageAuditStatusEnum.Failed;
                entity.AuditReason = $"审核异常:{ex.Message}";
                entity.AuditRawResponse = TruncateRawResponse(ex.ToString());
                entity.AuditTime = DateTime.Now;
                await _db.Updateable(entity)
                    .UpdateColumns(e => new { e.AuditStatus, e.AuditReason, e.AuditRawResponse, e.AuditTime })
                    .ExecuteCommandAsync();
            }
        }

        /// <summary>
        /// 定时扫描待审核的图片(兜底任务)
        /// </summary>
        public async Task ScanPendingAsync()
        {
            var threshold = DateTime.Now.AddMinutes(-2);
            var pendingRecords = await _db.Queryable<LqUploadRecordEntity>()
                .Where(x => x.AuditStatus == (int)ImageAuditStatusEnum.Pending && x.CreateTime < threshold)
                .Take(50)
                .ToListAsync();

            foreach (var record in pendingRecords)
            {
                try
                {
                    await ExecuteAsync(record.Id);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "兜底审核任务异常,uploadRecordId={Id}", record.Id);
                }
            }
        }

        /// <summary>
        /// 从 OSS 下载文件到内存
        /// </summary>
        private async Task<byte[]> DownloadFromOssAsync(string objectKey)
        {
            var accessKeyId = _configuration["NCC_App:AliyunOSS:AccessKeyId"];
            var accessKeySecret = _configuration["NCC_App:AliyunOSS:AccessKeySecret"];
            var endpoint = _configuration["NCC_App:AliyunOSS:Endpoint"];
            var region = _configuration["NCC_App:AliyunOSS:Region"] ?? "cn-chengdu";
            var bucketName = _configuration["NCC_App:BucketName"];

            if (!endpoint.StartsWith("http", StringComparison.OrdinalIgnoreCase))
                endpoint = "https://" + endpoint;

            var ossCfg = OSS.Configuration.LoadDefault();
            ossCfg.CredentialsProvider = new OSS.Credentials.StaticCredentialsProvider(accessKeyId, accessKeySecret);
            ossCfg.Region = region;
            ossCfg.Endpoint = endpoint;

            using (var ossClient = new OSS.Client(ossCfg))
            {
                var response = await ossClient.GetObjectAsync(new OSS.Models.GetObjectRequest
                {
                    Bucket = bucketName,
                    Key = objectKey
                });

                using (var ms = new MemoryStream())
                {
                    await response.Body.CopyToAsync(ms);
                    return ms.ToArray();
                }
            }
        }

        /// <summary>
        /// 将违规图片保存到本地
        /// </summary>
        private async Task SaveIllegalImageLocallyAsync(LqUploadRecordEntity entity, byte[] imageBytes)
        {
            try
            {
                var dirRoot = FileVariable.IllegalImageFilePath;
                var now = DateTime.Now;
                var dirPath = Path.Combine(dirRoot, now.ToString("yyyy"), now.ToString("MM"), now.ToString("dd"));

                if (!Directory.Exists(dirPath))
                    Directory.CreateDirectory(dirPath);

                var fileName = Path.GetFileName(entity.ObjectKey);
                var filePath = Path.Combine(dirPath, fileName);

                await File.WriteAllBytesAsync(filePath, imageBytes);
                entity.IllegalLocalPath = Path.GetFullPath(filePath);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "保存违规图片到本地失败,uploadRecordId={Id}", entity.Id);
                entity.IllegalLocalPath = $"保存失败:{ex.Message}";
            }
        }

        /// <summary>
        /// 限制原始响应长度,避免超过 MySQL TEXT 与 ORM 映射问题
        /// </summary>
        private static string TruncateRawResponse(string raw)
        {
            if (string.IsNullOrEmpty(raw)) return null;
            const int maxLen = 60000;
            return raw.Length <= maxLen ? raw : raw.Substring(0, maxLen);
        }
    }
}