Compare commits
5 Commits
0a16f27664
...
943ba539a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 943ba539a4 | |||
| 23eb80931a | |||
| c886e8cef3 | |||
| 8f7a4c1405 | |||
| 93bef462fb |
@ -1,8 +1,8 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Services.Orders;
|
using Oms.Application.Services.Orders;
|
||||||
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||||
using Oms.Domain.OrdersAggregate.Consts;
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
using Oms.Domain.OrdersAggregate.Entities;
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
@ -22,7 +22,7 @@ public sealed class LabelPdfGenerationService(
|
|||||||
ISystemOtherConfigRepository systemOtherConfigRepository,
|
ISystemOtherConfigRepository systemOtherConfigRepository,
|
||||||
IOrderRepository orderRepository,
|
IOrderRepository orderRepository,
|
||||||
OrderFileManager orderFileManager,
|
OrderFileManager orderFileManager,
|
||||||
IWebHostEnvironment webHostEnvironment,
|
IFileStorage fileStorage,
|
||||||
ILogger<LabelPdfGenerationService> logger)
|
ILogger<LabelPdfGenerationService> logger)
|
||||||
{
|
{
|
||||||
public async Task<LabelPdfGenerateResult> GenerateAndPersistAsync(
|
public async Task<LabelPdfGenerateResult> GenerateAndPersistAsync(
|
||||||
@ -158,11 +158,6 @@ public sealed class LabelPdfGenerationService(
|
|||||||
|
|
||||||
private async Task<string> SavePdfAsync(string serviceNo, byte[] pdfBytes, CancellationToken cancellationToken)
|
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();
|
var safeNo = string.IsNullOrWhiteSpace(serviceNo) ? "order" : serviceNo.Trim();
|
||||||
foreach (var c in Path.GetInvalidFileNameChars())
|
foreach (var c in Path.GetInvalidFileNameChars())
|
||||||
{
|
{
|
||||||
@ -170,9 +165,9 @@ public sealed class LabelPdfGenerationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf";
|
var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf";
|
||||||
var absolute = Path.Combine(directory, fileName);
|
var relativeKey = $"uploads/prints/orders/{fileName}";
|
||||||
await File.WriteAllBytesAsync(absolute, pdfBytes, cancellationToken);
|
await using var stream = new MemoryStream(pdfBytes);
|
||||||
return $"/uploads/prints/orders/{fileName}";
|
return await fileStorage.SaveAsync(stream, relativeKey, "application/pdf", cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@
|
|||||||
<PackageReference Include="MiniExcel" Version="1.43.1" />
|
<PackageReference Include="MiniExcel" Version="1.43.1" />
|
||||||
<PackageReference Include="QuestPDF" Version="2025.7.0" />
|
<PackageReference Include="QuestPDF" Version="2025.7.0" />
|
||||||
<PackageReference Include="Serilog" Version="4.2.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" Version="0.16.10" />
|
||||||
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" />
|
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ using Oms.Application.Integrations.PlatformShop;
|
|||||||
using Oms.Application.Labeling;
|
using Oms.Application.Labeling;
|
||||||
using Oms.Application.Labeling.Hiprint;
|
using Oms.Application.Labeling.Hiprint;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Application.Storage;
|
||||||
using Oms.Application.Workers;
|
using Oms.Application.Workers;
|
||||||
using Oms.Domain;
|
using Oms.Domain;
|
||||||
|
|
||||||
@ -51,6 +52,7 @@ public class OmsApplicationModule : FakeModule
|
|||||||
context.Services.AddTransient<ILabelPdfRenderer, QuestPdfLabelRenderer>();
|
context.Services.AddTransient<ILabelPdfRenderer, QuestPdfLabelRenderer>();
|
||||||
context.Services.AddTransient<LabelPdfGenerationService>();
|
context.Services.AddTransient<LabelPdfGenerationService>();
|
||||||
|
|
||||||
|
context.Services.AddOmsFileStorage(configuration);
|
||||||
context.Services.AddOmsSmallBagTrack(configuration);
|
context.Services.AddOmsSmallBagTrack(configuration);
|
||||||
context.Services.AddOmsItfxIntegration(configuration);
|
context.Services.AddOmsItfxIntegration(configuration);
|
||||||
context.Services.AddOmsPlatformShopIntegration(configuration);
|
context.Services.AddOmsPlatformShopIntegration(configuration);
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.OrdersAggregate.Consts;
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
using Oms.Domain.OrdersAggregate.Entities;
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
using Oms.Domain.OrdersAggregate.Repositories;
|
using Oms.Domain.OrdersAggregate.Repositories;
|
||||||
@ -15,7 +14,7 @@ namespace Oms.Application.Services.Orders;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class OrderFileManager(
|
public sealed class OrderFileManager(
|
||||||
IOrderFileRepository orderFileRepository,
|
IOrderFileRepository orderFileRepository,
|
||||||
IWebHostEnvironment webHostEnvironment) : IScopedDependency
|
IFileStorage fileStorage) : IScopedDependency
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> ImageExt =
|
private static readonly HashSet<string> ImageExt =
|
||||||
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
|
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 storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
|
||||||
var absolutePath = Path.Combine(absoluteDir, storedName);
|
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
|
||||||
await using (var stream = File.Create(absolutePath))
|
await using var stream = file.OpenReadStream();
|
||||||
{
|
var url = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType);
|
||||||
await file.CopyToAsync(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
var url = $"/uploads/order-files/{orderId}/{fileType}/{storedName}".Replace('\\', '/');
|
|
||||||
return (url, Path.GetFileName(file.FileName), file.ContentType, file.Length);
|
return (url, Path.GetFileName(file.FileName), file.ContentType, file.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ using Oms.Application.Dtos.Shared;
|
|||||||
using Oms.Application.Messaging;
|
using Oms.Application.Messaging;
|
||||||
using Oms.Application.Services.Orders.Templates;
|
using Oms.Application.Services.Orders.Templates;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.OrdersAggregate;
|
using Oms.Domain.OrdersAggregate;
|
||||||
using Oms.Domain.ChannelsAggregate.Consts;
|
using Oms.Domain.ChannelsAggregate.Consts;
|
||||||
using Oms.Domain.ChannelsAggregate.Entities;
|
using Oms.Domain.ChannelsAggregate.Entities;
|
||||||
@ -487,13 +488,15 @@ public partial class OrderService
|
|||||||
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
|
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
|
||||||
}
|
}
|
||||||
|
|
||||||
var absolutePath = OmsFileUploadHelper.ResolveWebRootAbsolutePath(WebHostEnvironment, template.SourceFilePath);
|
if (!await FileStorage.ExistsAsync(template.SourceFilePath))
|
||||||
if (!File.Exists(absolutePath))
|
|
||||||
{
|
{
|
||||||
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
|
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)
|
var downloadName = string.IsNullOrWhiteSpace(template.SourceFileName)
|
||||||
? $"{template.Name}.xlsx"
|
? $"{template.Name}.xlsx"
|
||||||
: template.SourceFileName;
|
: template.SourceFileName;
|
||||||
@ -609,8 +612,6 @@ public partial class OrderService
|
|||||||
|
|
||||||
private async Task<string> CreateOrderPrintFileAsync(OrderOutput order)
|
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 fileName = $"{order.ServiceNo}-{DateTime.Now:yyyyMMddHHmmss}.html";
|
||||||
var html = $"""
|
var html = $"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@ -625,8 +626,9 @@ public partial class OrderService
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""";
|
""";
|
||||||
await File.WriteAllTextAsync(Path.Combine(directory, fileName), html, Encoding.UTF8);
|
var relativeKey = $"uploads/prints/orders/{fileName}";
|
||||||
return $"/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");
|
private string GetOrderExportDirectory() => Path.Combine(GetWebRootDirectory(), "uploads", "exports", "orders");
|
||||||
@ -640,6 +642,17 @@ public partial class OrderService
|
|||||||
return webRoot;
|
return webRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task CleanupExpiredLocalExportFilesAsync()
|
||||||
|
{
|
||||||
|
// COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。
|
||||||
|
if (FileStorage is not LocalFileStorage)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CleanupExpiredFilesAsync(GetOrderExportDirectory());
|
||||||
|
}
|
||||||
|
|
||||||
private async Task CleanupExpiredFilesAsync(string directory)
|
private async Task CleanupExpiredFilesAsync(string directory)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(directory))
|
if (!Directory.Exists(directory))
|
||||||
|
|||||||
@ -77,7 +77,7 @@ public partial class OrderService
|
|||||||
var (relativePath, originalName) = await OmsFileUploadHelper.SaveOrderTemplateSampleAsync(
|
var (relativePath, originalName) = await OmsFileUploadHelper.SaveOrderTemplateSampleAsync(
|
||||||
input.File,
|
input.File,
|
||||||
customerId,
|
customerId,
|
||||||
WebHostEnvironment);
|
FileStorage);
|
||||||
|
|
||||||
var suggested = OrderTemplateHeaderMatcher.BuildSuggestedMapping(OrderTemplateKeys.DedicatedSmallParcelStandard, headers);
|
var suggested = OrderTemplateHeaderMatcher.BuildSuggestedMapping(OrderTemplateKeys.DedicatedSmallParcelStandard, headers);
|
||||||
var entity = new OrderTemplate
|
var entity = new OrderTemplate
|
||||||
|
|||||||
@ -8,6 +8,7 @@ using Oms.Application.Dtos.Shared;
|
|||||||
using Oms.Application.DataPerm;
|
using Oms.Application.DataPerm;
|
||||||
using Oms.Application.Labeling;
|
using Oms.Application.Labeling;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Application.Storage;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Oms.Application.Services.Orders.Batch;
|
using Oms.Application.Services.Orders.Batch;
|
||||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||||
@ -34,6 +35,7 @@ public partial class OrderService : ApplicationService
|
|||||||
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
|
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
|
||||||
private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService<IPickupOrderRepository>();
|
private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService<IPickupOrderRepository>();
|
||||||
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
||||||
|
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
|
||||||
private LabelPdfGenerationService LabelPdfGenerationService =>
|
private LabelPdfGenerationService LabelPdfGenerationService =>
|
||||||
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
|
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
|
||||||
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
||||||
@ -323,7 +325,7 @@ public partial class OrderService : ApplicationService
|
|||||||
[HttpPost("/order/export")]
|
[HttpPost("/order/export")]
|
||||||
public async Task<OrderExportOutput> ExportAsync([FromBody] OrderExportInput input)
|
public async Task<OrderExportOutput> ExportAsync([FromBody] OrderExportInput input)
|
||||||
{
|
{
|
||||||
await CleanupExpiredFilesAsync(GetOrderExportDirectory());
|
await CleanupExpiredLocalExportFilesAsync();
|
||||||
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
|
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
|
||||||
CustomerRepository,
|
CustomerRepository,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
@ -339,12 +341,17 @@ public partial class OrderService : ApplicationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fileName = $"orders-{DateTime.Now:yyyyMMddHHmmss}.xlsx";
|
var fileName = $"orders-{DateTime.Now:yyyyMMddHHmmss}.xlsx";
|
||||||
var directory = GetOrderExportDirectory();
|
var relativeKey = $"uploads/exports/orders/{fileName}";
|
||||||
Directory.CreateDirectory(directory);
|
await using var stream = new MemoryStream();
|
||||||
await MiniExcel.SaveAsAsync(Path.Combine(directory, fileName), await BuildOrderExportRowsAsync(orders));
|
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
|
return new OrderExportOutput
|
||||||
{
|
{
|
||||||
DownloadUrl = $"/uploads/exports/orders/{fileName}",
|
DownloadUrl = downloadUrl,
|
||||||
ExpireAt = DateTime.Now.AddHours(24)
|
ExpireAt = DateTime.Now.AddHours(24)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Fake.Domain.Exceptions;
|
using Fake.Domain.Exceptions;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using MiniExcelLibs;
|
using MiniExcelLibs;
|
||||||
using Oms.Application.Dtos.Recharge;
|
using Oms.Application.Dtos.Recharge;
|
||||||
using Oms.Application.Dtos.Shared;
|
using Oms.Application.Dtos.Shared;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.CustomersAggregate.Repositories;
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
using Oms.Domain.RechargeAggregate.Entities;
|
using Oms.Domain.RechargeAggregate.Entities;
|
||||||
using Oms.Domain.RechargeAggregate.Enums;
|
using Oms.Domain.RechargeAggregate.Enums;
|
||||||
@ -22,7 +22,7 @@ public class RechargeService : ApplicationService
|
|||||||
{
|
{
|
||||||
private IRechargeRecordRepository RechargeRecordRepository => LazyServiceProvider.GetRequiredService<IRechargeRecordRepository>();
|
private IRechargeRecordRepository RechargeRecordRepository => LazyServiceProvider.GetRequiredService<IRechargeRecordRepository>();
|
||||||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||||
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 提交充值申请。
|
/// 提交充值申请。
|
||||||
@ -45,7 +45,7 @@ public class RechargeService : ApplicationService
|
|||||||
|
|
||||||
var screenshotPath = await OmsFileUploadHelper.SaveRechargeScreenshotAsync(
|
var screenshotPath = await OmsFileUploadHelper.SaveRechargeScreenshotAsync(
|
||||||
input.RechargeScreenshot,
|
input.RechargeScreenshot,
|
||||||
WebHostEnvironment);
|
FileStorage);
|
||||||
|
|
||||||
var entity = new RechargeRecord
|
var entity = new RechargeRecord
|
||||||
{
|
{
|
||||||
|
|||||||
@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Oms.Application.Dtos.Shared;
|
using Oms.Application.Dtos.Shared;
|
||||||
using Oms.Application.Dtos.Warehouse;
|
using Oms.Application.Dtos.Warehouse;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.WarehouseAggregate.Consts;
|
using Oms.Domain.WarehouseAggregate.Consts;
|
||||||
using Oms.Domain.WarehouseAggregate.Entities;
|
using Oms.Domain.WarehouseAggregate.Entities;
|
||||||
|
|
||||||
@ -211,7 +212,7 @@ public partial class WarehouseService
|
|||||||
public async Task<object> UploadParcelImageAsync(IFormFile file)
|
public async Task<object> UploadParcelImageAsync(IFormFile file)
|
||||||
{
|
{
|
||||||
await ResolveMutationCustomerIdAsync(null);
|
await ResolveMutationCustomerIdAsync(null);
|
||||||
var path = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, WebHostEnvironment);
|
var path = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, FileStorage);
|
||||||
return new { url = path, fileUrl = path };
|
return new { url = path, fileUrl = path };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Oms.Application.Dtos.Shared;
|
using Oms.Application.Dtos.Shared;
|
||||||
using Oms.Application.Dtos.Warehouse;
|
using Oms.Application.Dtos.Warehouse;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Application.Storage;
|
||||||
|
|
||||||
namespace Oms.Application.Services.Warehouse;
|
namespace Oms.Application.Services.Warehouse;
|
||||||
|
|
||||||
public partial class WarehouseService
|
public partial class WarehouseService
|
||||||
{
|
{
|
||||||
private IWebHostEnvironment WebHostEnvironment =>
|
private IFileStorage FileStorage =>
|
||||||
LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
LazyServiceProvider.GetRequiredService<IFileStorage>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询 SKU 列表。
|
/// 查询 SKU 列表。
|
||||||
@ -166,7 +166,7 @@ public partial class WarehouseService
|
|||||||
public async Task<object> UploadSkuImageAsync(IFormFile file)
|
public async Task<object> UploadSkuImageAsync(IFormFile file)
|
||||||
{
|
{
|
||||||
await ResolveMutationCustomerIdAsync(null);
|
await ResolveMutationCustomerIdAsync(null);
|
||||||
var path = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, WebHostEnvironment);
|
var path = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, FileStorage);
|
||||||
return new { url = path, fileUrl = path };
|
return new { url = path, fileUrl = path };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Oms.Application.Dtos.Xhs;
|
using Oms.Application.Dtos.Xhs;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.CustomersAggregate.Repositories;
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
using Oms.Domain.XhsAggregate.Entities;
|
using Oms.Domain.XhsAggregate.Entities;
|
||||||
using Oms.Domain.XhsAggregate.Repositories;
|
using Oms.Domain.XhsAggregate.Repositories;
|
||||||
@ -15,7 +15,7 @@ public class XhsMediaService : ApplicationService
|
|||||||
{
|
{
|
||||||
private IXhsMediaRepository MediaRepository => LazyServiceProvider.GetRequiredService<IXhsMediaRepository>();
|
private IXhsMediaRepository MediaRepository => LazyServiceProvider.GetRequiredService<IXhsMediaRepository>();
|
||||||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||||
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 上传媒体文件
|
/// 上传媒体文件
|
||||||
@ -29,25 +29,18 @@ public class XhsMediaService : ApplicationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var customerId = await OmsSessionHelper.ResolveXhsCustomerIdAsync(CustomerRepository, CurrentUser);
|
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 ext = Path.GetExtension(file.FileName);
|
||||||
var storedName = $"{Guid.NewGuid():N}{ext}";
|
var storedName = $"{Guid.NewGuid():N}{ext}";
|
||||||
var fullPath = Path.Combine(uploadsRoot, storedName);
|
var relativeKey = $"uploads/xhs/{customerId}/{storedName}";
|
||||||
await using (var stream = File.Create(fullPath))
|
|
||||||
{
|
|
||||||
await file.CopyToAsync(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
var relativePath = Path.Combine("uploads", "xhs", customerId.ToString(), storedName).Replace('\\', '/');
|
await using var stream = file.OpenReadStream();
|
||||||
var publicUrl = $"/{relativePath}";
|
var publicUrl = await FileStorage.SaveAsync(stream, relativeKey, file.ContentType);
|
||||||
|
|
||||||
var media = new XhsMedia
|
var media = new XhsMedia
|
||||||
{
|
{
|
||||||
CustomerId = customerId,
|
CustomerId = customerId,
|
||||||
FileName = file.FileName,
|
FileName = file.FileName,
|
||||||
StoragePath = relativePath,
|
StoragePath = relativeKey,
|
||||||
PublicUrl = publicUrl,
|
PublicUrl = publicUrl,
|
||||||
MimeType = file.ContentType,
|
MimeType = file.ContentType,
|
||||||
FileSizeBytes = file.Length
|
FileSizeBytes = file.Length
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Oms.Application.Storage;
|
||||||
|
|
||||||
namespace Oms.Application.Shared;
|
namespace Oms.Application.Shared;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// OMS 上传文件落盘(wwwroot/uploads)。
|
/// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class OmsFileUploadHelper
|
public static class OmsFileUploadHelper
|
||||||
{
|
{
|
||||||
@ -15,26 +15,26 @@ public static class OmsFileUploadHelper
|
|||||||
private const long MaxImageBytes = 5 * 1024 * 1024;
|
private const long MaxImageBytes = 5 * 1024 * 1024;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 保存充值截图,返回以 /uploads 开头的相对访问路径。
|
/// 保存充值截图,返回可访问 URL。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IWebHostEnvironment env) =>
|
public static Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IFileStorage storage) =>
|
||||||
SaveImageAsync(file, env, "recharge", "充值截图");
|
SaveImageAsync(file, storage, "recharge", "充值截图");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 保存仓储 SKU 图片。
|
/// 保存仓储 SKU 图片。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IWebHostEnvironment env) =>
|
public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IFileStorage storage) =>
|
||||||
SaveImageAsync(file, env, "sku", "SKU 图片");
|
SaveImageAsync(file, storage, "sku", "SKU 图片");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 保存包裹商品图片。
|
/// 保存包裹商品图片。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Task<string> SaveWarehouseParcelImageAsync(IFormFile file, IWebHostEnvironment env) =>
|
public static Task<string> SaveWarehouseParcelImageAsync(IFormFile file, IFileStorage storage) =>
|
||||||
SaveImageAsync(file, env, "parcel", "包裹商品图片");
|
SaveImageAsync(file, storage, "parcel", "包裹商品图片");
|
||||||
|
|
||||||
private static async Task<string> SaveImageAsync(
|
private static async Task<string> SaveImageAsync(
|
||||||
IFormFile? file,
|
IFormFile? file,
|
||||||
IWebHostEnvironment env,
|
IFileStorage storage,
|
||||||
string folder,
|
string folder,
|
||||||
string label)
|
string label)
|
||||||
{
|
{
|
||||||
@ -64,25 +64,12 @@ public static class OmsFileUploadHelper
|
|||||||
throw new BusinessException($"{label}仅支持 jpg、png、webp、gif、bmp 格式");
|
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 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 fileName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
|
||||||
var absolutePath = Path.Combine(absoluteDir, fileName);
|
var relativeKey = $"uploads/{folder}/{monthFolder}/{fileName}";
|
||||||
await using (var stream = File.Create(absolutePath))
|
|
||||||
{
|
|
||||||
await file.CopyToAsync(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 =
|
private static readonly HashSet<string> ExcelExtensions =
|
||||||
@ -96,7 +83,7 @@ public static class OmsFileUploadHelper
|
|||||||
public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync(
|
public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync(
|
||||||
IFormFile file,
|
IFormFile file,
|
||||||
int customerId,
|
int customerId,
|
||||||
IWebHostEnvironment env)
|
IFileStorage storage)
|
||||||
{
|
{
|
||||||
if (file == null || file.Length <= 0)
|
if (file == null || file.Length <= 0)
|
||||||
{
|
{
|
||||||
@ -114,37 +101,15 @@ public static class OmsFileUploadHelper
|
|||||||
throw new BusinessException("仅支持 .xlsx / .xls 文件");
|
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 fileName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
|
||||||
var absolutePath = Path.Combine(absoluteDir, fileName);
|
var relativeKey = $"uploads/order-templates/{customerId}/{fileName}";
|
||||||
await using (var stream = File.Create(absolutePath))
|
|
||||||
{
|
|
||||||
await file.CopyToAsync(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
var relativePath = $"/uploads/order-templates/{customerId}/{fileName}".Replace('\\', '/');
|
await using var stream = file.OpenReadStream();
|
||||||
return (relativePath, Path.GetFileName(file.FileName));
|
var url = await storage.SaveAsync(
|
||||||
}
|
stream,
|
||||||
|
relativeKey,
|
||||||
public static string ResolveWebRootAbsolutePath(IWebHostEnvironment env, string relativePath)
|
file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
{
|
return (url, Path.GetFileName(file.FileName));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? GuessExtensionFromContentType(string? contentType) => contentType?.ToLowerInvariant() switch
|
private static string? GuessExtensionFromContentType(string? contentType) => contentType?.ToLowerInvariant() switch
|
||||||
|
|||||||
54
src/Oms.Application/Storage/FileStorageOptions.cs
Normal file
54
src/Oms.Application/Storage/FileStorageOptions.cs
Normal 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;
|
||||||
|
}
|
||||||
26
src/Oms.Application/Storage/FileStorageServiceExtensions.cs
Normal file
26
src/Oms.Application/Storage/FileStorageServiceExtensions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Oms.Application/Storage/IFileStorage.cs
Normal file
36
src/Oms.Application/Storage/IFileStorage.cs
Normal 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);
|
||||||
|
}
|
||||||
116
src/Oms.Application/Storage/LocalFileStorage.cs
Normal file
116
src/Oms.Application/Storage/LocalFileStorage.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src/Oms.Application/Storage/TencentCosFileStorage.cs
Normal file
217
src/Oms.Application/Storage/TencentCosFileStorage.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.CustomersAggregate.Entities;
|
using Oms.Domain.CustomersAggregate.Entities;
|
||||||
using Oms.Domain.CustomersAggregate.Enums;
|
using Oms.Domain.CustomersAggregate.Enums;
|
||||||
using Oms.Domain.CustomersAggregate.Repositories;
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
@ -36,13 +37,13 @@ public partial class AuthService(
|
|||||||
AccountManager accountManager,
|
AccountManager accountManager,
|
||||||
IJwtService jwtService,
|
IJwtService jwtService,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IWebHostEnvironment webHostEnvironment,
|
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
FakeRbacDbContext rbacDbContext,
|
FakeRbacDbContext rbacDbContext,
|
||||||
ICustomerRepository customerRepository,
|
ICustomerRepository customerRepository,
|
||||||
IWeChatLoginSessionStore weChatLoginSessionStore,
|
IWeChatLoginSessionStore weChatLoginSessionStore,
|
||||||
IMemoryCache memoryCache,
|
IMemoryCache memoryCache,
|
||||||
IRbacAuthCache rbacAuthCache)
|
IRbacAuthCache rbacAuthCache,
|
||||||
|
IFileStorage fileStorage)
|
||||||
: ApplicationService
|
: ApplicationService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3);
|
private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3);
|
||||||
@ -384,12 +385,10 @@ public partial class AuthService(
|
|||||||
throw new DomainException("文件大小不能超过10MB");
|
throw new DomainException("文件大小不能超过10MB");
|
||||||
}
|
}
|
||||||
|
|
||||||
var uploadPath = Path.Combine(webHostEnvironment.WebRootPath ?? webHostEnvironment.ContentRootPath, "uploads", "avatars");
|
var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}.jpg";
|
||||||
Directory.CreateDirectory(uploadPath);
|
var relativeKey = $"uploads/avatars/{fileName}";
|
||||||
|
|
||||||
var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}{extension}";
|
|
||||||
var filePath = Path.Combine(uploadPath, fileName);
|
|
||||||
|
|
||||||
|
await using var output = new MemoryStream();
|
||||||
using (var stream = file.OpenReadStream())
|
using (var stream = file.OpenReadStream())
|
||||||
using (var image = await Image.LoadAsync(stream, cancellationToken))
|
using (var image = await Image.LoadAsync(stream, cancellationToken))
|
||||||
{
|
{
|
||||||
@ -412,13 +411,14 @@ public partial class AuthService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
image.Mutate(x => x.Resize(width, height));
|
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
|
Quality = 80
|
||||||
}, cancellationToken);
|
}, 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);
|
var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken);
|
||||||
user.UpdateAvatar(avatarUrl);
|
user.UpdateAvatar(avatarUrl);
|
||||||
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
|
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
|
||||||
|
|||||||
@ -60,6 +60,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Oms": {
|
"Oms": {
|
||||||
|
"FileStorage": {
|
||||||
|
"Provider": "Local",
|
||||||
|
"Cos": {
|
||||||
|
"SecretId": "",
|
||||||
|
"SecretKey": "",
|
||||||
|
"Region": "ap-guangzhou",
|
||||||
|
"Bucket": "your-bucket-1250000000",
|
||||||
|
"BaseUrl": "",
|
||||||
|
"KeyPrefix": "",
|
||||||
|
"DurationSecond": 600,
|
||||||
|
"IsHttps": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"TrackService": {
|
"TrackService": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"BaseUrl": "http://api.nanafa.com/track",
|
"BaseUrl": "http://api.nanafa.com/track",
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user