Compare commits

...

5 Commits

Author SHA1 Message Date
943ba539a4 feat: cos
All checks were successful
Build and Deploy / deploy (push) Successful in 1m43s
2026-09-10 17:32:49 +08:00
23eb80931a Merge remote-tracking branch 'prod/master' 2026-09-09 15:52:35 +08:00
c886e8cef3 Merge remote-tracking branch 'prod/master' 2026-09-09 14:36:10 +08:00
8f7a4c1405 Merge remote-tracking branch 'origin/master' 2026-09-09 14:35:46 +08:00
93bef462fb feat: 迁移到gitea 2026-09-05 16:33:28 +08:00
20 changed files with 554 additions and 161 deletions

View File

@ -1,8 +1,8 @@
using Fake.Application;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
@ -22,7 +22,7 @@ public sealed class LabelPdfGenerationService(
ISystemOtherConfigRepository systemOtherConfigRepository,
IOrderRepository orderRepository,
OrderFileManager orderFileManager,
IWebHostEnvironment webHostEnvironment,
IFileStorage fileStorage,
ILogger<LabelPdfGenerationService> logger)
{
public async Task<LabelPdfGenerateResult> GenerateAndPersistAsync(
@ -158,11 +158,6 @@ public sealed class LabelPdfGenerationService(
private async Task<string> SavePdfAsync(string serviceNo, byte[] pdfBytes, CancellationToken cancellationToken)
{
var webRoot = string.IsNullOrWhiteSpace(webHostEnvironment.WebRootPath)
? Path.Combine(webHostEnvironment.ContentRootPath, "wwwroot")
: webHostEnvironment.WebRootPath;
var directory = Path.Combine(webRoot, "uploads", "prints", "orders");
Directory.CreateDirectory(directory);
var safeNo = string.IsNullOrWhiteSpace(serviceNo) ? "order" : serviceNo.Trim();
foreach (var c in Path.GetInvalidFileNameChars())
{
@ -170,9 +165,9 @@ public sealed class LabelPdfGenerationService(
}
var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf";
var absolute = Path.Combine(directory, fileName);
await File.WriteAllBytesAsync(absolute, pdfBytes, cancellationToken);
return $"/uploads/prints/orders/{fileName}";
var relativeKey = $"uploads/prints/orders/{fileName}";
await using var stream = new MemoryStream(pdfBytes);
return await fileStorage.SaveAsync(stream, relativeKey, "application/pdf", cancellationToken);
}
}

View File

@ -29,6 +29,7 @@
<PackageReference Include="MiniExcel" Version="1.43.1" />
<PackageReference Include="QuestPDF" Version="2025.7.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.49" />
<PackageReference Include="ZXing.Net" Version="0.16.10" />
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" />
</ItemGroup>

View File

@ -13,6 +13,7 @@ using Oms.Application.Integrations.PlatformShop;
using Oms.Application.Labeling;
using Oms.Application.Labeling.Hiprint;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Application.Workers;
using Oms.Domain;
@ -51,6 +52,7 @@ public class OmsApplicationModule : FakeModule
context.Services.AddTransient<ILabelPdfRenderer, QuestPdfLabelRenderer>();
context.Services.AddTransient<LabelPdfGenerationService>();
context.Services.AddOmsFileStorage(configuration);
context.Services.AddOmsSmallBagTrack(configuration);
context.Services.AddOmsItfxIntegration(configuration);
context.Services.AddOmsPlatformShopIntegration(configuration);

View File

@ -1,9 +1,8 @@
using Fake.Application;
using Fake.DependencyInjection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Oms.Application.Dtos.Orders;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
@ -15,7 +14,7 @@ namespace Oms.Application.Services.Orders;
/// </summary>
public sealed class OrderFileManager(
IOrderFileRepository orderFileRepository,
IWebHostEnvironment webHostEnvironment) : IScopedDependency
IFileStorage fileStorage) : IScopedDependency
{
private static readonly HashSet<string> ImageExt =
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
@ -303,21 +302,10 @@ public sealed class OrderFileManager(
}
}
var webRoot = string.IsNullOrWhiteSpace(webHostEnvironment.WebRootPath)
? Path.Combine(webHostEnvironment.ContentRootPath, "wwwroot")
: webHostEnvironment.WebRootPath;
var relativeDir = Path.Combine("uploads", "order-files", orderId.ToString(), fileType);
var absoluteDir = Path.Combine(webRoot, relativeDir);
Directory.CreateDirectory(absoluteDir);
var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
var absolutePath = Path.Combine(absoluteDir, storedName);
await using (var stream = File.Create(absolutePath))
{
await file.CopyToAsync(stream);
}
var url = $"/uploads/order-files/{orderId}/{fileType}/{storedName}".Replace('\\', '/');
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
await using var stream = file.OpenReadStream();
var url = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType);
return (url, Path.GetFileName(file.FileName), file.ContentType, file.Length);
}

