using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using MilliSim.Common.Entities; using MilliSim.Data; using COSXML; using COSXML.Auth; using COSXML.Model.Object; using COSXML.Model.Tag; using COSXML.Model.Bucket; using System.IO.Compression; using System.Security.Cryptography; using System.Text; namespace MilliSim.Services.Infrastructure; /// /// 文件存储服务接口 /// public interface IFileStorageService { /// /// 上传文件 /// Task UploadAsync(IFormFile file, string folder); /// /// 删除文件 /// Task DeleteAsync(string fileUrl); /// /// 获取文件URL(返回对象键) /// string GetFileUrl(string relativePath); /// /// 下载文件 /// Task<(Stream Stream, string ContentType, string FileName)> DownloadAsync(string fileUrl); /// /// 生成预签名URL(COS私有读写必须用预签名URL访问) /// string GetSignedUrl(string fileUrl, int expireMinutes = 60); /// /// 从文件URL中提取对象键(用于数据库存储,避免签名URL过期导致数据丢失) /// string ExtractKey(string fileUrl); /// /// 清除存储配置缓存(在后台修改存储设置后调用,确保后续请求读取最新配置) /// void ClearConfigCache(); /// /// 解压 ZIP 文件到 COS(用于 WebGL 资源在线运行) /// /// ZIP 文件 /// COS 目标目录 /// 解压后的根目录对象键前缀(如 webgl/xxx/) Task ExtractZipAsync(IFormFile zipFile, string folder); /// /// 读取文件文本内容(用于 WebGL index.html 代理读取) /// /// 文件URL或对象键 /// 文件文本内容 Task ReadFileTextAsync(string fileUrl); /// /// 列出存储桶内所有对象键(分页列出,用于工厂重置清理) /// Task> ListAllObjectsAsync(); /// /// 批量删除对象(每批最多 1000 个,用于工厂重置清理) /// Task DeleteObjectsAsync(IEnumerable keys); /// /// 将 COS 中所有对象迁移到本地存储(用于从 COS 切换到 Local 模式后补齐旧文件) /// /// 迁移的文件数量 Task MigrateCosToLocalAsync(); /// /// 迁移已有的 APK/IPA 文件扩展名(COS 禁止通过默认域名分发 APK/IPA 文件) /// 将对象键从 xxx.apk 复制为 xxx.apk.bin,删除旧对象,更新数据库 /// /// 迁移的文件数量 Task MigrateApkExtensionsAsync(); } /// /// 文件存储服务实现 /// 支持本地存储和腾讯云COS存储,默认使用COS模式 /// 配置信息从数据库 SystemSettings 表读取 /// public class FileStorageService : IFileStorageService { private readonly MilliSimDbContext _db; private readonly IWebHostEnvironment _env; private readonly IConfiguration _config; // 缓存的COS客户端(静态,避免每次请求都创建) private static CosXml? _cachedCosXml; // 缓存的配置签名,用于检测配置是否变更 private static string? _cachedConfigSignature; // 锁对象,保证COS客户端创建的线程安全 private static readonly object _lock = new(); // 缓存的存储配置(静态,避免每次 GetSignedUrl 都查库) private static StorageConfig? _cachedStorageConfig; // 缓存的存储配置过期时间 private static DateTime _cachedStorageConfigExpiry; // 存储配置缓存锁 private static readonly object _configLock = new(); // 存储配置缓存有效期(分钟) private const int ConfigCacheMinutes = 5; // 静态 HttpClient(用于 COS 预签名 URL 流式下载,避免每次请求创建实例) private static readonly HttpClient _downloadHttpClient = new HttpClient(); /// /// 构造函数 /// /// 数据库上下文 /// Web主机环境 /// 配置(用于读取 Jwt:SecretKey 作为本地签名URL的签名密钥) public FileStorageService(MilliSimDbContext db, IWebHostEnvironment env, IConfiguration config) { _db = db; _env = env; _config = config; } /// /// 上传文件 /// COS模式返回对象键(folder/filename),本地模式返回访问URL /// /// 表单文件 /// 存储目录 /// COS对象键或本地访问URL public async Task UploadAsync(IFormFile file, string folder) { // 从数据库读取存储配置 var config = await GetStorageConfigAsync(); // 根据存储模式选择上传方式 if (config.Mode.Equals("Cos", StringComparison.OrdinalIgnoreCase)) { return await UploadToCosAsync(file, folder, config); } return await UploadToLocalAsync(file, folder, config); } /// /// 删除文件 /// /// 文件URL或对象键 public async Task DeleteAsync(string fileUrl) { // 空值检查 if (string.IsNullOrEmpty(fileUrl)) return; // 从数据库读取存储配置 var config = await GetStorageConfigAsync(); // 根据存储模式选择删除方式 if (config.Mode.Equals("Cos", StringComparison.OrdinalIgnoreCase)) { await DeleteFromCosAsync(fileUrl, config); } else { DeleteFromLocal(fileUrl, config); } } /// /// 获取文件URL(返回对象键,不直接返回可访问URL) /// COS模式下需要通过GetSignedUrl获取可访问的预签名URL /// /// 相对路径/对象键 /// 对象键 public string GetFileUrl(string relativePath) { if (string.IsNullOrEmpty(relativePath)) return string.Empty; // 直接返回对象键,需要通过GetSignedUrl获取可访问URL return relativePath; } /// /// 下载文件 /// 当前存储模式下载失败时(如文件上传于另一模式后系统切换了存储模式),回退到另一模式尝试 /// /// 文件URL或对象键 /// 文件流、内容类型、文件名 public async Task<(Stream Stream, string ContentType, string FileName)> DownloadAsync(string fileUrl) { var config = await GetStorageConfigAsync(); var isCosMode = config.Mode.Equals("Cos", StringComparison.OrdinalIgnoreCase); Exception? primaryEx = null; try { if (isCosMode) { return await DownloadFromCosAsync(fileUrl, config); } return await DownloadFromLocalAsync(fileUrl, config); } catch (Exception ex) { primaryEx = ex; } // 回退:尝试另一存储模式(文件可能上传于切换存储模式之前) try { if (isCosMode) { return await DownloadFromLocalAsync(fileUrl, config); } return await DownloadFromCosAsync(fileUrl, config); } catch { // 两种模式都失败,抛出原始异常 throw primaryEx; } } /// /// 生成预签名URL(COS私有读写必须用预签名URL访问) /// /// 文件URL或对象键 /// 过期时间(分钟),默认60分钟 /// 预签名URL public string GetSignedUrl(string fileUrl, int expireMinutes = 60) { // 空值检查 if (string.IsNullOrEmpty(fileUrl)) return string.Empty; // 同步获取存储配置(ASP.NET Core中安全调用) var config = GetStorageConfigAsync().GetAwaiter().GetResult(); // 本地模式:将对象键转为可访问的URL if (config.Mode.Equals("Local", StringComparison.OrdinalIgnoreCase)) { var baseUrl = config.LocalBaseUrl.TrimEnd('/'); var baseDir = config.LocalBaseUrl.Trim('/'); var localKey = fileUrl; // 如果是完整URL(COS签名URL),提取对象键后转为本地URL // 旧数据可能存了 COS 签名 URL,切换到 Local 模式后仍会请求 COS,应转成本地路径 if (fileUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { try { var uri = new Uri(fileUrl); localKey = uri.AbsolutePath.TrimStart('/'); } catch { return fileUrl; // URL解析失败,原样返回 } } // 如果已经是本地URL(以/开头),剥离前缀得到对象键 else if (fileUrl.StartsWith("/")) { localKey = fileUrl.TrimStart('/'); } // 兼容旧数据:如果对象键已包含 LocalBaseUrl 目录前缀(如 uploads/projects/...),剥离避免重复 if (!string.IsNullOrEmpty(baseDir) && localKey.StartsWith(baseDir + "/", StringComparison.OrdinalIgnoreCase)) { localKey = localKey.Substring((baseDir + "/").Length); } // 检查本地文件是否存在:存在则返回签名URL,不存在则返回空字符串 // Local 模式下不回退到 COS,确保完全区分本地与 COS 资源请求 // 旧数据(上传于 COS 模式但未迁移的文件)应通过迁移端点 /api/system/migrate-cos-to-local 补齐 var localPath = Path.Combine(_env.ContentRootPath, config.LocalBasePath, localKey.Replace('/', Path.DirectorySeparatorChar)); if (File.Exists(localPath)) { // 生成签名URL:/uploads/{path}?exp={unix_ts}&sig={hmac_sha256} // 与 COS 临时签名URL对齐,防止 Local 模式资源被永久链接盗用 return GenerateLocalSignedUrl(baseUrl, localKey, expireMinutes); } // 本地文件不存在,返回空字符串(前端展示占位图,不发起 COS 请求) return string.Empty; } // COS模式生成预签名URL var cosXml = GetCosXml(config); // 从文件URL中提取对象键 var key = ExtractObjectKey(fileUrl); // 从存储桶名称中提取APPID var appid = ExtractAppid(config.CosBucket); // 构建预签名请求结构 var preSignatureStruct = new PreSignatureStruct { appid = appid, // 腾讯云账号APPID region = config.CosRegion, // 存储桶地域 bucket = config.CosBucket, // 存储桶名称 key = key, // 对象键 httpMethod = "GET", // HTTP请求方法 isHttps = true, // 使用HTTPS signDurationSecond = expireMinutes * 60L, // 签名有效时长(秒) headers = null, // 签名中需要校验的header queryParameters = null // 签名中需要校验的URL请求参数 }; // 生成预签名URL return cosXml.GenerateSignURL(preSignatureStruct); } // ===== COS存储操作 ===== /// /// 上传文件到COS /// /// 表单文件 /// 存储目录 /// 存储配置 /// COS对象键(folder/filename) private async Task UploadToCosAsync(IFormFile file, string folder, StorageConfig config) { // 获取缓存的COS客户端 var cosXml = GetCosXml(config); // 生成对象键:folder/时间戳_唯一ID.扩展名 // COS 禁止通过默认域名分发 APK/IPA 文件,追加 .bin 绕过限制(原始文件名存于 FileName 字段) var ext = SanitizeExtension(Path.GetExtension(file.FileName)); var fileName = $"{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}{ext}"; var key = $"{folder}/{fileName}"; // 使用文件流上传到COS await using var stream = file.OpenReadStream(); // 构建上传请求(存储桶、对象键、文件流、偏移量、长度) var request = new PutObjectRequest(config.CosBucket, key, stream, 0L, file.Length); // COS SDK的PutObject是同步方法,使用Task.Run包装为异步调用 await Task.Run(() => cosXml.PutObject(request)); // 返回对象键 return key; } /// /// 从COS删除文件 /// /// 文件URL或对象键 /// 存储配置 private async Task DeleteFromCosAsync(string fileUrl, StorageConfig config) { // 获取缓存的COS客户端 var cosXml = GetCosXml(config); // 从文件URL中提取对象键 var key = ExtractObjectKey(fileUrl); // 构建删除请求 var request = new DeleteObjectRequest(config.CosBucket, key); // COS SDK的DeleteObject是同步方法,使用Task.Run包装为异步调用 await Task.Run(() => cosXml.DeleteObject(request)); } /// /// 从COS下载文件 /// /// 文件URL或对象键 /// 存储配置 /// 文件流、内容类型、文件名 private async Task<(Stream Stream, string ContentType, string FileName)> DownloadFromCosAsync(string fileUrl, StorageConfig config) { // 从文件URL中提取对象键 var key = ExtractObjectKey(fileUrl); // 获取文件名和内容类型 var fileName = Path.GetFileName(key); var contentType = GetContentType(fileName); // 生成COS预签名URL(与浏览器下载使用相同的签名机制) // 不调用 GetSignedUrl(),因为它会根据当前存储模式返回本地URL或COS URL // DownloadFromCosAsync 专门处理COS下载,直接生成COS预签名URL var cosXml = GetCosXml(config); var appid = ExtractAppid(config.CosBucket); var preSignatureStruct = new PreSignatureStruct { appid = appid, region = config.CosRegion, bucket = config.CosBucket, key = key, httpMethod = "GET", isHttps = true, signDurationSecond = 10 * 60L, // 10分钟有效期 headers = null, queryParameters = null }; var signedUrl = cosXml.GenerateSignURL(preSignatureStruct); // 使用 HttpClient 流式下载(避免将大文件全部加载到内存) var response = await _downloadHttpClient.GetAsync(signedUrl, HttpCompletionOption.ResponseHeadersRead); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(); response.Dispose(); throw new InvalidOperationException( $"COS下载失败(对象键:{key},存储桶:{config.CosBucket},地域:{config.CosRegion},HTTP {(int)response.StatusCode}):{errorBody}"); } var stream = await response.Content.ReadAsStreamAsync(); return (stream, contentType, fileName); } // ===== 本地存储操作 ===== /// /// 上传到本地文件系统 /// /// 表单文件 /// 存储目录 /// 存储配置 /// COS对象键(folder/fileName),与COS模式保持一致 private async Task UploadToLocalAsync(IFormFile file, string folder, StorageConfig config) { // 构建本地存储根目录 var uploadsRoot = Path.Combine(_env.ContentRootPath, config.LocalBasePath); var targetDir = Path.Combine(uploadsRoot, folder); // 创建目录(如果不存在) Directory.CreateDirectory(targetDir); // 生成文件名:时间戳_唯一ID.扩展名(与 COS 模式保持一致,APK/IPA 追加 .bin) var ext = SanitizeExtension(Path.GetExtension(file.FileName)); var fileName = $"{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}{ext}"; var filePath = Path.Combine(targetDir, fileName); // 写入文件 await using var stream = new FileStream(filePath, FileMode.Create); await file.CopyToAsync(stream); // 返回对象键(folder/fileName),与COS模式保持一致 return $"{folder}/{fileName}"; } /// /// 从本地文件系统删除文件 /// /// 文件URL /// 存储配置 private void DeleteFromLocal(string fileUrl, StorageConfig config) { // 获取本地文件路径 var localPath = GetLocalPath(fileUrl, config); // 删除文件(如果存在) if (File.Exists(localPath)) { File.Delete(localPath); } } /// /// 从本地文件系统下载文件 /// /// 文件URL /// 存储配置 /// 文件流、内容类型、文件名 private Task<(Stream Stream, string ContentType, string FileName)> DownloadFromLocalAsync(string fileUrl, StorageConfig config) { // 获取本地文件路径 var localPath = GetLocalPath(fileUrl, config); // 检查文件是否存在 if (!File.Exists(localPath)) throw new FileNotFoundException("文件不存在", localPath); // 创建文件流 Stream stream = new FileStream(localPath, FileMode.Open, FileAccess.Read); // 获取内容类型 var contentType = GetContentType(localPath); // 获取文件名 var fileName = Path.GetFileName(localPath); return Task.FromResult((stream, contentType, fileName)); } // ===== 辅助方法 ===== /// /// 从数据库读取设置值 /// /// 设置键 /// 默认值 /// 设置值 private async Task GetSettingAsync(string key, string defaultValue) { // 从SystemSettings表查询指定键的值 var setting = await _db.SystemSettings.FirstOrDefaultAsync(s => s.Key == key); return setting?.Value ?? defaultValue; } /// /// 获取存储配置(从数据库读取,带5分钟内存缓存,避免高频 GetSignedUrl 调用压垮数据库) /// /// 存储配置 private async Task GetStorageConfigAsync() { // 快速路径:缓存未过期直接返回(无锁,绝大多数请求走这里) if (_cachedStorageConfig != null && DateTime.UtcNow < _cachedStorageConfigExpiry) { return _cachedStorageConfig; } lock (_configLock) { // 双重检查:其他线程可能已经刷新了缓存 if (_cachedStorageConfig != null && DateTime.UtcNow < _cachedStorageConfigExpiry) { return _cachedStorageConfig; } } // 一次性查询所有存储相关配置,减少数据库访问次数 // 注意:使用 List 并先 ToListAsync 再转字典,避免 EF Core 在 .NET 10 下 // 对数组 + ToDictionaryAsync 的 LINQ 翻译出现 ReadOnlySpan 类型加载异常 var keys = new List { "StorageMode", "CosSecretId", "CosSecretKey", "CosBucket", "CosRegion", "CosSignedUrlExpireMinutes", "LocalBasePath", "LocalBaseUrl" }; var settingsList = await _db.SystemSettings .Where(s => keys.Contains(s.Key)) .ToListAsync(); var settings = settingsList.ToDictionary(s => s.Key, s => s.Value); // 构建存储配置对象 var config = new StorageConfig { Mode = GetValue(settings, "StorageMode", "Cos"), CosSecretId = GetValue(settings, "CosSecretId", ""), CosSecretKey = GetValue(settings, "CosSecretKey", ""), CosBucket = GetValue(settings, "CosBucket", ""), CosRegion = GetValue(settings, "CosRegion", ""), CosExpireMinutes = int.Parse(GetValue(settings, "CosSignedUrlExpireMinutes", "60")), LocalBasePath = GetValue(settings, "LocalBasePath", "wwwroot/uploads"), LocalBaseUrl = GetValue(settings, "LocalBaseUrl", "/uploads") }; // 更新缓存 lock (_configLock) { _cachedStorageConfig = config; _cachedStorageConfigExpiry = DateTime.UtcNow.AddMinutes(ConfigCacheMinutes); } return config; } /// /// 从字典中获取值(带默认值) /// private static string GetValue(Dictionary dict, string key, string defaultValue) { return dict.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value) ? value : defaultValue; } /// /// 获取COS客户端(带缓存,配置变更时重新创建) /// /// 存储配置 /// COS客户端 private CosXml GetCosXml(StorageConfig config) { // 生成配置签名,用于检测配置是否变更 var signature = $"{config.CosSecretId}|{config.CosSecretKey}|{config.CosBucket}|{config.CosRegion}"; lock (_lock) { // 配置未变更时返回缓存的客户端 if (_cachedCosXml != null && _cachedConfigSignature == signature) { return _cachedCosXml; } // 创建COS配置 var cosConfig = new CosXmlConfig.Builder() .SetRegion(config.CosRegion) // 设置地域 .IsHttps(true) // 使用HTTPS .Build(); // 创建密钥提供者(使用永久密钥,签名有效时长600秒) var provider = new DefaultQCloudCredentialProvider( config.CosSecretId, config.CosSecretKey, 600); // 创建COS客户端 _cachedCosXml = new CosXmlServer(cosConfig, provider); _cachedConfigSignature = signature; return _cachedCosXml; } } /// /// 从文件URL中提取对象键 /// 支持完整签名URL、本地URL路径、纯对象键 /// 公开方法,供其他服务调用(保存到数据库前将签名URL转为对象键) /// Local模式下需剥离 LocalBaseUrl 前缀(如 /uploads/projects/cover/x.jpg → projects/cover/x.jpg) /// /// 文件URL或对象键 /// 对象键 public string ExtractKey(string fileUrl) { if (string.IsNullOrEmpty(fileUrl)) return string.Empty; // 剥离查询字符串(Local模式签名URL带 ?exp=...&sig=...,COS签名URL也带查询参数) // 必须在提取对象键之前剥离,否则查询字符串会被当作对象键的一部分 var queryIndex = fileUrl.IndexOf('?'); if (queryIndex >= 0) { fileUrl = fileUrl.Substring(0, queryIndex); } // 完整URL(COS签名URL):提取路径部分作为对象键 if (fileUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { var uri = new Uri(fileUrl); return uri.AbsolutePath.TrimStart('/'); } // 本地URL(以 / 开头):剥离 LocalBaseUrl 前缀,得到纯对象键 if (fileUrl.StartsWith("/")) { var config = GetStorageConfigAsync().GetAwaiter().GetResult(); var baseDir = config.LocalBaseUrl.Trim('/'); var key = fileUrl.TrimStart('/'); if (!string.IsNullOrEmpty(baseDir) && key.StartsWith(baseDir + "/", StringComparison.OrdinalIgnoreCase)) { key = key.Substring((baseDir + "/").Length); } return key; } // 已经是对象键,直接返回 return fileUrl; } /// /// 生成本地签名URL:{baseUrl}/{localKey}?exp={unix_ts}&sig={hmac_sha256_hex} /// 与 COS 临时签名URL对齐,使 Local 模式资源同样具有时效性,防止永久链接被盗用。 /// 签名数据 = "{baseUrl}/{localKey}?exp={exp}"(即 URL 的 path + ?exp= 部分), /// 验证中间件只需 request.Path + query["exp"] 即可重新计算 HMAC,无需读 DB。 /// /// 本地访问基础URL(如 /uploads) /// 对象键(如 avatars/xxx.png) /// 过期时间(分钟) /// 带 exp 与 sig 查询参数的签名URL private string GenerateLocalSignedUrl(string baseUrl, string localKey, int expireMinutes) { var path = $"{baseUrl}/{localKey}"; var exp = DateTimeOffset.UtcNow.AddMinutes(expireMinutes).ToUnixTimeSeconds(); var signingData = $"{path}?exp={exp}"; var secretKey = _config["Jwt:SecretKey"] ?? throw new InvalidOperationException("Jwt:SecretKey 未配置,无法生成 Local 模式签名URL"); var sig = ComputeHmacSha256Hex(signingData, secretKey); return $"{path}?exp={exp}&sig={sig}"; } /// /// 验证本地签名URL(供 Program.cs 中间件调用) /// 签名数据 = "{requestPath}?exp={exp}",与 GenerateLocalSignedUrl 一致。 /// 同时校验 exp 是否过期,签名是否匹配(使用固定时间比较防止时序攻击)。 /// /// 请求路径(如 /uploads/avatars/xxx.png),不含查询字符串 /// exp 查询参数(Unix 时间戳,秒) /// sig 查询参数(HMAC-SHA256 十六进制) /// 签名密钥(Jwt:SecretKey) /// true=签名有效且未过期;false=签名缺失/无效/已过期 public static bool ValidateLocalSignedUrl(string requestPath, string? exp, string? sig, string secretKey) { if (string.IsNullOrEmpty(exp) || string.IsNullOrEmpty(sig) || string.IsNullOrEmpty(secretKey)) return false; if (!long.TryParse(exp, out var expTs)) return false; // 检查过期时间(允许 30 秒时钟偏移) if (expTs < DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 30) return false; var signingData = $"{requestPath}?exp={exp}"; var expectedSig = ComputeHmacSha256Hex(signingData, secretKey); // 固定时间比较,防止时序攻击 return CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(sig), Encoding.ASCII.GetBytes(expectedSig)); } /// /// 计算 HMAC-SHA256 并返回小写十六进制字符串 /// private static string ComputeHmacSha256Hex(string data, string secretKey) { using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data)); // Convert.ToHexString 在 .NET 5+ 可用,输出大写;统一转小写便于 URL 传递 return Convert.ToHexString(hash).ToLowerInvariant(); } /// /// 清除存储配置缓存(在后台修改存储设置后调用,确保后续请求读取最新配置) /// public void ClearConfigCache() { lock (_configLock) { _cachedStorageConfig = null; _cachedStorageConfigExpiry = DateTime.MinValue; } } /// /// 解压 ZIP 文件到 COS(用于 WebGL 资源在线运行) /// 将 ZIP 中的所有文件按原目录结构上传到 COS 指定目录 /// /// ZIP 文件 /// COS 目标目录 /// 解压后的根目录对象键前缀(如 webgl/xxx/) public async Task ExtractZipAsync(IFormFile zipFile, string folder) { if (zipFile == null || zipFile.Length == 0) { throw new ArgumentException("ZIP 文件不能为空"); } // 从数据库读取存储配置 var config = await GetStorageConfigAsync(); // 生成唯一子目录,避免不同资源包文件冲突 var subFolder = $"{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; var basePath = $"{folder}/{subFolder}"; // 打开 ZIP 文件流 using var archive = new ZipArchive(zipFile.OpenReadStream(), ZipArchiveMode.Read); // 遍历 ZIP 中的所有条目 foreach (var entry in archive.Entries) { // 跳过目录条目 if (string.IsNullOrEmpty(entry.Name)) continue; // 构建对象键:basePath + ZIP 内相对路径 // 统一使用正斜杠(COS 对象键使用正斜杠) var relativePath = entry.FullName.Replace('\\', '/').TrimStart('/'); var key = $"{basePath}/{relativePath}"; // 读取条目内容 using var entryStream = entry.Open(); using var ms = new MemoryStream(); await entryStream.CopyToAsync(ms); var bytes = ms.ToArray(); // 上传到 COS 或本地 if (config.Mode.Equals("Cos", StringComparison.OrdinalIgnoreCase)) { await UploadBytesToCosAsync(bytes, key, config); } else { UploadBytesToLocal(bytes, key, config); } } // 返回解压后的根目录对象键前缀 return basePath + "/"; } /// /// 上传字节数组到 COS /// private async Task UploadBytesToCosAsync(byte[] bytes, string key, StorageConfig config) { var cosXml = GetCosXml(config); using var stream = new MemoryStream(bytes); var request = new PutObjectRequest(config.CosBucket, key, stream, 0L, bytes.Length); await Task.Run(() => cosXml.PutObject(request)); } /// /// 读取文件文本内容(用于 WebGL index.html 代理读取) /// 复用 DownloadAsync 获取文件流,再读取为文本 /// public async Task ReadFileTextAsync(string fileUrl) { var (stream, _, _) = await DownloadAsync(fileUrl); using var reader = new StreamReader(stream); return await reader.ReadToEndAsync(); } /// /// 上传字节数组到本地 /// private void UploadBytesToLocal(byte[] bytes, string key, StorageConfig config) { var localPath = Path.Combine(_env.ContentRootPath, config.LocalBasePath, key.Replace('/', Path.DirectorySeparatorChar)); var dir = Path.GetDirectoryName(localPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); File.WriteAllBytes(localPath, bytes); } /// /// 从文件URL中提取COS对象键 /// 支持完整URL、本地URL路径、纯对象键 /// /// 文件URL或对象键 /// COS对象键 private static string ExtractObjectKey(string fileUrl) { if (string.IsNullOrEmpty(fileUrl)) return string.Empty; // 如果是完整URL,提取路径部分作为对象键 if (fileUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { var uri = new Uri(fileUrl); return uri.AbsolutePath.TrimStart('/'); } // 如果以斜杠开头(本地URL格式),去掉前导斜杠 if (fileUrl.StartsWith("/")) { return fileUrl.TrimStart('/'); } // 已经是对象键,直接返回 return fileUrl; } /// /// 规范化文件扩展名 /// COS 禁止通过默认域名分发 APK/IPA 文件(返回 DownloadForbidden 403) /// 追加 .bin 后缀绕过限制,原始文件名保存在 ProjectPackage.FileName 字段 /// private static string SanitizeExtension(string ext) { if (string.IsNullOrEmpty(ext)) return ext; if (ext.Equals(".apk", StringComparison.OrdinalIgnoreCase) || ext.Equals(".ipa", StringComparison.OrdinalIgnoreCase)) { return ext + ".bin"; } return ext; } /// /// 从存储桶名称中提取APPID /// 存储桶格式:bucketname-APPID(如 millisim-1300718495) /// /// 存储桶名称 /// APPID private static string ExtractAppid(string bucket) { if (string.IsNullOrEmpty(bucket)) return string.Empty; // 取最后一个"-"之后的部分作为APPID var lastIndex = bucket.LastIndexOf('-'); return lastIndex >= 0 ? bucket.Substring(lastIndex + 1) : string.Empty; } /// /// 获取本地文件路径 /// /// 文件URL /// 存储配置 /// 本地文件绝对路径 private string GetLocalPath(string fileUrl, StorageConfig config) { // 统一提取对象键(兼容 /uploads/folder/file、folder/file、/folder/file 等格式) var key = ExtractObjectKey(fileUrl); // 剥离 LocalBasePath 前缀(如 wwwroot/uploads/) var localBasePath = config.LocalBasePath.Replace('/', Path.DirectorySeparatorChar).TrimStart(Path.DirectorySeparatorChar); if (key.StartsWith(localBasePath, StringComparison.OrdinalIgnoreCase)) { key = key.Substring(localBasePath.Length).TrimStart(Path.DirectorySeparatorChar, '/'); } // 剥离 LocalBaseUrl 目录前缀(如 uploads/),兼容旧数据中误存了 URL 目录前缀的对象键 var baseUrlDir = config.LocalBaseUrl.Trim('/'); if (!string.IsNullOrEmpty(baseUrlDir) && key.StartsWith(baseUrlDir + "/", StringComparison.OrdinalIgnoreCase)) { key = key.Substring((baseUrlDir + "/").Length); } return Path.Combine(_env.ContentRootPath, config.LocalBasePath, key.Replace('/', Path.DirectorySeparatorChar)); } /// /// 根据文件扩展名获取内容类型 /// /// 文件路径或文件名 /// 内容类型 private static string GetContentType(string path) { var ext = Path.GetExtension(path).ToLower(); return ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".svg" => "image/svg+xml", ".webp" => "image/webp", ".mp4" => "video/mp4", ".webm" => "video/webm", ".zip" => "application/zip", ".rar" => "application/x-rar-compressed", ".apk" => "application/vnd.android.package-archive", ".pdf" => "application/pdf", ".json" => "application/json", ".html" => "text/html", _ => "application/octet-stream" }; } /// /// 列出存储桶内所有对象键(分页列出,用于工厂重置清理) /// public async Task> ListAllObjectsAsync() { var config = await GetStorageConfigAsync(); if (config.Mode.Equals("Cos", StringComparison.OrdinalIgnoreCase)) { return await ListAllCosObjectsAsync(config); } return ListAllLocalObjects(config); } /// /// 列出 COS 桶内所有对象(分页,每页 1000) /// private async Task> ListAllCosObjectsAsync(StorageConfig config) { var cosXml = GetCosXml(config); var allKeys = new List(); string? marker = null; do { var request = new GetBucketRequest(config.CosBucket); // 不设置 prefix(空字符串会导致 COS 返回 400 Bad Request),默认列出全部对象 if (!string.IsNullOrEmpty(marker)) { request.SetMarker(marker); } request.SetMaxKeys("1000"); var result = await Task.Run(() => cosXml.GetBucket(request)); if (result?.listBucket == null) break; foreach (var content in result.listBucket.contentsList) { if (!string.IsNullOrEmpty(content.key)) { allKeys.Add(content.key); } } marker = result.listBucket.isTruncated ? result.listBucket.nextMarker : null; } while (!string.IsNullOrEmpty(marker)); return allKeys; } /// /// 列出本地存储根目录下所有文件(相对路径,正斜杠分隔) /// private List ListAllLocalObjects(StorageConfig config) { var uploadsRoot = Path.Combine(_env.ContentRootPath, config.LocalBasePath); if (!Directory.Exists(uploadsRoot)) return new List(); return Directory.GetFiles(uploadsRoot, "*", SearchOption.AllDirectories) .Select(p => Path.GetRelativePath(uploadsRoot, p).Replace('\\', '/')) .ToList(); } /// /// 批量删除对象(每批最多 1000 个,用于工厂重置清理) /// public async Task DeleteObjectsAsync(IEnumerable keys) { var keyList = keys.Where(k => !string.IsNullOrEmpty(k)).ToList(); if (keyList.Count == 0) return 0; var config = await GetStorageConfigAsync(); if (config.Mode.Equals("Cos", StringComparison.OrdinalIgnoreCase)) { return await DeleteMultiCosObjectsAsync(keyList, config); } return DeleteLocalObjects(keyList, config); } /// /// COS 批量删除(DeleteMultiObject,每批 1000) /// private async Task DeleteMultiCosObjectsAsync(List keys, StorageConfig config) { var cosXml = GetCosXml(config); var deletedCount = 0; foreach (var chunk in keys.Chunk(1000)) { var request = new DeleteMultiObjectRequest(config.CosBucket); foreach (var key in chunk) { request.SetDeleteKey(key); } await Task.Run(() => cosXml.DeleteMultiObjects(request)); deletedCount += chunk.Length; } return deletedCount; } /// /// 本地批量删除文件 /// private int DeleteLocalObjects(List keys, StorageConfig config) { var deletedCount = 0; foreach (var key in keys) { try { var localPath = Path.Combine(_env.ContentRootPath, config.LocalBasePath, key.Replace('/', Path.DirectorySeparatorChar)); if (File.Exists(localPath)) { File.Delete(localPath); deletedCount++; } } catch { // 静默失败,继续删除其他文件 } } return deletedCount; } /// /// 将 COS 中所有对象迁移到本地存储(用于从 COS 切换到 Local 模式后补齐旧文件) /// 遍历 COS 桶内所有对象,下载到本地对应的目录结构中 /// /// 迁移的文件数量 public async Task MigrateCosToLocalAsync() { var config = await GetStorageConfigAsync(); // 必须有完整的 COS 配置才能迁移 if (string.IsNullOrEmpty(config.CosSecretId) || string.IsNullOrEmpty(config.CosSecretKey) || string.IsNullOrEmpty(config.CosBucket) || string.IsNullOrEmpty(config.CosRegion)) { throw new InvalidOperationException("COS 配置不完整,无法迁移"); } // 列出 COS 中所有对象 var cosKeys = await ListAllCosObjectsAsync(config); if (cosKeys.Count == 0) return 0; var uploadsRoot = Path.Combine(_env.ContentRootPath, config.LocalBasePath); if (!Directory.Exists(uploadsRoot)) Directory.CreateDirectory(uploadsRoot); var migrated = 0; var cosXml = GetCosXml(config); foreach (var key in cosKeys) { // 跳过目录标记对象(以 / 结尾的空对象) if (key.EndsWith("/")) continue; // 计算本地文件路径 var localPath = Path.Combine(uploadsRoot, key.Replace('/', Path.DirectorySeparatorChar)); // 如果本地已存在同名文件,跳过(避免重复下载) if (File.Exists(localPath)) { migrated++; continue; } try { // 从 COS 下载文件内容 var request = new GetObjectBytesRequest(config.CosBucket, key); var result = await Task.Run(() => cosXml.GetObject(request)); var bytes = result.content; // 确保本地目录存在 var dir = Path.GetDirectoryName(localPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); // 写入本地文件 await File.WriteAllBytesAsync(localPath, bytes); migrated++; } catch { // 单个文件下载失败不影响整体迁移,继续下一个 } } return migrated; } /// /// 迁移已有的 APK/IPA 文件扩展名 /// COS 禁止通过默认域名分发 APK/IPA 文件(返回 DownloadForbidden 403) /// 将对象键从 xxx.apk 服务端复制为 xxx.apk.bin,删除旧对象,更新数据库 /// public async Task MigrateApkExtensionsAsync() { var config = await GetStorageConfigAsync(); if (!config.Mode.Equals("Cos", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("当前非 COS 模式,无需迁移 APK 扩展名"); } // 查询所有 .apk/.ipa 结尾的资源包 var packages = await _db.ProjectPackages .Where(p => p.Url.EndsWith(".apk") || p.Url.EndsWith(".ipa")) .ToListAsync(); if (packages.Count == 0) return 0; var cosXml = GetCosXml(config); var appid = ExtractAppid(config.CosBucket); int migrated = 0; foreach (var pkg in packages) { var oldKey = ExtractObjectKey(pkg.Url); var newKey = oldKey + ".bin"; try { // 服务端复制(COS 内部操作,不受 APK 分发限制) var source = new CopySourceStruct(appid, config.CosBucket, config.CosRegion, oldKey); var copyRequest = new CopyObjectRequest(config.CosBucket, newKey); copyRequest.SetCopySource(source); await Task.Run(() => cosXml.CopyObject(copyRequest)); // 删除旧对象 var deleteRequest = new DeleteObjectRequest(config.CosBucket, oldKey); await Task.Run(() => cosXml.DeleteObject(deleteRequest)); // 更新数据库对象键 pkg.Url = newKey; migrated++; } catch (Exception ex) { // 单个文件迁移失败不影响整体,继续下一个 Console.WriteLine($"迁移 APK 文件失败({oldKey}):{ex.Message}"); } } if (migrated > 0) { await _db.SaveChangesAsync(); } return migrated; } /// /// 存储配置内部类 /// private class StorageConfig { /// 存储模式:Local=本地存储,Cos=腾讯云COS public string Mode { get; set; } = "Cos"; /// 腾讯云COS SecretId public string CosSecretId { get; set; } = ""; /// 腾讯云COS SecretKey public string CosSecretKey { get; set; } = ""; /// 腾讯云COS 存储桶名称(格式:bucketname-APPID) public string CosBucket { get; set; } = ""; /// 腾讯云COS 地域(如 ap-guangzhou) public string CosRegion { get; set; } = ""; /// COS临时URL有效期(分钟) public int CosExpireMinutes { get; set; } = 60; /// 本地存储根目录 public string LocalBasePath { get; set; } = "wwwroot/uploads"; /// 本地访问基础URL public string LocalBaseUrl { get; set; } = "/uploads"; } }