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