View File

@ -12,6 +12,7 @@ using Oms.Application.Dtos.Shared;
using Oms.Application.Messaging;
using Oms.Application.Services.Orders.Templates;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.OrdersAggregate;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
@ -487,13 +488,15 @@ public partial class OrderService
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
}
var absolutePath = OmsFileUploadHelper.ResolveWebRootAbsolutePath(WebHostEnvironment, template.SourceFilePath);
if (!File.Exists(absolutePath))
if (!await FileStorage.ExistsAsync(template.SourceFilePath))
{
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
}
var bytes = await File.ReadAllBytesAsync(absolutePath);
await using var stream = await FileStorage.OpenReadAsync(template.SourceFilePath);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
var downloadName = string.IsNullOrWhiteSpace(template.SourceFileName)
? $"{template.Name}.xlsx"
: template.SourceFileName;
@ -609,8 +612,6 @@ public partial class OrderService
private async Task<string> CreateOrderPrintFileAsync(OrderOutput order)
{
var directory = Path.Combine(GetWebRootDirectory(), "uploads", "prints", "orders");
Directory.CreateDirectory(directory);
var fileName = $"{order.ServiceNo}-{DateTime.Now:yyyyMMddHHmmss}.html";
var html = $"""
<!DOCTYPE html>
@ -625,8 +626,9 @@ public partial class OrderService
</body>
</html>
""";
await File.WriteAllTextAsync(Path.Combine(directory, fileName), html, Encoding.UTF8);
return $"/uploads/prints/orders/{fileName}";
var relativeKey = $"uploads/prints/orders/{fileName}";
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(html));
return await FileStorage.SaveAsync(stream, relativeKey, "text/html; charset=utf-8");
}
private string GetOrderExportDirectory() => Path.Combine(GetWebRootDirectory(), "uploads", "exports", "orders");
@ -640,6 +642,17 @@ public partial class OrderService
return webRoot;
}
private async Task CleanupExpiredLocalExportFilesAsync()
{
// COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。
if (FileStorage is not LocalFileStorage)
{
return;
}
await CleanupExpiredFilesAsync(GetOrderExportDirectory());
}
private async Task CleanupExpiredFilesAsync(string directory)
{
if (!Directory.Exists(directory))

View File

@ -77,7 +77,7 @@ public partial class OrderService
var (relativePath, originalName) = await OmsFileUploadHelper.SaveOrderTemplateSampleAsync(
input.File,
customerId,
WebHostEnvironment);
FileStorage);
var suggested = OrderTemplateHeaderMatcher.BuildSuggestedMapping(OrderTemplateKeys.DedicatedSmallParcelStandard, headers);
var entity = new OrderTemplate

View File

