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;
}
}
}