1202 lines
46 KiB
C#
1202 lines
46 KiB
C#
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>
|
||
/// 生成预签名URL(COS私有读写必须用预签名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>
|
||
/// 生成预签名URL(COS私有读写必须用预签名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;
|
||
|
||
// 如果是完整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存储操作 =====
|
||
|
||
/// <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);
|
||
}
|
||
|
||
// 完整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;
|
||
}
|
||
|
||
/// <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";
|
||
}
|
||
}
|