@ -8,6 +8,7 @@ using Oms.Application.Dtos.Shared;
using Oms.Application.DataPerm;
using Oms.Application.Labeling;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Newtonsoft.Json;
using Oms.Application.Services.Orders.Batch;
using Oms.Domain.ChannelsAggregate.Repositories;
@ -34,6 +35,7 @@ public partial class OrderService : ApplicationService
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService<IPickupOrderRepository>();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
private LabelPdfGenerationService LabelPdfGenerationService =>
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
@ -323,7 +325,7 @@ public partial class OrderService : ApplicationService
[HttpPost("/order/export")]
public async Task<OrderExportOutput> ExportAsync([FromBody] OrderExportInput input)
{
await CleanupExpiredFilesAsync(GetOrderExportDirectory());
await CleanupExpiredLocalExportFilesAsync();
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
@ -339,12 +341,17 @@ public partial class OrderService : ApplicationService
}
var fileName = $"orders-{DateTime.Now:yyyyMMddHHmmss}.xlsx";
var directory = GetOrderExportDirectory();
Directory.CreateDirectory(directory);
await MiniExcel.SaveAsAsync(Path.Combine(directory, fileName), await BuildOrderExportRowsAsync(orders));
var relativeKey = $"uploads/exports/orders/{fileName}";
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders));
stream.Position = 0;
var downloadUrl = await FileStorage.SaveAsync(
stream,
relativeKey,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
return new OrderExportOutput
{
DownloadUrl = $"/uploads/exports/orders/{fileName}",
DownloadUrl = downloadUrl,
ExpireAt = DateTime.Now.AddHours(24)
};
}

View File

@ -1,12 +1,12 @@
using System.Linq.Expressions;
using Fake.Application;
using Fake.Domain.Exceptions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using MiniExcelLibs;
using Oms.Application.Dtos.Recharge;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.RechargeAggregate.Entities;
using Oms.Domain.RechargeAggregate.Enums;
@ -22,7 +22,7 @@ public class RechargeService : ApplicationService
{
private IRechargeRecordRepository RechargeRecordRepository => LazyServiceProvider.GetRequiredService<IRechargeRecordRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
/// <summary>
/// 提交充值申请。
@ -45,7 +45,7 @@ public class RechargeService : ApplicationService
var screenshotPath = await OmsFileUploadHelper.SaveRechargeScreenshotAsync(
input.RechargeScreenshot,
WebHostEnvironment);
FileStorage);
var entity = new RechargeRecord
{

View File

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.WarehouseAggregate.Consts;
using Oms.Domain.WarehouseAggregate.Entities;
@ -211,7 +212,7 @@ public partial class WarehouseService
public async Task<object> UploadParcelImageAsync(IFormFile file)
{
await ResolveMutationCustomerIdAsync(null);
var path = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, WebHostEnvironment);
var path = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, FileStorage);
return new { url = path, fileUrl = path };
}

View File

@ -1,18 +1,18 @@
using System.Linq.Expressions;
using Fake.Application;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
using Oms.Application.Shared;
using Oms.Application.Storage;
namespace Oms.Application.Services.Warehouse;
public partial class WarehouseService
{
private IWebHostEnvironment WebHostEnvironment =>
LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
private IFileStorage FileStorage =>
LazyServiceProvider.GetRequiredService<IFileStorage>();
/// <summary>
/// 查询 SKU 列表。
@ -166,7 +166,7 @@ public partial class WarehouseService
public async Task<object> UploadSkuImageAsync(IFormFile file)
{
await ResolveMutationCustomerIdAsync(null);
var path = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, WebHostEnvironment);
var path = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, FileStorage);
return new { url = path, fileUrl = path };
}

View File

