1202 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// 文件存储服务接口
/// </summary>
public interface IFileStorageService
{
/// <summary>
/// 上传文件
/// </summary>
Task<string> UploadAsync(IFormFile file, string folder);
/// <summary>
/// 删除文件
/// </summary>
Task DeleteAsync(string fileUrl);
/// <summary>
/// 获取文件URL返回对象键
/// </summary>
string GetFileUrl(string relativePath);
/// <summary>
/// 下载文件
/// </summary>
Task<(Stream Stream, string ContentType, string FileName)> DownloadAsync(string fileUrl);
/// <summary>
/// 生成预签名URLCOS私有读写必须用预签名URL访问
/// </summary>
string GetSignedUrl(string fileUrl, int expireMinutes = 60);
/// <summary>
/// 从文件URL中提取对象键用于数据库存储避免签名URL过期导致数据丢失
/// </summary>
string ExtractKey(string fileUrl);
/// <summary>
/// 清除存储配置缓存(在后台修改存储设置后调用,确保后续请求读取最新配置)
/// </summary>
void ClearConfigCache();
/// <summary>
/// 解压 ZIP 文件到 COS用于 WebGL 资源在线运行)
/// </summary>
/// <param name="zipFile">ZIP 文件</param>
/// <param name="folder">COS 目标目录</param>
/// <returns>解压后的根目录对象键前缀(如 webgl/xxx/</returns>
Task<string> ExtractZipAsync(IFormFile zipFile, string folder);
/// <summary>
/// 读取文件文本内容(用于 WebGL index.html 代理读取)
/// </summary>
/// <param name="fileUrl">文件URL或对象键</param>
/// <returns>文件文本内容</returns>
Task<string> ReadFileTextAsync(string fileUrl);
/// <summary>
/// 列出存储桶内所有对象键(分页列出,用于工厂重置清理)
/// </summary>
Task<List<string>> ListAllObjectsAsync();
/// <summary>
/// 批量删除对象(每批最多 1000 个,用于工厂重置清理)
/// </summary>
Task<int> DeleteObjectsAsync(IEnumerable<string> keys);
/// <summary>
/// 将 COS 中所有对象迁移到本地存储(用于从 COS 切换到 Local 模式后补齐旧文件)
/// </summary>
/// <returns>迁移的文件数量</returns>
Task<int> MigrateCosToLocalAsync();
/// <summary>
/// 迁移已有的 APK/IPA 文件扩展名COS 禁止通过默认域名分发 APK/IPA 文件)
/// 将对象键从 xxx.apk 复制为 xxx.apk.bin删除旧对象更新数据库
/// </summary>
/// <returns>迁移的文件数量</returns>
Task<int> MigrateApkExtensionsAsync();
}
/// <summary>
/// 文件存储服务实现
/// 支持本地存储和腾讯云COS存储默认使用COS模式
/// 配置信息从数据库 SystemSettings 表读取
/// </summary>
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();
/// <summary>
/// 构造函数
/// </summary>
/// <param name="db">数据库上下文</param>
/// <param name="env">Web主机环境</param>
/// <param name="config">配置(用于读取 Jwt:SecretKey 作为本地签名URL的签名密钥</param>
public FileStorageService(MilliSimDbContext db, IWebHostEnvironment env, IConfiguration config)
{
_db = db;
_env = env;
_config = config;
}
/// <summary>
/// 上传文件
/// COS模式返回对象键folder/filename本地模式返回访问URL
/// </summary>
/// <param name="file">表单文件</param>
/// <param name="folder">存储目录</param>
/// <returns>COS对象键或本地访问URL</returns>
public async Task<string> 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);
}
/// <summary>
/// 删除文件
/// </summary>
/// <param name="fileUrl">文件URL或对象键</param>
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);
}
}
/// <summary>
/// 获取文件URL返回对象键不直接返回可访问URL
/// COS模式下需要通过GetSignedUrl获取可访问的预签名URL
/// </summary>
/// <param name="relativePath">相对路径/对象键</param>
/// <returns>对象键</returns>
public string GetFileUrl(string relativePath)
{
if (string.IsNullOrEmpty(relativePath)) return string.Empty;
// 直接返回对象键需要通过GetSignedUrl获取可访问URL
return relativePath;
}
/// <summary>
/// 下载文件
/// 当前存储模式下载失败时(如文件上传于另一模式后系统切换了存储模式),回退到另一模式尝试
/// </summary>
/// <param name="fileUrl">文件URL或对象键</param>
/// <returns>文件流、内容类型、文件名</returns>
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;
}
}
/// <summary>
/// 生成预签名URLCOS私有读写必须用预签名URL访问
/// </summary>
/// <param name="fileUrl">文件URL或对象键</param>
/// <param name="expireMinutes">过期时间分钟默认60分钟</param>
/// <returns>预签名URL</returns>
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;
// 如果是完整URLCOS签名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存储操作 =====
/// <summary>
/// 上传文件到COS
/// </summary>
/// <param name="file">表单文件</param>
/// <param name="folder">存储目录</param>
/// <param name="config">存储配置</param>
/// <returns>COS对象键folder/filename</returns>
private async Task<string> 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;
}
/// <summary>
/// 从COS删除文件
/// </summary>
/// <param name="fileUrl">文件URL或对象键</param>
/// <param name="config">存储配置</param>
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));
}
/// <summary>
/// 从COS下载文件
/// </summary>
/// <param name="fileUrl">文件URL或对象键</param>
/// <param name="config">存储配置</param>
/// <returns>文件流、内容类型、文件名</returns>
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);
}
// ===== 本地存储操作 =====
/// <summary>
/// 上传到本地文件系统
/// </summary>
/// <param name="file">表单文件</param>
/// <param name="folder">存储目录</param>
/// <param name="config">存储配置</param>
/// <returns>COS对象键folder/fileName与COS模式保持一致</returns>
private async Task<string> 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}";
}
/// <summary>
/// 从本地文件系统删除文件
/// </summary>
/// <param name="fileUrl">文件URL</param>
/// <param name="config">存储配置</param>
private void DeleteFromLocal(string fileUrl, StorageConfig config)
{
// 获取本地文件路径
var localPath = GetLocalPath(fileUrl, config);
// 删除文件(如果存在)
if (File.Exists(localPath))
{
File.Delete(localPath);
}
}
/// <summary>
/// 从本地文件系统下载文件
/// </summary>
/// <param name="fileUrl">文件URL</param>
/// <param name="config">存储配置</param>
/// <returns>文件流、内容类型、文件名</returns>
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));
}
// ===== 辅助方法 =====
/// <summary>
/// 从数据库读取设置值
/// </summary>
/// <param name="key">设置键</param>
/// <param name="defaultValue">默认值</param>
/// <returns>设置值</returns>
private async Task<string> GetSettingAsync(string key, string defaultValue)
{
// 从SystemSettings表查询指定键的值
var setting = await _db.SystemSettings.FirstOrDefaultAsync(s => s.Key == key);
return setting?.Value ?? defaultValue;
}
/// <summary>
/// 获取存储配置从数据库读取带5分钟内存缓存避免高频 GetSignedUrl 调用压垮数据库)
/// </summary>
/// <returns>存储配置</returns>
private async Task<StorageConfig> GetStorageConfigAsync()
{
// 快速路径:缓存未过期直接返回(无锁,绝大多数请求走这里)
if (_cachedStorageConfig != null && DateTime.UtcNow < _cachedStorageConfigExpiry)
{
return _cachedStorageConfig;
}
lock (_configLock)
{
// 双重检查:其他线程可能已经刷新了缓存
if (_cachedStorageConfig != null && DateTime.UtcNow < _cachedStorageConfigExpiry)
{
return _cachedStorageConfig;
}
}
// 一次性查询所有存储相关配置,减少数据库访问次数
// 注意:使用 List<string> 并先 ToListAsync 再转字典,避免 EF Core 在 .NET 10 下
// 对数组 + ToDictionaryAsync 的 LINQ 翻译出现 ReadOnlySpan<string> 类型加载异常
var keys = new List<string> { "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;
}
/// <summary>
/// 从字典中获取值(带默认值)
/// </summary>
private static string GetValue(Dictionary<string, string> dict, string key, string defaultValue)
{
return dict.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value) ? value : defaultValue;
}
/// <summary>
/// 获取COS客户端带缓存配置变更时重新创建
/// </summary>
/// <param name="config">存储配置</param>
/// <returns>COS客户端</returns>
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;
}
}
/// <summary>
/// 从文件URL中提取对象键
/// 支持完整签名URL、本地URL路径、纯对象键
/// 公开方法供其他服务调用保存到数据库前将签名URL转为对象键
/// Local模式下需剥离 LocalBaseUrl 前缀(如 /uploads/projects/cover/x.jpg → projects/cover/x.jpg
/// </summary>
/// <param name="fileUrl">文件URL或对象键</param>
/// <returns>对象键</returns>
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);
}
// 完整URLCOS签名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;
}
/// <summary>
/// 生成本地签名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。
/// </summary>
/// <param name="baseUrl">本地访问基础URL如 /uploads</param>
/// <param name="localKey">对象键(如 avatars/xxx.png</param>
/// <param name="expireMinutes">过期时间(分钟)</param>
/// <returns>带 exp 与 sig 查询参数的签名URL</returns>
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}";
}
/// <summary>
/// 验证本地签名URL供 Program.cs 中间件调用)
/// 签名数据 = "{requestPath}?exp={exp}",与 GenerateLocalSignedUrl 一致。
/// 同时校验 exp 是否过期,签名是否匹配(使用固定时间比较防止时序攻击)。
/// </summary>
/// <param name="requestPath">请求路径(如 /uploads/avatars/xxx.png不含查询字符串</param>
/// <param name="exp">exp 查询参数Unix 时间戳,秒)</param>
/// <param name="sig">sig 查询参数HMAC-SHA256 十六进制)</param>
/// <param name="secretKey">签名密钥Jwt:SecretKey</param>
/// <returns>true=签名有效且未过期false=签名缺失/无效/已过期</returns>
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));
}
/// <summary>
/// 计算 HMAC-SHA256 并返回小写十六进制字符串
/// </summary>
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();
}
/// <summary>
/// 清除存储配置缓存(在后台修改存储设置后调用,确保后续请求读取最新配置)
/// </summary>
public void ClearConfigCache()
{
lock (_configLock)
{
_cachedStorageConfig = null;
_cachedStorageConfigExpiry = DateTime.MinValue;
}
}
/// <summary>
/// 解压 ZIP 文件到 COS用于 WebGL 资源在线运行)
/// 将 ZIP 中的所有文件按原目录结构上传到 COS 指定目录
/// </summary>
/// <param name="zipFile">ZIP 文件</param>
/// <param name="folder">COS 目标目录</param>
/// <returns>解压后的根目录对象键前缀(如 webgl/xxx/</returns>
public async Task<string> 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 + "/";
}
/// <summary>
/// 上传字节数组到 COS
/// </summary>
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));
}
/// <summary>
/// 读取文件文本内容(用于 WebGL index.html 代理读取)
/// 复用 DownloadAsync 获取文件流,再读取为文本
/// </summary>
public async Task<string> ReadFileTextAsync(string fileUrl)
{
var (stream, _, _) = await DownloadAsync(fileUrl);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
/// <summary>
/// 上传字节数组到本地
/// </summary>
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);
}
/// <summary>
/// 从文件URL中提取COS对象键
/// 支持完整URL、本地URL路径、纯对象键
/// </summary>
/// <param name="fileUrl">文件URL或对象键</param>
/// <returns>COS对象键</returns>
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;
}
/// <summary>
/// 规范化文件扩展名
/// COS 禁止通过默认域名分发 APK/IPA 文件(返回 DownloadForbidden 403
/// 追加 .bin 后缀绕过限制,原始文件名保存在 ProjectPackage.FileName 字段
/// </summary>
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;
}
/// <summary>
/// 从存储桶名称中提取APPID
/// 存储桶格式bucketname-APPID如 millisim-1300718495
/// </summary>
/// <param name="bucket">存储桶名称</param>
/// <returns>APPID</returns>
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;
}
/// <summary>
/// 获取本地文件路径
/// </summary>
/// <param name="fileUrl">文件URL</param>
/// <param name="config">存储配置</param>
/// <returns>本地文件绝对路径</returns>
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));
}
/// <summary>
/// 根据文件扩展名获取内容类型
/// </summary>
/// <param name="path">文件路径或文件名</param>
/// <returns>内容类型</returns>
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"
};
}
/// <summary>
/// 列出存储桶内所有对象键(分页列出,用于工厂重置清理)
/// </summary>
public async Task<List<string>> ListAllObjectsAsync()
{
var config = await GetStorageConfigAsync();
if (config.Mode.Equals("Cos", StringComparison.OrdinalIgnoreCase))
{
return await ListAllCosObjectsAsync(config);
}
return ListAllLocalObjects(config);
}
/// <summary>
/// 列出 COS 桶内所有对象(分页,每页 1000
/// </summary>
private async Task<List<string>> ListAllCosObjectsAsync(StorageConfig config)
{
var cosXml = GetCosXml(config);
var allKeys = new List<string>();
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;
}
/// <summary>
/// 列出本地存储根目录下所有文件(相对路径,正斜杠分隔)
/// </summary>
private List<string> ListAllLocalObjects(StorageConfig config)
{
var uploadsRoot = Path.Combine(_env.ContentRootPath, config.LocalBasePath);
if (!Directory.Exists(uploadsRoot)) return new List<string>();
return Directory.GetFiles(uploadsRoot, "*", SearchOption.AllDirectories)
.Select(p => Path.GetRelativePath(uploadsRoot, p).Replace('\\', '/'))
.ToList();
}
/// <summary>
/// 批量删除对象(每批最多 1000 个,用于工厂重置清理)
/// </summary>
public async Task<int> DeleteObjectsAsync(IEnumerable<string> 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);
}
/// <summary>
/// COS 批量删除DeleteMultiObject每批 1000
/// </summary>
private async Task<int> DeleteMultiCosObjectsAsync(List<string> 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;
}
/// <summary>
/// 本地批量删除文件
/// </summary>
private int DeleteLocalObjects(List<string> 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;
}
/// <summary>
/// 将 COS 中所有对象迁移到本地存储(用于从 COS 切换到 Local 模式后补齐旧文件)
/// 遍历 COS 桶内所有对象,下载到本地对应的目录结构中
/// </summary>
/// <returns>迁移的文件数量</returns>
public async Task<int> 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;
}
/// <summary>
/// 迁移已有的 APK/IPA 文件扩展名
/// COS 禁止通过默认域名分发 APK/IPA 文件(返回 DownloadForbidden 403
/// 将对象键从 xxx.apk 服务端复制为 xxx.apk.bin删除旧对象更新数据库
/// </summary>
public async Task<int> 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;
}
/// <summary>
/// 存储配置内部类
/// </summary>
private class StorageConfig
{
/// <summary>存储模式Local=本地存储Cos=腾讯云COS</summary>
public string Mode { get; set; } = "Cos";
/// <summary>腾讯云COS SecretId</summary>
public string CosSecretId { get; set; } = "";
/// <summary>腾讯云COS SecretKey</summary>
public string CosSecretKey { get; set; } = "";
/// <summary>腾讯云COS 存储桶名称格式bucketname-APPID</summary>
public string CosBucket { get; set; } = "";
/// <summary>腾讯云COS 地域(如 ap-guangzhou</summary>
public string CosRegion { get; set; } = "";
/// <summary>COS临时URL有效期分钟</summary>
public int CosExpireMinutes { get; set; } = 60;
/// <summary>本地存储根目录</summary>
public string LocalBasePath { get; set; } = "wwwroot/uploads";
/// <summary>本地访问基础URL</summary>
public string LocalBaseUrl { get; set; } = "/uploads";
}
}