using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using Hangfire; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using NCC.Common.Core.Manager; using NCC.Common.Filter; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqUploadRecord; using NCC.Extend.Entitys.Enum; using NCC.Extend.Entitys.lq_upload_record; using NCC.System.Entitys.Permission; using Newtonsoft.Json; using SqlSugar; using Yitter.IdGenerator; namespace NCC.Extend { /// /// OSS 直传服务(前端直传 OSS,服务端签名 + 确认入库 + 异步审核) /// [ApiDescriptionSettings(Tag = "Common", Name = "OssDirectUpload", Order = 160)] [Route("api/Extend/[controller]")] public class OssDirectUploadService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; private readonly IUserManager _userManager; private readonly IConfiguration _configuration; private readonly IWebHostEnvironment _webHostEnvironment; private static readonly string[] ImageExtensions = { "jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg" }; /// /// 初始化 OSS 直传服务 /// public OssDirectUploadService( ISqlSugarClient db, IUserManager userManager, IConfiguration configuration, IWebHostEnvironment webHostEnvironment) { _db = db; _userManager = userManager; _configuration = configuration; _webHostEnvironment = webHostEnvironment; _db.CodeFirst.InitTables(typeof(LqUploadRecordEntity)); } /// /// OSS 控制台「图片样式」名称,用于列表缩略图(x-oss-process=style/xxx) /// private string GetOssThumbnailStyleName() => _configuration["NCC_App:AliyunOSS:ThumbnailStyle"] ?? _configuration["NCC_APP:AliyunOSS:ThumbnailStyle"] ?? OssImageDisplayUrlHelper.DefaultThumbnailStyleName; /// /// 将相对路径与 LocalFileBaseUrl/Domain 拼成带域名的完整 URL;已是 http(s) 则原样返回 /// private static string CombinePublicUrl(string publicBase, string pathOrAbsolute) { if (string.IsNullOrWhiteSpace(pathOrAbsolute)) return null; if (pathOrAbsolute.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || pathOrAbsolute.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return pathOrAbsolute; if (string.IsNullOrWhiteSpace(publicBase)) return pathOrAbsolute.StartsWith("/") ? pathOrAbsolute : "/" + pathOrAbsolute; var path = pathOrAbsolute.StartsWith("/") ? pathOrAbsolute : "/" + pathOrAbsolute; return $"{publicBase.TrimEnd('/')}{path}"; } /// /// 按当前 OSS / 域名配置,将带域名的原图与缩略图 URL 写回实体(重试审核等) /// private void RefillPublicImageUrlsForRecord(LqUploadRecordEntity entity) { var ext = entity.Extension ?? ""; if (string.IsNullOrEmpty(entity.ObjectKey) || !ImageExtensions.Contains(ext.ToLower())) { entity.AccessUrl = null; entity.ThumbnailPublicUrl = null; return; } var publicBase = (_configuration["NCC_App:LocalFileBaseUrl"] ?? _configuration["NCC_App:Domain"] ?? "") .Trim().TrimEnd('/'); var customDomain = _configuration["NCC_App:AliyunOSS:CustomDomain"]; var fileName = Path.GetFileName(entity.ObjectKey); string url; if (!string.IsNullOrEmpty(customDomain)) url = $"{customDomain.TrimEnd('/')}/{entity.ObjectKey}"; else url = $"/api/File/Image/{entity.FileType}/{fileName}"; entity.AccessUrl = CombinePublicUrl(publicBase, url); var thumbStyle = GetOssThumbnailStyleName(); var thumb = OssImageDisplayUrlHelper.IsRasterImageExtension(ext) ? OssImageDisplayUrlHelper.AppendThumbnailToUrl(url, ext, thumbStyle) : url; entity.ThumbnailPublicUrl = CombinePublicUrl(publicBase, thumb); } /// /// 获取 OSS 直传凭证 /// /// /// 前端调用此接口获取签名凭证,然后直接从浏览器上传到 OSS,不经过业务服务器。 /// 返回的 fileId + objectKeyPrefix 组合后拼接扩展名即为完整 objectKey。 /// /// 文件类型(如 annexpic、workFlow 等),用于 OSS 路径分类 /// OSS 直传凭证(含签名、policy、host 等) /// 返回直传凭证 [HttpGet("GetUploadCredential")] public GetUploadCredentialOutput GetUploadCredential([FromQuery] string type) { if (string.IsNullOrWhiteSpace(type)) type = "annexpic"; var accessKeyId = _configuration["NCC_App:AliyunOSS:AccessKeyId"]; var accessKeySecret = _configuration["NCC_App:AliyunOSS:AccessKeySecret"]; var endpoint = _configuration["NCC_App:AliyunOSS:Endpoint"]; var customDomain = _configuration["NCC_App:AliyunOSS:CustomDomain"]; var bucketName = _configuration["NCC_App:BucketName"]; var now = DateTime.Now; var objectKeyPrefix = $"{type}/{now:yyyy}/{now:MM}/{now:dd}/"; var fileId = $"{now:yyyyMMdd}_{YitIdHelper.NextId()}"; var expireTime = now.AddMinutes(15).ToUniversalTime(); var policyObj = new { expiration = expireTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"), conditions = new object[] { new object[] { "content-length-range", 0, 10485760 }, new object[] { "starts-with", "$key", objectKeyPrefix } } }; var policyJson = JsonConvert.SerializeObject(policyObj); var policyBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(policyJson)); string signature; using (var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(accessKeySecret))) { var signatureBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(policyBase64)); signature = Convert.ToBase64String(signatureBytes); } var host = $"https://{bucketName}.{endpoint}"; return new GetUploadCredentialOutput { host = host, accessKeyId = accessKeyId, policy = policyBase64, signature = signature, objectKeyPrefix = objectKeyPrefix, fileId = fileId, expire = new DateTimeOffset(expireTime).ToUnixTimeSeconds(), customDomain = customDomain }; } /// /// 确认上传(前端直传 OSS 成功后回调) /// /// /// 前端直传 OSS 完成后调用此接口: /// 1. 在数据库中记录上传信息 /// 2. 如果是图片文件,触发异步审核任务 /// 3. 返回文件访问 URL /// /// 上传确认入参(objectKey、原始文件名、文件类型) /// 文件信息(包含访问 URL、审核状态等) /// 确认成功,返回文件信息 [HttpPost("ConfirmUpload")] public async Task ConfirmUpload([FromBody] ConfirmUploadInput input) { if (string.IsNullOrWhiteSpace(input?.objectKey)) throw new Exception("objectKey 不能为空"); var extension = Path.GetExtension(input.objectKey)?.TrimStart('.') ?? ""; var fileName = Path.GetFileName(input.objectKey); var fileId = Path.GetFileNameWithoutExtension(input.objectKey); var fileType = input.type ?? "annexpic"; var isImage = ImageExtensions.Contains(extension.ToLower()); var customDomain = _configuration["NCC_App:AliyunOSS:CustomDomain"]; var publicBase = (_configuration["NCC_App:LocalFileBaseUrl"] ?? _configuration["NCC_App:Domain"] ?? "") .Trim().TrimEnd('/'); string proxyUrl = $"/api/File/Image/{fileType}/{fileName}"; string url; if (!string.IsNullOrEmpty(customDomain)) { url = $"{customDomain.TrimEnd('/')}/{input.objectKey}"; } else { url = proxyUrl; } var thumbStyle = GetOssThumbnailStyleName(); var thumbnailUrl = isImage ? OssImageDisplayUrlHelper.AppendThumbnailToUrl(url, extension, thumbStyle) : null; var thumbnailProxyUrl = isImage ? OssImageDisplayUrlHelper.AppendThumbnailToUrl(proxyUrl, extension, thumbStyle) : null; string accessPersist = null; string thumbPersist = null; if (isImage) { accessPersist = CombinePublicUrl(publicBase, url); thumbPersist = string.IsNullOrEmpty(thumbnailUrl) ? null : CombinePublicUrl(publicBase, thumbnailUrl); } var entity = new LqUploadRecordEntity { Id = YitIdHelper.NextId().ToString(), ObjectKey = input.objectKey, OriginalFileName = input.originalFileName ?? fileName, FileType = fileType, Extension = extension, AuditStatus = (int)ImageAuditStatusEnum.Pending, UserId = _userManager?.UserId, CreateTime = DateTime.Now, AccessUrl = accessPersist, ThumbnailPublicUrl = thumbPersist }; await _db.Insertable(entity).ExecuteCommandAsync(); if (isImage) { BackgroundJob.Enqueue(x => x.ExecuteAsync(entity.Id)); } else { entity.AuditStatus = (int)ImageAuditStatusEnum.Pass; entity.AuditTime = DateTime.Now; entity.AuditReason = "非图片文件,跳过审核"; await _db.Updateable(entity) .UpdateColumns(e => new { e.AuditStatus, e.AuditTime, e.AuditReason }) .ExecuteCommandAsync(); } return new { name = entity.OriginalFileName, fileId = fileId, uploadRecordId = entity.Id, url = url, thumbnailUrl = thumbnailUrl, proxyUrl = proxyUrl, thumbnailProxyUrl = thumbnailProxyUrl, objectKey = input.objectKey, auditStatus = entity.AuditStatus }; } /// /// 按上传记录读取违规落盘后的本地图片(仅审核状态为违规且已成功落盘时可访问) /// /// 上传记录主键 /// 图片二进制流 [HttpGet("IllegalImage/{id}")] [AllowAnonymous] [NonUnify] public IActionResult IllegalImage(string id) { var entity = _db.Queryable() .Where(x => x.Id == id && x.AuditStatus == (int)ImageAuditStatusEnum.Reject) .First(); if (entity == null || string.IsNullOrWhiteSpace(entity.IllegalLocalPath)) return new NotFoundResult(); if (entity.IllegalLocalPath.StartsWith("保存失败", StringComparison.Ordinal)) return new NotFoundResult(); var fullPath = ResolveIllegalImageFullPath(entity.IllegalLocalPath); if (string.IsNullOrEmpty(fullPath) || !global::System.IO.File.Exists(fullPath)) return new NotFoundResult(); var ext = (entity.Extension ?? Path.GetExtension(fullPath)?.TrimStart('.'))?.ToLowerInvariant() ?? "jpg"; var contentType = ext switch { "png" => "image/png", "gif" => "image/gif", "webp" => "image/webp", "bmp" => "image/bmp", "svg" => "image/svg+xml", _ => "image/jpeg" }; var stream = new global::System.IO.FileStream( fullPath, global::System.IO.FileMode.Open, global::System.IO.FileAccess.Read, global::System.IO.FileShare.Read); return new FileStreamResult(stream, contentType) { FileDownloadName = Path.GetFileName(fullPath) }; } private string ResolveIllegalImageFullPath(string illegalLocalPath) { if (string.IsNullOrWhiteSpace(illegalLocalPath)) return null; if (global::System.IO.File.Exists(illegalLocalPath)) return illegalLocalPath; if (Path.IsPathRooted(illegalLocalPath)) return null; var combinedContentRoot = Path.GetFullPath( Path.Combine(_webHostEnvironment.ContentRootPath, illegalLocalPath)); if (global::System.IO.File.Exists(combinedContentRoot)) return combinedContentRoot; var combinedCurrentDir = Path.GetFullPath( Path.Combine(Directory.GetCurrentDirectory(), illegalLocalPath)); return global::System.IO.File.Exists(combinedCurrentDir) ? combinedCurrentDir : null; } private static readonly Dictionary AuditStatusTextMap = new Dictionary { { (int)ImageAuditStatusEnum.Pending, "待审核" }, { (int)ImageAuditStatusEnum.Pass, "通过" }, { (int)ImageAuditStatusEnum.Reject, "违规" }, { (int)ImageAuditStatusEnum.Failed, "审核失败" } }; /// /// 按上传记录 ID 查询审核状态与当前应对外使用的图片 URL(业务只绑记录 ID 时可轮询本接口) /// /// 上传记录主键 /// 审核状态、解析后的 imageUrl / thumbnailUrl [HttpGet("GetUploadRecord/{id}")] public async Task GetUploadRecord(string id) { var row = await _db.Queryable().Where(x => x.Id == id).FirstAsync(); if (row == null) throw new Exception("上传记录不存在"); var customDomain = _configuration["NCC_App:AliyunOSS:CustomDomain"]; string imageUrl = null; string thumbnailUrl = null; var publicBase = (_configuration["NCC_App:LocalFileBaseUrl"] ?? _configuration["NCC_App:Domain"] ?? "") .Trim().TrimEnd('/'); if (!string.IsNullOrWhiteSpace(row.AccessUrl)) { imageUrl = row.AccessUrl; thumbnailUrl = string.IsNullOrWhiteSpace(row.ThumbnailPublicUrl) ? row.AccessUrl : row.ThumbnailPublicUrl; } else if (!string.IsNullOrEmpty(row.ObjectKey)) { if (!string.IsNullOrEmpty(customDomain)) { imageUrl = $"{customDomain.TrimEnd('/')}/{row.ObjectKey}"; var thumbStyle = GetOssThumbnailStyleName(); thumbnailUrl = OssImageDisplayUrlHelper.IsRasterImageExtension(row.Extension) ? OssImageDisplayUrlHelper.AppendThumbnailToUrl(imageUrl, row.Extension, thumbStyle) : imageUrl; } else { var fileName = Path.GetFileName(row.ObjectKey); var relImg = $"/api/File/Image/{row.FileType}/{fileName}"; imageUrl = CombinePublicUrl(publicBase, relImg); var thumbStyle = GetOssThumbnailStyleName(); var relThumb = OssImageDisplayUrlHelper.IsRasterImageExtension(row.Extension) ? OssImageDisplayUrlHelper.AppendThumbnailToUrl(relImg, row.Extension, thumbStyle) : relImg; thumbnailUrl = CombinePublicUrl(publicBase, relThumb); } } return new { id = row.Id, auditStatus = row.AuditStatus, auditStatusText = AuditStatusTextMap.TryGetValue(row.AuditStatus, out var t) ? t : "未知", imageUrl, thumbnailUrl, accessUrl = row.AccessUrl, thumbnailPublicUrl = row.ThumbnailPublicUrl, auditReason = row.AuditReason, auditRawResponse = row.AuditRawResponse }; } /// /// 分页查询上传记录 /// /// /// 管理端查询上传记录列表,支持按审核状态、关键词、时间范围筛选。 /// keyword 模糊匹配原始文件名或 ObjectKey。 /// /// 分页查询入参 /// 分页列表(含用户姓名、图片访问 URL) /// 返回分页数据 [HttpGet("GetUploadRecords")] public async Task GetUploadRecords([FromQuery] UploadRecordListQueryInput input) { var customDomain = _configuration["NCC_App:AliyunOSS:CustomDomain"]; var publicBase = (_configuration["NCC_App:LocalFileBaseUrl"] ?? _configuration["NCC_App:Domain"] ?? "") .Trim().TrimEnd('/'); var data = await _db.Queryable() .WhereIF(input.auditStatus.HasValue, x => x.AuditStatus == input.auditStatus.Value) .WhereIF(!string.IsNullOrWhiteSpace(input.keyword), x => x.OriginalFileName.Contains(input.keyword) || x.ObjectKey.Contains(input.keyword)) .WhereIF(input.startTime.HasValue, x => x.CreateTime >= input.startTime.Value) .WhereIF(input.endTime.HasValue, x => x.CreateTime <= input.endTime.Value) .OrderBy(x => x.CreateTime, OrderByType.Desc) .Select(x => new UploadRecordListOutput { id = x.Id, objectKey = x.ObjectKey, originalFileName = x.OriginalFileName, fileType = x.FileType, extension = x.Extension, auditStatus = x.AuditStatus, auditStatusText = "", auditReason = x.AuditReason, auditRawResponse = x.AuditRawResponse, illegalLocalPath = x.IllegalLocalPath, accessUrl = x.AccessUrl, thumbnailPublicUrl = x.ThumbnailPublicUrl, userId = x.UserId, userName = SqlFunc.Subqueryable() .Where(u => u.Id == x.UserId).Select(u => u.RealName), createTime = x.CreateTime, auditTime = x.AuditTime, imageUrl = "", thumbnailUrl = "" }) .ToPagedListAsync(input.currentPage, input.pageSize); foreach (var item in data.list) { item.auditStatusText = AuditStatusTextMap.ContainsKey(item.auditStatus) ? AuditStatusTextMap[item.auditStatus] : "未知"; if (!string.IsNullOrEmpty(item.objectKey)) { if (!string.IsNullOrWhiteSpace(item.accessUrl)) { item.imageUrl = item.accessUrl; item.thumbnailUrl = string.IsNullOrWhiteSpace(item.thumbnailPublicUrl) ? item.accessUrl : item.thumbnailPublicUrl; } else if (!string.IsNullOrEmpty(customDomain)) { item.imageUrl = $"{customDomain.TrimEnd('/')}/{item.objectKey}"; var thumbStyle = GetOssThumbnailStyleName(); item.thumbnailUrl = OssImageDisplayUrlHelper.IsRasterImageExtension(item.extension) ? OssImageDisplayUrlHelper.AppendThumbnailToUrl(item.imageUrl, item.extension, thumbStyle) : item.imageUrl; } else { var fileName = Path.GetFileName(item.objectKey); var relImg = $"/api/File/Image/{item.fileType}/{fileName}"; item.imageUrl = CombinePublicUrl(publicBase, relImg); var thumbStyle = GetOssThumbnailStyleName(); var relThumb = OssImageDisplayUrlHelper.IsRasterImageExtension(item.extension) ? OssImageDisplayUrlHelper.AppendThumbnailToUrl(relImg, item.extension, thumbStyle) : relImg; item.thumbnailUrl = CombinePublicUrl(publicBase, relThumb); } } } return PageResult.SqlSugarPageResult(data); } /// /// 手动重新审核 /// /// /// 将指定上传记录的审核状态重置为待审核,并重新触发异步审核任务。 /// 适用于审核失败或需要重新检查的记录。 /// /// 上传记录 ID /// 操作结果 /// 已重新提交审核 [HttpPost("RetryAudit/{id}")] public async Task RetryAudit(string id) { var entity = await _db.Queryable() .Where(x => x.Id == id) .FirstAsync(); if (entity == null) throw new Exception("上传记录不存在"); entity.AuditStatus = (int)ImageAuditStatusEnum.Pending; entity.AuditReason = null; entity.AuditTime = null; entity.AuditRawResponse = null; entity.IllegalLocalPath = null; RefillPublicImageUrlsForRecord(entity); await _db.Updateable(entity) .UpdateColumns(e => new { e.AuditStatus, e.AuditReason, e.AuditTime, e.AuditRawResponse, e.IllegalLocalPath, e.AccessUrl, e.ThumbnailPublicUrl }) .ExecuteCommandAsync(); BackgroundJob.Enqueue(x => x.ExecuteAsync(id)); return new { success = true, message = "已重新提交审核" }; } /// /// 获取审核统计 /// /// /// 返回各审核状态的记录数量,用于管理端仪表盘展示。 /// /// 各状态数量统计 /// 返回审核统计数据 [HttpGet("GetAuditStats")] public async Task GetAuditStats() { var allRecords = await _db.Queryable() .GroupBy(x => x.AuditStatus) .Select(x => new { status = x.AuditStatus, count = SqlFunc.AggregateCount(x.Id) }) .ToListAsync(); var result = new AuditStatsOutput { pending = allRecords.FirstOrDefault(x => x.status == (int)ImageAuditStatusEnum.Pending)?.count ?? 0, pass = allRecords.FirstOrDefault(x => x.status == (int)ImageAuditStatusEnum.Pass)?.count ?? 0, reject = allRecords.FirstOrDefault(x => x.status == (int)ImageAuditStatusEnum.Reject)?.count ?? 0, failed = allRecords.FirstOrDefault(x => x.status == (int)ImageAuditStatusEnum.Failed)?.count ?? 0 }; result.total = result.pending + result.pass + result.reject + result.failed; return result; } } }