@ -1,9 +1,9 @@
using Fake.Application;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Xhs;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.XhsAggregate.Entities;
using Oms.Domain.XhsAggregate.Repositories;
@ -15,7 +15,7 @@ public class XhsMediaService : ApplicationService
{
private IXhsMediaRepository MediaRepository => LazyServiceProvider.GetRequiredService<IXhsMediaRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
/// <summary>
/// 上传媒体文件
@ -29,25 +29,18 @@ public class XhsMediaService : ApplicationService
}
var customerId = await OmsSessionHelper.ResolveXhsCustomerIdAsync(CustomerRepository, CurrentUser);
var uploadsRoot = Path.Combine(WebHostEnvironment.WebRootPath ?? "wwwroot", "uploads", "xhs", customerId.ToString());
Directory.CreateDirectory(uploadsRoot);
var ext = Path.GetExtension(file.FileName);
var storedName = $"{Guid.NewGuid():N}{ext}";
var fullPath = Path.Combine(uploadsRoot, storedName);
await using (var stream = File.Create(fullPath))
{
await file.CopyToAsync(stream);
}
var relativeKey = $"uploads/xhs/{customerId}/{storedName}";
var relativePath = Path.Combine("uploads", "xhs", customerId.ToString(), storedName).Replace('\\', '/');
var publicUrl = $"/{relativePath}";
await using var stream = file.OpenReadStream();
var publicUrl = await FileStorage.SaveAsync(stream, relativeKey, file.ContentType);
var media = new XhsMedia
{
CustomerId = customerId,
FileName = file.FileName,
StoragePath = relativePath,
StoragePath = relativeKey,
PublicUrl = publicUrl,
MimeType = file.ContentType,
FileSizeBytes = file.Length

View File

@ -1,11 +1,11 @@
using Fake.Application;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Oms.Application.Storage;
namespace Oms.Application.Shared;
/// <summary>
/// OMS 上传文件落盘(wwwroot/uploads)。
/// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。
/// </summary>
public static class OmsFileUploadHelper
{
@ -15,26 +15,26 @@ public static class OmsFileUploadHelper
private const long MaxImageBytes = 5 * 1024 * 1024;
/// <summary>
/// 保存充值截图,返回以 /uploads 开头的相对访问路径。
/// 保存充值截图,返回可访问 URL。
/// </summary>
public static Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IWebHostEnvironment env) =>
SaveImageAsync(file, env, "recharge", "充值截图");
public static Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IFileStorage storage) =>
SaveImageAsync(file, storage, "recharge", "充值截图");
/// <summary>
/// 保存仓储 SKU 图片。
/// </summary>
public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IWebHostEnvironment env) =>
SaveImageAsync(file, env, "sku", "SKU 图片");
public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IFileStorage storage) =>
SaveImageAsync(file, storage, "sku", "SKU 图片");
/// <summary>
/// 保存包裹商品图片。
/// </summary>
public static Task<string> SaveWarehouseParcelImageAsync(IFormFile file, IWebHostEnvironment env) =>
SaveImageAsync(file, env, "parcel", "包裹商品图片");
public static Task<string> SaveWarehouseParcelImageAsync(IFormFile file, IFileStorage storage) =>
SaveImageAsync(file, storage, "parcel", "包裹商品图片");
private static async Task<string> SaveImageAsync(
IFormFile? file,
IWebHostEnvironment env,
IFileStorage storage,
string folder,
string label)
{
@ -64,25 +64,12 @@ public static class OmsFileUploadHelper
throw new BusinessException($"{label}仅支持 jpg、png、webp、gif、bmp 格式");
}
var webRoot = env.WebRootPath;
if (string.IsNullOrWhiteSpace(webRoot))
{
webRoot = Path.Combine(env.ContentRootPath, "wwwroot");
}
var monthFolder = DateTime.Now.ToString("yyyyMM");
var relativeDir = Path.Combine("uploads", folder, monthFolder);
var absoluteDir = Path.Combine(webRoot, relativeDir);
Directory.CreateDirectory(absoluteDir);
var fileName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
var absolutePath = Path.Combine(absoluteDir, fileName);
await using (var stream = File.Create(absolutePath))
{
await file.CopyToAsync(stream);
}
var relativeKey = $"uploads/{folder}/{monthFolder}/{fileName}";
return $"/uploads/{folder}/{monthFolder}/{fileName}".Replace('\\', '/');
await using var stream = file.OpenReadStream();
return await storage.SaveAsync(stream, relativeKey, file.ContentType);
}
private static readonly HashSet<string> ExcelExtensions =
@ -96,7 +83,7 @@ public static class OmsFileUploadHelper
public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync(
IFormFile file,
int customerId,
IWebHostEnvironment env)
IFileStorage storage)
{
if (file == null || file.Length <= 0)
{
@ -114,37 +101,15 @@ public static class OmsFileUploadHelper
throw new BusinessException("仅支持 .xlsx / .xls 文件");
}
var webRoot = env.WebRootPath;
if (string.IsNullOrWhiteSpace(webRoot))
{
webRoot = Path.Combine(env.ContentRootPath, "wwwroot");
}
var relativeDir = Path.Combine("uploads", "order-templates", customerId.ToString());
var absoluteDir = Path.Combine(webRoot, relativeDir);
Directory.CreateDirectory(absoluteDir);
var fileName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
var absolutePath = Path.Combine(absoluteDir, fileName);
await using (var stream = File.Create(absolutePath))
{
await file.CopyToAsync(stream);
}
var relativeKey = $"uploads/order-templates/{customerId}/{fileName}";
var relativePath = $"/uploads/order-templates/{customerId}/{fileName}".Replace('\\', '/');
return (relativePath, Path.GetFileName(file.FileName));
}
public static string ResolveWebRootAbsolutePath(IWebHostEnvironment env, string relativePath)
{
var webRoot = env.WebRootPath;
if (string.IsNullOrWhiteSpace(webRoot))
{
webRoot = Path.Combine(env.ContentRootPath, "wwwroot");
}
var trimmed = relativePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar);
return Path.Combine(webRoot, trimmed);
await using var stream = file.OpenReadStream();
var url = await storage.SaveAsync(
stream,
relativeKey,
file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
return (url, Path.GetFileName(file.FileName));
}
private static string? GuessExtensionFromContentType(string? contentType) => contentType?.ToLowerInvariant() switch

View File

@ -0,0 +1,54 @@
namespace Oms.Application.Storage;
/// <summary>
/// 文件存储配置。Provider=Local 落盘 wwwroot;Provider=Cos 走腾讯云 COS。
/// </summary>
public class FileStorageOptions
{
public const string SectionName = "Oms:FileStorage";
/// <summary>
/// 存储提供方:Local | Cos。
/// </summary>
public string Provider { get; set; } = "Local";
/// <summary>
/// 腾讯云 COS 配置(Provider=Cos 时必填)。
/// </summary>
public TencentCosOptions Cos { get; set; } = new();
}
/// <summary>
/// 腾讯云 COS 连接参数。
/// </summary>
public class TencentCosOptions
{
/// <summary>API 密钥 SecretId。</summary>
public string SecretId { get; set; } = string.Empty;
/// <summary>API 密钥 SecretKey。</summary>
public string SecretKey { get; set; } = string.Empty;
/// <summary>地域,如 ap-guangzhou。</summary>
public string Region { get; set; } = string.Empty;
/// <summary>存储桶,格式 bucketname-APPID。</summary>
public string Bucket { get; set; } = string.Empty;
/// <summary>
/// 对外访问根地址。可填 CDN / 自定义域名,末尾不要斜杠。
/// 空则默认 https://{Bucket}.cos.{Region}.myqcloud.com
/// </summary>
public string? BaseUrl { get; set; }
/// <summary>
/// 对象键前缀(如 oms/prod/),可空。
/// </summary>
public string KeyPrefix { get; set; } = string.Empty;
/// <summary>签名有效期(秒)。</summary>
public long DurationSecond { get; set; } = 600;
/// <summary>是否 HTTPS。</summary>
public bool IsHttps { get; set; } = true;
}

View File

@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Oms.Application.Storage;
public static class FileStorageServiceExtensions
{
public static IServiceCollection AddOmsFileStorage(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<FileStorageOptions>(configuration.GetSection(FileStorageOptions.SectionName));
var provider = configuration.GetSection(FileStorageOptions.SectionName)["Provider"]
?? "Local";
if (string.Equals(provider, "Cos", StringComparison.OrdinalIgnoreCase))
{
services.AddSingleton<IFileStorage, TencentCosFileStorage>();
}
else
{
services.AddSingleton<IFileStorage, LocalFileStorage>();
}
return services;
}
}

View File

@ -0,0 +1,36 @@
namespace Oms.Application.Storage;
/// <summary>
/// 统一文件存储(本地 / 腾讯云 COS)。
/// </summary>
public interface IFileStorage
{
/// <summary>
/// 保存文件。
/// </summary>
/// <param name="content">文件流。</param>
/// <param name="relativeKey">相对键,如 uploads/avatars/a.jpg(不要前导 /)。</param>
/// <param name="contentType">MIME,可选。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>可对外访问的 URL(本地为 /uploads/...,COS 为完整 URL)。</returns>
Task<string> SaveAsync(
Stream content,
string relativeKey,
string? contentType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// 打开已保存文件的只读流。pathOrUrl 可为相对路径或完整 URL。
/// </summary>
Task<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default);
/// <summary>
/// 判断文件是否存在。
/// </summary>
Task<bool> ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default);
/// <summary>
/// 删除文件(不存在时忽略)。
/// </summary>
Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,116 @@
using Microsoft.AspNetCore.Hosting;
namespace Oms.Application.Storage;
/// <summary>
/// 本地 wwwroot 文件存储。
/// </summary>
public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage
{
public async Task<string> SaveAsync(
Stream content,
string relativeKey,
string? contentType = null,
CancellationToken cancellationToken = default)
{
var key = NormalizeRelativeKey(relativeKey);
var absolutePath = ToAbsolutePath(key);
var directory = Path.GetDirectoryName(absolutePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
await using var file = new FileStream(
absolutePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
if (content.CanSeek)
{
content.Position = 0;
}
await content.CopyToAsync(file, cancellationToken);
return "/" + key.Replace('\\', '/');
}
public Task<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default)
{
var absolutePath = ResolveAbsolutePath(pathOrUrl);
if (!File.Exists(absolutePath))
{
throw new FileNotFoundException("本地文件不存在", absolutePath);
}
Stream stream = new FileStream(
absolutePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 81920,
useAsync: true);
return Task.FromResult(stream);
}
public Task<bool> ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default)
{
var absolutePath = ResolveAbsolutePath(pathOrUrl);
return Task.FromResult(File.Exists(absolutePath));
}
public Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default)
{
var absolutePath = ResolveAbsolutePath(pathOrUrl);
if (File.Exists(absolutePath))
{
File.Delete(absolutePath);
}
return Task.CompletedTask;
}
private string ResolveAbsolutePath(string pathOrUrl)
{
if (string.IsNullOrWhiteSpace(pathOrUrl))
{
throw new ArgumentException("路径不能为空", nameof(pathOrUrl));
}
if (Path.IsPathRooted(pathOrUrl) && File.Exists(pathOrUrl))
{
return pathOrUrl;
}
var key = NormalizeRelativeKey(pathOrUrl);
return ToAbsolutePath(key);
}
private string ToAbsolutePath(string relativeKey)
{
var webRoot = string.IsNullOrWhiteSpace(environment.WebRootPath)
? Path.Combine(environment.ContentRootPath, "wwwroot")
: environment.WebRootPath;
return Path.Combine(webRoot, relativeKey.Replace('/', Path.DirectorySeparatorChar));
}
private static string NormalizeRelativeKey(string relativeKey)
{
var key = relativeKey.Trim().Replace('\\', '/').TrimStart('/');
if (key.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| key.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
var uri = new Uri(key);
key = uri.AbsolutePath.TrimStart('/');
}
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentException("relativeKey 不能为空", nameof(relativeKey));
}
return key;
}
}

View File

@ -0,0 +1,217 @@
using COSXML;
using COSXML.Auth;
using COSXML.Model.Object;
using Fake.Application;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Oms.Application.Storage;
/// <summary>
/// 腾讯云 COS 文件存储。
/// </summary>
public sealed class TencentCosFileStorage : IFileStorage, IDisposable
{
private readonly TencentCosOptions _options;
private readonly CosXmlServer _cosXml;
private readonly ILogger<TencentCosFileStorage> _logger;
private readonly string _publicBaseUrl;
public TencentCosFileStorage(
IOptions<FileStorageOptions> options,
ILogger<TencentCosFileStorage> logger)
{
_logger = logger;
_options = options.Value.Cos ?? new TencentCosOptions();
ValidateOptions(_options);
var config = new CosXmlConfig.Builder()
.IsHttps(_options.IsHttps)
.SetRegion(_options.Region)
.SetAppid(ExtractAppId(_options.Bucket))
.Build();
var credential = new DefaultQCloudCredentialProvider(
_options.SecretId,
_options.SecretKey,
_options.DurationSecond);
_cosXml = new CosXmlServer(config, credential);
_publicBaseUrl = ResolvePublicBaseUrl(_options);
}
public Task<string> SaveAsync(
Stream content,
string relativeKey,
string? contentType = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var objectKey = ToObjectKey(relativeKey);
if (content.CanSeek)
{
content.Position = 0;
}
// SDK PutObject(Stream) 需要可定位流;不可定位时先拷到内存。
Stream uploadStream = content;
MemoryStream? owned = null;
if (!content.CanSeek)
{
owned = new MemoryStream();
content.CopyTo(owned);
owned.Position = 0;
uploadStream = owned;
}
try
{
var request = new PutObjectRequest(_options.Bucket, objectKey, uploadStream);
if (!string.IsNullOrWhiteSpace(contentType))
{
request.SetRequestHeader("Content-Type", contentType);
}
var result = _cosXml.PutObject(request);
if (result.httpCode is < 200 or >= 300)
{
throw new BusinessException($"上传 COS 失败: HTTP {result.httpCode}");
}
var url = $"{_publicBaseUrl}/{objectKey}";
_logger.LogDebug("COS 上传成功: {ObjectKey}", objectKey);
return Task.FromResult(url);
}
finally
{
owned?.Dispose();
}
}
public Task<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var objectKey = ToObjectKey(pathOrUrl);
var request = new GetObjectBytesRequest(_options.Bucket, objectKey);
var result = _cosXml.GetObject(request);
if (result.httpCode is < 200 or >= 300)
{
throw new FileNotFoundException($"COS 对象不存在或读取失败: {objectKey}");
}
Stream stream = new MemoryStream(result.content);
stream.Position = 0;
return Task.FromResult(stream);
}
public Task<bool> ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var objectKey = ToObjectKey(pathOrUrl);
var request = new HeadObjectRequest(_options.Bucket, objectKey);
var result = _cosXml.HeadObject(request);
return Task.FromResult(result.httpCode is >= 200 and < 300);
}
catch
{
return Task.FromResult(false);
}
}
public Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var objectKey = ToObjectKey(pathOrUrl);
var request = new DeleteObjectRequest(_options.Bucket, objectKey);
_cosXml.DeleteObject(request);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "删除 COS 对象失败: {Path}", pathOrUrl);
}
return Task.CompletedTask;
}
public void Dispose()
{
// CosXmlServer 无显式 Dispose;保留以便未来扩展。
}
private string ToObjectKey(string pathOrUrl)
{
if (string.IsNullOrWhiteSpace(pathOrUrl))
{
throw new ArgumentException("路径不能为空", nameof(pathOrUrl));
}
var key = pathOrUrl.Trim().Replace('\\', '/');
if (key.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| key.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
var uri = new Uri(key);
key = uri.AbsolutePath.TrimStart('/');
}
else
{
key = key.TrimStart('/');
}
var prefix = (_options.KeyPrefix ?? string.Empty).Replace('\\', '/').Trim('/');
if (!string.IsNullOrEmpty(prefix))
{
// 若已带前缀则不重复追加(兼容完整 URL 回读)。
if (!key.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(key, prefix, StringComparison.OrdinalIgnoreCase))
{
key = $"{prefix}/{key}";
}
}
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentException("对象键不能为空", nameof(pathOrUrl));
}
return key;
}
private static string ResolvePublicBaseUrl(TencentCosOptions options)
{
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
{
return options.BaseUrl.Trim().TrimEnd('/');
}
var scheme = options.IsHttps ? "https" : "http";
return $"{scheme}://{options.Bucket}.cos.{options.Region}.myqcloud.com";
}
private static string ExtractAppId(string bucket)
{
var idx = bucket.LastIndexOf('-');
if (idx <= 0 || idx >= bucket.Length - 1)
{
throw new BusinessException("COS Bucket 格式应为 bucketname-APPID");
}
return bucket[(idx + 1)..];
}
private static void ValidateOptions(TencentCosOptions options)
{
if (string.IsNullOrWhiteSpace(options.SecretId)
|| string.IsNullOrWhiteSpace(options.SecretKey)
|| string.IsNullOrWhiteSpace(options.Region)
|| string.IsNullOrWhiteSpace(options.Bucket))
{
throw new BusinessException(
"Oms:FileStorage:Cos 配置不完整,请填写 SecretId / SecretKey / Region / Bucket");
}
}
}

View File

@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
@ -36,13 +37,13 @@ public partial class AuthService(
AccountManager accountManager,
IJwtService jwtService,
IUserRepository userRepository,
IWebHostEnvironment webHostEnvironment,
IHttpContextAccessor httpContextAccessor,
FakeRbacDbContext rbacDbContext,
ICustomerRepository customerRepository,
IWeChatLoginSessionStore weChatLoginSessionStore,
IMemoryCache memoryCache,
IRbacAuthCache rbacAuthCache)
IRbacAuthCache rbacAuthCache,
IFileStorage fileStorage)
: ApplicationService
{
private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3);
@ -384,12 +385,10 @@ public partial class AuthService(
throw new DomainException("文件大小不能超过10MB");
}
var uploadPath = Path.Combine(webHostEnvironment.WebRootPath ?? webHostEnvironment.ContentRootPath, "uploads", "avatars");
Directory.CreateDirectory(uploadPath);
var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}{extension}";
var filePath = Path.Combine(uploadPath, fileName);
var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}.jpg";
var relativeKey = $"uploads/avatars/{fileName}";
await using var output = new MemoryStream();
using (var stream = file.OpenReadStream())
using (var image = await Image.LoadAsync(stream, cancellationToken))
{
@ -412,13 +411,14 @@ public partial class AuthService(
}
image.Mutate(x => x.Resize(width, height));
await image.SaveAsJpegAsync(filePath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
await image.SaveAsJpegAsync(output, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
{
Quality = 80
}, cancellationToken);
}
var avatarUrl = $"/uploads/avatars/{fileName}";
output.Position = 0;
var avatarUrl = await fileStorage.SaveAsync(output, relativeKey, "image/jpeg", cancellationToken);
var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken);
user.UpdateAvatar(avatarUrl);
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);

View File

@ -60,6 +60,19 @@
}
},
"Oms": {
"FileStorage": {
"Provider": "Local",
"Cos": {
"SecretId": "",
"SecretKey": "",
"Region": "ap-guangzhou",
"Bucket": "your-bucket-1250000000",
"BaseUrl": "",
"KeyPrefix": "",
"DurationSecond": 600,
"IsHttps": true
}
},
"TrackService": {
"Enabled": true,
"BaseUrl": "http://api.nanafa.com/track",
@ -120,4 +133,4 @@
"Enabled": false
}
}
}
}

View File

@ -1,34 +0,0 @@
{
"UseLocalConfigs": false,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"App": {
"CorsOrigins": [
"https://ideship.com",
"https://www.ideship.com"
],
"SendStartupNotification": false
},
"WeChat": {
"Mp": {
"OAuthRedirectBase": "https://ideship.com",
"FollowUrl": ""
}
},
"Oms": {
"TrackService": {
"Enabled": true,
"BaseUrl": "https://api.ideship.com/track"
},
"Itfx": {
"BaseUrl": "https://api.ideship.com/itfx"
},
"PlatformShop": {
"FrontendBaseUrl": "https://ideship.com"
}
}
}