This commit is contained in:
parent
d1e3672808
commit
dd1318bc00
@ -1,10 +1,10 @@
|
|||||||
# 复制到 admin .gitea/workflows/deploy.yml
|
# 复制到 admin .gitea/workflows/deploy.yml
|
||||||
|
# push master → prod;push test → test
|
||||||
name: Build and Deploy
|
name: Build and Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master, main]
|
branches: [master, test]
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CSPROJ: src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
|
CSPROJ: src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
|
||||||
@ -21,14 +21,47 @@ jobs:
|
|||||||
- name: Build and deploy
|
- name: Build and deploy
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
REF="${{ gitea.ref_name }}"
|
||||||
TAG="${{ gitea.sha }}"
|
case "$REF" in
|
||||||
IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}"
|
test) TARGET=test ;;
|
||||||
|
master) TARGET=prod ;;
|
||||||
|
*) echo "unsupported branch: $REF"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
SHA="${{ gitea.sha }}"
|
||||||
REPO="${{ gitea.repository }}"
|
REPO="${{ gitea.repository }}"
|
||||||
test -n "$REPO" && [[ "$REPO" == */* ]]
|
test -n "$REPO" && [[ "$REPO" == */* ]]
|
||||||
echo "REPO=$REPO TAG=$TAG host=$(hostname)"
|
|
||||||
|
case "$TARGET" in
|
||||||
|
test) TAG="test-${SHA}" ;;
|
||||||
|
*) TAG="${SHA}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}"
|
||||||
|
echo "TARGET=$TARGET REF=$REF REPO=$REPO TAG=$TAG host=$(hostname)"
|
||||||
|
|
||||||
|
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||||
|
unset NAMESPACE
|
||||||
cd /data/k3s
|
cd /data/k3s
|
||||||
./scripts/ci-publish-container.sh "$REPO" "${{ env.CSPROJ }}" \
|
./scripts/ci-publish-container.sh "$REPO" "${{ env.CSPROJ }}" \
|
||||||
"${{ env.IMAGE_NAME }}" "$TAG" "$TAG"
|
"${{ env.IMAGE_NAME }}" "$TAG" "$SHA"
|
||||||
|
|
||||||
|
case "$TARGET" in
|
||||||
|
test)
|
||||||
|
export KUBECONFIG=/etc/rancher/k3s/k3s-test.yaml
|
||||||
|
export NAMESPACE=test
|
||||||
|
export MANIFEST_ROOT=/data/k3s-test
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||||
|
export NAMESPACE=prod
|
||||||
|
export MANIFEST_ROOT=/data/k3s
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ ! -f "$KUBECONFIG" ]]; then
|
||||||
|
echo "FAIL: missing KUBECONFIG=$KUBECONFIG host=$(hostname) uid=$(id -u)"
|
||||||
|
ls -la /etc/rancher/k3s/ /root/.kube/ 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
./scripts/ci-deploy-api.sh "${{ env.DEPLOY_NAME }}" \
|
./scripts/ci-deploy-api.sh "${{ env.DEPLOY_NAME }}" \
|
||||||
"${{ env.CONTAINER_NAME }}" "$IMG"
|
"${{ env.CONTAINER_NAME }}" "$IMG"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- All Fake packages -->
|
<!-- All Fake packages -->
|
||||||
<FakePackageVersion>8.0.5.16</FakePackageVersion>
|
<FakePackageVersion>8.0.5.17</FakePackageVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
|
using Fake.ObjectStorage;
|
||||||
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;
|
||||||
@ -22,7 +23,7 @@ public sealed class LabelPdfGenerationService(
|
|||||||
ISystemOtherConfigRepository systemOtherConfigRepository,
|
ISystemOtherConfigRepository systemOtherConfigRepository,
|
||||||
IOrderRepository orderRepository,
|
IOrderRepository orderRepository,
|
||||||
OrderFileManager orderFileManager,
|
OrderFileManager orderFileManager,
|
||||||
IFileStorage fileStorage,
|
IObjectStorage fileStorage,
|
||||||
ILogger<LabelPdfGenerationService> logger)
|
ILogger<LabelPdfGenerationService> logger)
|
||||||
{
|
{
|
||||||
public async Task<LabelPdfGenerateResult> GenerateAndPersistAsync(
|
public async Task<LabelPdfGenerateResult> GenerateAndPersistAsync(
|
||||||
@ -167,7 +168,7 @@ public sealed class LabelPdfGenerationService(
|
|||||||
var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf";
|
var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf";
|
||||||
var relativeKey = $"uploads/prints/orders/{fileName}";
|
var relativeKey = $"uploads/prints/orders/{fileName}";
|
||||||
await using var stream = new MemoryStream(pdfBytes);
|
await using var stream = new MemoryStream(pdfBytes);
|
||||||
return await fileStorage.SaveAsync(stream, relativeKey, "application/pdf", cancellationToken);
|
return await fileStorage.SaveObjectAsync(stream, relativeKey, "application/pdf", cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Fake.EventBus.RabbitMQ" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.EventBus.RabbitMQ" Version="$(FakePackageVersion)" />
|
||||||
<PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="$(FakePackageVersion)" />
|
||||||
|
<PackageReference Include="Fake.ObjectStorage" Version="$(FakePackageVersion)" />
|
||||||
<PackageReference Include="Fake.RabbitMQ" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.RabbitMQ" Version="$(FakePackageVersion)" />
|
||||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||||
<PackageReference Include="Fake.Rbac.Infrastructure" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.Rbac.Infrastructure" Version="$(FakePackageVersion)" />
|
||||||
@ -29,8 +30,6 @@
|
|||||||
<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="Tencent.QCloud.Cos.Sts.Sdk" Version="3.0.5" />
|
|
||||||
<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,7 +13,6 @@ 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;
|
||||||
|
|
||||||
@ -21,6 +20,7 @@ namespace Oms.Application;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// OMS 应用层模块注册。
|
/// OMS 应用层模块注册。
|
||||||
|
/// 对象存储由 Api 宿主注册 Fake.ObjectStorage(Local / TencentCos);文件元数据走 Fake.FileManagement。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[DependsOn(
|
[DependsOn(
|
||||||
typeof(OmsDomainModule),
|
typeof(OmsDomainModule),
|
||||||
@ -52,7 +52,6 @@ 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,5 +1,6 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
|
using Fake.ObjectStorage;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
@ -14,7 +15,7 @@ namespace Oms.Application.Services.Orders;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class OrderFileManager(
|
public sealed class OrderFileManager(
|
||||||
IOrderFileRepository orderFileRepository,
|
IOrderFileRepository orderFileRepository,
|
||||||
IFileStorage fileStorage) : IScopedDependency
|
IObjectStorage 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" };
|
||||||
@ -94,7 +95,7 @@ public sealed class OrderFileManager(
|
|||||||
OrderId = orderId,
|
OrderId = orderId,
|
||||||
FileType = OrderFileTypeCodes.SystemLabel,
|
FileType = OrderFileTypeCodes.SystemLabel,
|
||||||
FileName = fileName,
|
FileName = fileName,
|
||||||
FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
|
FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
|
||||||
ContentType = contentType,
|
ContentType = contentType,
|
||||||
FileSize = fileSize,
|
FileSize = fileSize,
|
||||||
Sort = 0,
|
Sort = 0,
|
||||||
@ -143,7 +144,7 @@ public sealed class OrderFileManager(
|
|||||||
OrderId = orderId,
|
OrderId = orderId,
|
||||||
FileType = OrderFileTypeCodes.DownstreamLabel,
|
FileType = OrderFileTypeCodes.DownstreamLabel,
|
||||||
FileName = TryFileNameFromUrl(fileUrl),
|
FileName = TryFileNameFromUrl(fileUrl),
|
||||||
FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
|
FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
|
||||||
ContentType = null,
|
ContentType = null,
|
||||||
Sort = existing.Count,
|
Sort = existing.Count,
|
||||||
Source = OrderFileSourceCodes.Downstream,
|
Source = OrderFileSourceCodes.Downstream,
|
||||||
@ -305,7 +306,7 @@ public sealed class OrderFileManager(
|
|||||||
var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
|
var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
|
||||||
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
|
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
|
||||||
await using var stream = file.OpenReadStream();
|
await using var stream = file.OpenReadStream();
|
||||||
var key = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType);
|
var key = await fileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType);
|
||||||
return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length);
|
return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -488,12 +488,12 @@ public partial class OrderService
|
|||||||
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
|
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await FileStorage.ExistsAsync(template.SourceFilePath))
|
if (!await FileStorage.ExistsAsync(FileStoragePath.NormalizeKey(template.SourceFilePath)))
|
||||||
{
|
{
|
||||||
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
|
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var stream = await FileStorage.OpenReadAsync(template.SourceFilePath);
|
await using var stream = await FileStorage.GetAsync(FileStoragePath.NormalizeKey(template.SourceFilePath));
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
await stream.CopyToAsync(ms);
|
await stream.CopyToAsync(ms);
|
||||||
var bytes = ms.ToArray();
|
var bytes = ms.ToArray();
|
||||||
@ -628,7 +628,7 @@ public partial class OrderService
|
|||||||
""";
|
""";
|
||||||
var relativeKey = $"uploads/prints/orders/{fileName}";
|
var relativeKey = $"uploads/prints/orders/{fileName}";
|
||||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(html));
|
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(html));
|
||||||
var key = await FileStorage.SaveAsync(stream, relativeKey, "text/html; charset=utf-8");
|
var key = await FileStorage.SaveObjectAsync(stream, relativeKey, "text/html; charset=utf-8");
|
||||||
return FileStorage.GetPublicUrl(key);
|
return FileStorage.GetPublicUrl(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -647,7 +647,7 @@ public partial class OrderService
|
|||||||
private async Task CleanupExpiredLocalExportFilesAsync()
|
private async Task CleanupExpiredLocalExportFilesAsync()
|
||||||
{
|
{
|
||||||
// COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。
|
// COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。
|
||||||
if (FileStorage is not LocalFileStorage)
|
if (!FileStorage.IsLocalProvider())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
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.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;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Fake.Domain.Exceptions;
|
using Fake.Domain.Exceptions;
|
||||||
|
using Fake.ObjectStorage;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using MiniExcelLibs;
|
using MiniExcelLibs;
|
||||||
@ -35,7 +36,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 IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService<IObjectStorage>();
|
||||||
private LabelPdfGenerationService LabelPdfGenerationService =>
|
private LabelPdfGenerationService LabelPdfGenerationService =>
|
||||||
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
|
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
|
||||||
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
||||||
@ -345,7 +346,7 @@ public partial class OrderService : ApplicationService
|
|||||||
await using var stream = new MemoryStream();
|
await using var stream = new MemoryStream();
|
||||||
await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders));
|
await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders));
|
||||||
stream.Position = 0;
|
stream.Position = 0;
|
||||||
var storageKey = await FileStorage.SaveAsync(
|
var storageKey = await FileStorage.SaveObjectAsync(
|
||||||
stream,
|
stream,
|
||||||
relativeKey,
|
relativeKey,
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
|||||||
@ -6,6 +6,7 @@ using Fake.Rbac.Application.Dtos.User;
|
|||||||
using Fake.Rbac.Application.Services;
|
using Fake.Rbac.Application.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Fake.ObjectStorage;
|
||||||
using Oms.Application.Dtos.Rbac;
|
using Oms.Application.Dtos.Rbac;
|
||||||
using Oms.Application.Rbac;
|
using Oms.Application.Rbac;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
@ -24,7 +25,7 @@ public class OmsUserService(
|
|||||||
UserService userService,
|
UserService userService,
|
||||||
UserProfileRepository profileRepository,
|
UserProfileRepository profileRepository,
|
||||||
IEmployeeRepository employeeRepository,
|
IEmployeeRepository employeeRepository,
|
||||||
IFileStorage fileStorage) : ApplicationService
|
IObjectStorage fileStorage) : ApplicationService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取用户详情(含扩展资料)。
|
/// 获取用户详情(含扩展资料)。
|
||||||
@ -98,7 +99,7 @@ public class OmsUserService(
|
|||||||
Account = input.Account,
|
Account = input.Account,
|
||||||
Password = input.Password,
|
Password = input.Password,
|
||||||
Email = input.Email,
|
Email = input.Email,
|
||||||
Avatar = fileStorage.ToStorageKey(input.Avatar),
|
Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
|
||||||
OrganizationId = input.OrganizationId,
|
OrganizationId = input.OrganizationId,
|
||||||
RoleIds = input.RoleIds,
|
RoleIds = input.RoleIds,
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
@ -136,7 +137,7 @@ public class OmsUserService(
|
|||||||
{
|
{
|
||||||
Name = input.Name,
|
Name = input.Name,
|
||||||
Email = input.Email,
|
Email = input.Email,
|
||||||
Avatar = fileStorage.ToStorageKey(input.Avatar),
|
Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
|
||||||
OrganizationId = input.OrganizationId,
|
OrganizationId = input.OrganizationId,
|
||||||
},
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@ -174,7 +175,7 @@ public class OmsUserService(
|
|||||||
[FromQuery] Guid id,
|
[FromQuery] Guid id,
|
||||||
[FromQuery] string avatarUrl,
|
[FromQuery] string avatarUrl,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
userService.UpdateAvatarAsync(id, fileStorage.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken);
|
userService.UpdateAvatarAsync(id, OmsObjectStorageExtensions.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 删除用户。
|
/// 删除用户。
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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 Fake.ObjectStorage;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.CustomersAggregate.Repositories;
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
@ -22,7 +23,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 IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
|
private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService<IObjectStorage>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 提交充值申请。
|
/// 提交充值申请。
|
||||||
|
|||||||
@ -1,161 +0,0 @@
|
|||||||
using Fake.Application;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Oms.Application.Storage;
|
|
||||||
|
|
||||||
namespace Oms.Application.Services.Storage;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 文件存储直传 / 临时访问。私有桶:前端拿凭证直传 COS,后端只存 key。
|
|
||||||
/// </summary>
|
|
||||||
[Authorize]
|
|
||||||
[ApiExplorerSettings(GroupName = "OMS-Storage")]
|
|
||||||
public class FileStorageService : ApplicationService
|
|
||||||
{
|
|
||||||
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否支持前端直传(Cos=true)。
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("/file-storage/capabilities")]
|
|
||||||
public object GetCapabilities() => new
|
|
||||||
{
|
|
||||||
supportsClientDirectUpload = FileStorage.SupportsClientDirectUpload,
|
|
||||||
provider = FileStorage.GetType().Name
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 签发预签名 PUT 上传地址。前端对该 URL 直接 PUT 文件体,成功后把返回的 key 交给业务接口落库。
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost("/file-storage/presign-upload")]
|
|
||||||
public async Task<PresignUploadOutput> PresignUploadAsync([FromBody] PresignUploadInput input)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(input.FileName))
|
|
||||||
{
|
|
||||||
throw new BusinessException("fileName 不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
var ticket = await FileStorage.CreatePresignedUploadAsync(
|
|
||||||
input.Folder ?? "misc",
|
|
||||||
input.FileName.Trim(),
|
|
||||||
input.ContentType);
|
|
||||||
|
|
||||||
return new PresignUploadOutput
|
|
||||||
{
|
|
||||||
Key = ticket.Key,
|
|
||||||
UploadUrl = ticket.UploadUrl,
|
|
||||||
Method = ticket.Method,
|
|
||||||
ContentType = ticket.ContentType,
|
|
||||||
Headers = ticket.Headers,
|
|
||||||
ExpireAt = ticket.ExpireAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 签发 STS 临时密钥。前端用 cos-js-sdk-v5 等 SDK 直传;落库只存 key。
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost("/file-storage/sts-upload")]
|
|
||||||
public async Task<StsUploadOutput> StsUploadAsync([FromBody] PresignUploadInput input)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(input.FileName))
|
|
||||||
{
|
|
||||||
throw new BusinessException("fileName 不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
var ticket = await FileStorage.CreateStsUploadAsync(
|
|
||||||
input.Folder ?? "misc",
|
|
||||||
input.FileName.Trim());
|
|
||||||
|
|
||||||
return new StsUploadOutput
|
|
||||||
{
|
|
||||||
Key = ticket.Key,
|
|
||||||
CosKey = ticket.CosKey,
|
|
||||||
Bucket = ticket.Bucket,
|
|
||||||
Region = ticket.Region,
|
|
||||||
TmpSecretId = ticket.TmpSecretId,
|
|
||||||
TmpSecretKey = ticket.TmpSecretKey,
|
|
||||||
SessionToken = ticket.SessionToken,
|
|
||||||
StartTime = ticket.StartTime,
|
|
||||||
ExpiredTime = ticket.ExpiredTime
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按库内 key 生成临时访问地址(私有桶预签名 GET)。
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("/file-storage/access-url")]
|
|
||||||
public AccessUrlOutput GetAccessUrl([FromQuery] string key)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(key))
|
|
||||||
{
|
|
||||||
throw new BusinessException("key 不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
var storageKey = FileStorage.ToStorageKey(key) ?? key.Trim();
|
|
||||||
return new AccessUrlOutput
|
|
||||||
{
|
|
||||||
Key = storageKey,
|
|
||||||
Url = FileStorage.GetPublicUrl(storageKey)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将完整 URL / 相对路径规范为库内 key(业务保存前可调用)。
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost("/file-storage/normalize-key")]
|
|
||||||
public NormalizeKeyOutput NormalizeKey([FromBody] NormalizeKeyInput input)
|
|
||||||
{
|
|
||||||
var key = FileStorage.ToStorageKey(input.PathOrUrl);
|
|
||||||
return new NormalizeKeyOutput { Key = key };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PresignUploadInput
|
|
||||||
{
|
|
||||||
/// <summary>业务目录:avatars / sku / parcel / recharge / order-files / xhs 等。</summary>
|
|
||||||
public string? Folder { get; set; }
|
|
||||||
|
|
||||||
public string FileName { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? ContentType { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PresignUploadOutput
|
|
||||||
{
|
|
||||||
public string Key { get; set; } = string.Empty;
|
|
||||||
public string UploadUrl { get; set; } = string.Empty;
|
|
||||||
public string Method { get; set; } = "PUT";
|
|
||||||
public string? ContentType { get; set; }
|
|
||||||
public IReadOnlyDictionary<string, string> Headers { get; set; } =
|
|
||||||
new Dictionary<string, string>();
|
|
||||||
public DateTime ExpireAt { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class StsUploadOutput
|
|
||||||
{
|
|
||||||
public string Key { get; set; } = string.Empty;
|
|
||||||
public string CosKey { get; set; } = string.Empty;
|
|
||||||
public string Bucket { get; set; } = string.Empty;
|
|
||||||
public string Region { get; set; } = string.Empty;
|
|
||||||
public string TmpSecretId { get; set; } = string.Empty;
|
|
||||||
public string TmpSecretKey { get; set; } = string.Empty;
|
|
||||||
public string SessionToken { get; set; } = string.Empty;
|
|
||||||
public long StartTime { get; set; }
|
|
||||||
public long ExpiredTime { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AccessUrlOutput
|
|
||||||
{
|
|
||||||
public string Key { get; set; } = string.Empty;
|
|
||||||
public string Url { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class NormalizeKeyInput
|
|
||||||
{
|
|
||||||
public string? PathOrUrl { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class NormalizeKeyOutput
|
|
||||||
{
|
|
||||||
public string? Key { get; set; }
|
|
||||||
}
|
|
||||||
@ -178,7 +178,7 @@ public partial class WarehouseService
|
|||||||
throw new BusinessException("商品数量必须大于 0");
|
throw new BusinessException("商品数量必须大于 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageUrl = FileStorage.ToStorageKey(itemInput.ImageUrl);
|
var imageUrl = OmsObjectStorageExtensions.ToStorageKey(itemInput.ImageUrl);
|
||||||
if (imageUrl != null && imageUrl.Length > 1024)
|
if (imageUrl != null && imageUrl.Length > 1024)
|
||||||
{
|
{
|
||||||
imageUrl = imageUrl[..1024];
|
imageUrl = imageUrl[..1024];
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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 Fake.ObjectStorage;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
|
|
||||||
@ -11,8 +12,8 @@ namespace Oms.Application.Services.Warehouse;
|
|||||||
|
|
||||||
public partial class WarehouseService
|
public partial class WarehouseService
|
||||||
{
|
{
|
||||||
private IFileStorage FileStorage =>
|
private IObjectStorage FileStorage =>
|
||||||
LazyServiceProvider.GetRequiredService<IFileStorage>();
|
LazyServiceProvider.GetRequiredService<IObjectStorage>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询 SKU 列表。
|
/// 查询 SKU 列表。
|
||||||
@ -117,7 +118,7 @@ public partial class WarehouseService
|
|||||||
entity.SkuCode = skuCode;
|
entity.SkuCode = skuCode;
|
||||||
entity.SkuName = skuName;
|
entity.SkuName = skuName;
|
||||||
entity.SkuNameEn = string.IsNullOrWhiteSpace(input.SkuNameEn) ? null : input.SkuNameEn.Trim();
|
entity.SkuNameEn = string.IsNullOrWhiteSpace(input.SkuNameEn) ? null : input.SkuNameEn.Trim();
|
||||||
entity.SkuImageUrl = FileStorage.ToStorageKey(input.SkuImageUrl);
|
entity.SkuImageUrl = OmsObjectStorageExtensions.ToStorageKey(input.SkuImageUrl);
|
||||||
entity.UnitPrice = input.UnitPrice;
|
entity.UnitPrice = input.UnitPrice;
|
||||||
entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim();
|
entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim();
|
||||||
entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim();
|
entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim();
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
|
using Fake.ObjectStorage;
|
||||||
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;
|
||||||
@ -15,7 +16,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 IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
|
private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService<IObjectStorage>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 上传媒体文件
|
/// 上传媒体文件
|
||||||
@ -34,7 +35,7 @@ public class XhsMediaService : ApplicationService
|
|||||||
var relativeKey = $"uploads/xhs/{customerId}/{storedName}";
|
var relativeKey = $"uploads/xhs/{customerId}/{storedName}";
|
||||||
|
|
||||||
await using var stream = file.OpenReadStream();
|
await using var stream = file.OpenReadStream();
|
||||||
var storageKey = await FileStorage.SaveAsync(stream, relativeKey, file.ContentType);
|
var storageKey = await FileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType);
|
||||||
var publicUrl = FileStorage.GetPublicUrl(storageKey);
|
var publicUrl = FileStorage.GetPublicUrl(storageKey);
|
||||||
|
|
||||||
var media = new XhsMedia
|
var media = new XhsMedia
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
|
using Fake.ObjectStorage;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
|
|
||||||
namespace Oms.Application.Shared;
|
namespace Oms.Application.Shared;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。
|
/// OMS 上传文件辅助(经 <see cref="IObjectStorage"/>)。
|
||||||
/// 返回值为 object key,对外展示请再调用 <see cref="IFileStorage.GetPublicUrl"/>。
|
/// 返回值为 object key,对外展示请再调用 <see cref="OmsObjectStorageExtensions.GetPublicUrl"/>。
|
||||||
|
/// 用户直传请走 Fake.FileManagement(Presign/Sts + Confirm → FileId)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class OmsFileUploadHelper
|
public static class OmsFileUploadHelper
|
||||||
{
|
{
|
||||||
@ -15,27 +17,18 @@ public static class OmsFileUploadHelper
|
|||||||
|
|
||||||
private const long MaxImageBytes = 5 * 1024 * 1024;
|
private const long MaxImageBytes = 5 * 1024 * 1024;
|
||||||
|
|
||||||
/// <summary>
|
public static Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IObjectStorage storage) =>
|
||||||
/// 保存充值截图,返回可访问 URL。
|
|
||||||
/// </summary>
|
|
||||||
public static Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IFileStorage storage) =>
|
|
||||||
SaveImageAsync(file, storage, "recharge", "充值截图");
|
SaveImageAsync(file, storage, "recharge", "充值截图");
|
||||||
|
|
||||||
/// <summary>
|
public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IObjectStorage storage) =>
|
||||||
/// 保存仓储 SKU 图片。
|
|
||||||
/// </summary>
|
|
||||||
public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IFileStorage storage) =>
|
|
||||||
SaveImageAsync(file, storage, "sku", "SKU 图片");
|
SaveImageAsync(file, storage, "sku", "SKU 图片");
|
||||||
|
|
||||||
/// <summary>
|
public static Task<string> SaveWarehouseParcelImageAsync(IFormFile file, IObjectStorage storage) =>
|
||||||
/// 保存包裹商品图片。
|
|
||||||
/// </summary>
|
|
||||||
public static Task<string> SaveWarehouseParcelImageAsync(IFormFile file, IFileStorage storage) =>
|
|
||||||
SaveImageAsync(file, storage, "parcel", "包裹商品图片");
|
SaveImageAsync(file, storage, "parcel", "包裹商品图片");
|
||||||
|
|
||||||
private static async Task<string> SaveImageAsync(
|
private static async Task<string> SaveImageAsync(
|
||||||
IFormFile? file,
|
IFormFile? file,
|
||||||
IFileStorage storage,
|
IObjectStorage storage,
|
||||||
string folder,
|
string folder,
|
||||||
string label)
|
string label)
|
||||||
{
|
{
|
||||||
@ -70,7 +63,7 @@ public static class OmsFileUploadHelper
|
|||||||
var relativeKey = $"uploads/{folder}/{monthFolder}/{fileName}";
|
var relativeKey = $"uploads/{folder}/{monthFolder}/{fileName}";
|
||||||
|
|
||||||
await using var stream = file.OpenReadStream();
|
await using var stream = file.OpenReadStream();
|
||||||
return await storage.SaveAsync(stream, relativeKey, file.ContentType);
|
return await storage.SaveObjectAsync(stream, relativeKey, file.ContentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly HashSet<string> ExcelExtensions =
|
private static readonly HashSet<string> ExcelExtensions =
|
||||||
@ -84,7 +77,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,
|
||||||
IFileStorage storage)
|
IObjectStorage storage)
|
||||||
{
|
{
|
||||||
if (file == null || file.Length <= 0)
|
if (file == null || file.Length <= 0)
|
||||||
{
|
{
|
||||||
@ -106,7 +99,7 @@ public static class OmsFileUploadHelper
|
|||||||
var relativeKey = $"uploads/order-templates/{customerId}/{fileName}";
|
var relativeKey = $"uploads/order-templates/{customerId}/{fileName}";
|
||||||
|
|
||||||
await using var stream = file.OpenReadStream();
|
await using var stream = file.OpenReadStream();
|
||||||
var key = await storage.SaveAsync(
|
var key = await storage.SaveObjectAsync(
|
||||||
stream,
|
stream,
|
||||||
relativeKey,
|
relativeKey,
|
||||||
file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
namespace Oms.Application.Storage;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 预签名 PUT 直传凭证(前端直接 PUT 到 COS,文件不经后端)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PresignedUploadTicket
|
|
||||||
{
|
|
||||||
/// <summary>库内应保存的逻辑 key(不含 KeyPrefix)。</summary>
|
|
||||||
public string Key { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>一次性 PUT 地址(含签名)。</summary>
|
|
||||||
public string UploadUrl { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public string Method { get; init; } = "PUT";
|
|
||||||
|
|
||||||
/// <summary>PUT 时必须带上的 Content-Type(若签发时绑定了则必填)。</summary>
|
|
||||||
public string? ContentType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>PUT 时必须原样携带的请求头。</summary>
|
|
||||||
public IReadOnlyDictionary<string, string> Headers { get; init; } =
|
|
||||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public DateTime ExpireAt { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// STS 临时密钥直传凭证(前端用 cos-js-sdk 等上传)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class StsUploadTicket
|
|
||||||
{
|
|
||||||
/// <summary>库内应保存的逻辑 key。</summary>
|
|
||||||
public string Key { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>COS 对象完整键(含 KeyPrefix),SDK putObject 用。</summary>
|
|
||||||
public string CosKey { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public string Bucket { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public string Region { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public string TmpSecretId { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public string TmpSecretKey { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public string SessionToken { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public long StartTime { get; init; }
|
|
||||||
|
|
||||||
public long ExpiredTime { get; init; }
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
namespace Oms.Application.Storage;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 文件存储配置。Provider=Local 落盘 wwwroot;Provider=Cos 走腾讯云 COS。
|
|
||||||
/// </summary>
|
|
||||||
public class FileStorageOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "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>
|
|
||||||
/// 预签名读 URL 有效期(秒)。私有桶临时访问用。
|
|
||||||
/// </summary>
|
|
||||||
public long DurationSecond { get; set; } = 3600;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 预签名上传 / STS 有效期(秒)。建议短于读有效期。
|
|
||||||
/// </summary>
|
|
||||||
public long UploadDurationSecond { get; set; } = 600;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否对 GetPublicUrl 返回预签名链接。私有桶必须为 true。
|
|
||||||
/// </summary>
|
|
||||||
public bool SignUrls { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>是否 HTTPS。</summary>
|
|
||||||
public bool IsHttps { get; set; } = true;
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace Oms.Application.Storage;
|
|
||||||
|
|
||||||
public static class FileStorageServiceExtensions
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddOmsFileStorage(this IServiceCollection services, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
var section = configuration.GetSection(FileStorageOptions.SectionName);
|
|
||||||
services.Configure<FileStorageOptions>(section);
|
|
||||||
|
|
||||||
var options = section.Get<FileStorageOptions>() ?? new FileStorageOptions();
|
|
||||||
var provider = ResolveProvider(options);
|
|
||||||
|
|
||||||
if (string.Equals(provider, "Cos", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
services.AddSingleton<IFileStorage, TencentCosFileStorage>();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
services.AddSingleton<IFileStorage, LocalFileStorage>();
|
|
||||||
}
|
|
||||||
|
|
||||||
services.AddHostedService<FileStorageStartupLogger>();
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 显式 Provider 优先;未写 Provider 但 Cos 凭证齐全时自动走 Cos,避免生产误落 Local。
|
|
||||||
/// </summary>
|
|
||||||
internal static string ResolveProvider(FileStorageOptions options)
|
|
||||||
{
|
|
||||||
var configured = options.Provider?.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(configured))
|
|
||||||
{
|
|
||||||
return configured;
|
|
||||||
}
|
|
||||||
|
|
||||||
return HasCompleteCos(options.Cos) ? "Cos" : "Local";
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static bool HasCompleteCos(TencentCosOptions? cos) =>
|
|
||||||
cos != null
|
|
||||||
&& !string.IsNullOrWhiteSpace(cos.SecretId)
|
|
||||||
&& !string.IsNullOrWhiteSpace(cos.SecretKey)
|
|
||||||
&& !string.IsNullOrWhiteSpace(cos.Region)
|
|
||||||
&& !string.IsNullOrWhiteSpace(cos.Bucket)
|
|
||||||
&& !cos.Bucket.Contains("your-bucket", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 启动时打印实际 IFileStorage 实现,便于核对 Consul 是否生效。
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class FileStorageStartupLogger(
|
|
||||||
IFileStorage storage,
|
|
||||||
IOptions<FileStorageOptions> options,
|
|
||||||
ILogger<FileStorageStartupLogger> logger) : IHostedService
|
|
||||||
{
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var cfg = options.Value;
|
|
||||||
var cosReady = FileStorageServiceExtensions.HasCompleteCos(cfg.Cos);
|
|
||||||
logger.LogInformation(
|
|
||||||
"IFileStorage 实际类型={Implementation},配置 Provider={ConfiguredProvider},Cos凭证齐全={CosReady},Section={Section}",
|
|
||||||
storage.GetType().FullName,
|
|
||||||
string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider,
|
|
||||||
cosReady,
|
|
||||||
FileStorageOptions.SectionName);
|
|
||||||
|
|
||||||
if (storage is LocalFileStorage && cosReady)
|
|
||||||
{
|
|
||||||
logger.LogWarning(
|
|
||||||
"已检测到齐全 Cos 凭证,但仍在使用 LocalFileStorage。请确认 Consul 中 {Section}:Provider 是否为 Cos(当前={ConfiguredProvider})",
|
|
||||||
FileStorageOptions.SectionName,
|
|
||||||
string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storage is LocalFileStorage && !cosReady)
|
|
||||||
{
|
|
||||||
logger.LogWarning(
|
|
||||||
"当前为 Local 存储。生产若要用 Cos,请在 Consul KV(idwms/admin)配置 {Section}:Provider=Cos 及 Cos.SecretId/SecretKey/Region/Bucket",
|
|
||||||
FileStorageOptions.SectionName);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
using Fake.Application;
|
|
||||||
|
|
||||||
namespace Oms.Application.Storage;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 直传目录与 object key 生成。
|
|
||||||
/// </summary>
|
|
||||||
public static class FileStorageUploadPaths
|
|
||||||
{
|
|
||||||
private static readonly HashSet<string> AllowedFolders = new(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
"avatars",
|
|
||||||
"sku",
|
|
||||||
"parcel",
|
|
||||||
"recharge",
|
|
||||||
"order-files",
|
|
||||||
"order-templates",
|
|
||||||
"xhs",
|
|
||||||
"prints",
|
|
||||||
"exports",
|
|
||||||
"misc"
|
|
||||||
};
|
|
||||||
|
|
||||||
public static string NormalizeFolder(string? folder)
|
|
||||||
{
|
|
||||||
var value = (folder ?? string.Empty).Trim().Trim('/').Replace('\\', '/');
|
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
|
||||||
{
|
|
||||||
throw new BusinessException("上传目录 folder 不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 允许 uploads/sku 或 sku
|
|
||||||
if (value.StartsWith("uploads/", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
value = value["uploads/".Length..];
|
|
||||||
}
|
|
||||||
|
|
||||||
var root = value.Split('/', StringSplitOptions.RemoveEmptyEntries)[0];
|
|
||||||
if (!AllowedFolders.Contains(root))
|
|
||||||
{
|
|
||||||
throw new BusinessException($"不支持的上传目录: {root}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string BuildLogicalKey(string folder, string fileName)
|
|
||||||
{
|
|
||||||
var normalizedFolder = NormalizeFolder(folder);
|
|
||||||
var ext = Path.GetExtension(fileName);
|
|
||||||
if (string.IsNullOrWhiteSpace(ext) || ext.Length > 16)
|
|
||||||
{
|
|
||||||
ext = ".bin";
|
|
||||||
}
|
|
||||||
|
|
||||||
var month = DateTime.Now.ToString("yyyyMM");
|
|
||||||
return $"uploads/{normalizedFolder}/{month}/{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
namespace Oms.Application.Storage;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 统一文件存储(本地 / 腾讯云 COS)。
|
|
||||||
/// 约定:库内只存 object key(如 uploads/avatars/a.jpg)。
|
|
||||||
/// 私有桶场景:访问用临时签名 URL;用户上传用预签名 PUT 或 STS,文件不经业务服务器。
|
|
||||||
/// </summary>
|
|
||||||
public interface IFileStorage
|
|
||||||
{
|
|
||||||
/// <summary>是否支持前端直传(COS=true,Local=false)。</summary>
|
|
||||||
bool SupportsClientDirectUpload { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 服务端落盘/上传(仅用于后端生成的文件,如面单 PDF、导出)。用户上传请走直传。
|
|
||||||
/// </summary>
|
|
||||||
Task<string> SaveAsync(
|
|
||||||
Stream content,
|
|
||||||
string relativeKey,
|
|
||||||
string? contentType = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 临时访问地址。Cos 私有桶返回预签名 GET;Local 返回 /uploads/...;外链原样返回。
|
|
||||||
/// </summary>
|
|
||||||
string GetPublicUrl(string? keyOrUrl);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 签发前端直传用的预签名 PUT URL。
|
|
||||||
/// </summary>
|
|
||||||
Task<PresignedUploadTicket> CreatePresignedUploadAsync(
|
|
||||||
string folder,
|
|
||||||
string fileName,
|
|
||||||
string? contentType = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 签发 STS 临时密钥(前端 COS SDK 直传)。不支持时抛业务异常。
|
|
||||||
/// </summary>
|
|
||||||
Task<StsUploadTicket> CreateStsUploadAsync(
|
|
||||||
string folder,
|
|
||||||
string fileName,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将 key / 相对路径 / 完整 URL 规范为库内应保存的 object key。
|
|
||||||
/// </summary>
|
|
||||||
string? ToStorageKey(string? keyOrUrl);
|
|
||||||
|
|
||||||
Task<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<bool> ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
using Fake.Application;
|
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
|
|
||||||
namespace Oms.Application.Storage;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 本地 wwwroot 文件存储。库内存 key,对外为 /key。不支持前端直传 COS。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage
|
|
||||||
{
|
|
||||||
public bool SupportsClientDirectUpload => false;
|
|
||||||
|
|
||||||
public async Task<string> SaveAsync(
|
|
||||||
Stream content,
|
|
||||||
string relativeKey,
|
|
||||||
string? contentType = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var key = FileStoragePath.NormalizeKey(relativeKey);
|
|
||||||
var absolutePath = ToAbsolutePath(key);
|
|
||||||
var directory = Path.GetDirectoryName(absolutePath);
|
|
||||||
if (!string.IsNullOrWhiteSpace(directory))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException ex)
|
|
||||||
{
|
|
||||||
throw new UnauthorizedAccessException(
|
|
||||||
$"本地文件存储无法写入目录 '{directory}'(WebRoot={GetWebRoot()})。" +
|
|
||||||
"容器请确保 wwwroot/uploads 可写,或将 Oms:FileStorage:Provider 设为 Cos。",
|
|
||||||
ex);
|
|
||||||
}
|
|
||||||
catch (IOException ex)
|
|
||||||
{
|
|
||||||
throw new IOException(
|
|
||||||
$"本地文件存储无法创建目录 '{directory}'(WebRoot={GetWebRoot()})。" +
|
|
||||||
"容器请确保 wwwroot/uploads 可写,或将 Oms:FileStorage:Provider 设为 Cos。",
|
|
||||||
ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetPublicUrl(string? keyOrUrl)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0")
|
|
||||||
{
|
|
||||||
return keyOrUrl ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
|
|
||||||
{
|
|
||||||
return keyOrUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyOrUrl.StartsWith('/'))
|
|
||||||
{
|
|
||||||
return keyOrUrl.Replace('\\', '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/" + FileStoragePath.NormalizeKey(keyOrUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<PresignedUploadTicket> CreatePresignedUploadAsync(
|
|
||||||
string folder,
|
|
||||||
string fileName,
|
|
||||||
string? contentType = null,
|
|
||||||
CancellationToken cancellationToken = default) =>
|
|
||||||
throw new BusinessException(
|
|
||||||
"当前为 Local 存储,不支持 COS 直传。请将 Oms:FileStorage:Provider 设为 Cos,或继续使用服务端 multipart 上传。");
|
|
||||||
|
|
||||||
public Task<StsUploadTicket> CreateStsUploadAsync(
|
|
||||||
string folder,
|
|
||||||
string fileName,
|
|
||||||
CancellationToken cancellationToken = default) =>
|
|
||||||
throw new BusinessException("当前为 Local 存储,不支持 STS 直传。请将 Oms:FileStorage:Provider 设为 Cos。");
|
|
||||||
|
|
||||||
public string? ToStorageKey(string? keyOrUrl)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(keyOrUrl))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyOrUrl == "0")
|
|
||||||
{
|
|
||||||
return "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
|
|
||||||
{
|
|
||||||
return keyOrUrl.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return FileStoragePath.NormalizeKey(keyOrUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ToAbsolutePath(FileStoragePath.NormalizeKey(pathOrUrl));
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ToAbsolutePath(string relativeKey) =>
|
|
||||||
Path.Combine(GetWebRoot(), relativeKey.Replace('/', Path.DirectorySeparatorChar));
|
|
||||||
|
|
||||||
private string GetWebRoot() =>
|
|
||||||
string.IsNullOrWhiteSpace(environment.WebRootPath)
|
|
||||||
? Path.Combine(environment.ContentRootPath, "wwwroot")
|
|
||||||
: environment.WebRootPath;
|
|
||||||
}
|
|
||||||
86
src/Oms.Application/Storage/OmsObjectStorageExtensions.cs
Normal file
86
src/Oms.Application/Storage/OmsObjectStorageExtensions.cs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
using Fake.ObjectStorage;
|
||||||
|
|
||||||
|
namespace Oms.Application.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OMS 对 <see cref="IObjectStorage"/> 的约定封装:库内存逻辑 object key(或外链原样),展示用临时/公开 URL。
|
||||||
|
/// </summary>
|
||||||
|
public static class OmsObjectStorageExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 将 key / 相对路径规范为库内 object key;绝对外链原样返回;空返回 null。
|
||||||
|
/// </summary>
|
||||||
|
public static string? ToStorageKey(string? keyOrUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(keyOrUrl))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = keyOrUrl.Trim();
|
||||||
|
if (raw == "0")
|
||||||
|
{
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FileStoragePath.IsAbsoluteUrl(raw))
|
||||||
|
{
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileStoragePath.NormalizeKey(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 临时访问地址。空值返回空串;外链原样返回;逻辑 key 走 <see cref="IObjectStorage.GetUrlAsync"/>。
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<string> GetPublicUrlAsync(
|
||||||
|
this IObjectStorage storage,
|
||||||
|
string? keyOrUrl,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0")
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = keyOrUrl.Trim();
|
||||||
|
if (FileStoragePath.IsAbsoluteUrl(raw))
|
||||||
|
{
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = FileStoragePath.NormalizeKey(raw);
|
||||||
|
return await storage.GetUrlAsync(key, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 同步取访问 URL(映射 DTO 等场景)。
|
||||||
|
/// </summary>
|
||||||
|
public static string GetPublicUrl(this IObjectStorage storage, string? keyOrUrl) =>
|
||||||
|
storage.GetPublicUrlAsync(keyOrUrl).ConfigureAwait(false).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务端写入对象,返回逻辑 object key。
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<string> SaveObjectAsync(
|
||||||
|
this IObjectStorage storage,
|
||||||
|
Stream content,
|
||||||
|
string relativeKey,
|
||||||
|
string? contentType = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await storage.SaveAsync(
|
||||||
|
new ObjectStorageSaveArgs
|
||||||
|
{
|
||||||
|
ObjectKey = FileStoragePath.NormalizeKey(relativeKey),
|
||||||
|
Content = content,
|
||||||
|
ContentType = contentType
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
return result.ObjectKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsLocalProvider(this IObjectStorage storage) =>
|
||||||
|
string.Equals(storage.Name, "Local", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@ -1,487 +0,0 @@
|
|||||||
using COSSTS;
|
|
||||||
using COSXML;
|
|
||||||
using COSXML.Auth;
|
|
||||||
using COSXML.Model.Object;
|
|
||||||
using COSXML.Model.Tag;
|
|
||||||
using Fake.Application;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
|
|
||||||
namespace Oms.Application.Storage;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 腾讯云 COS:库内存逻辑 key;读返回预签名 GET;写支持预签名 PUT / STS 前端直传。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class TencentCosFileStorage : IFileStorage, IDisposable
|
|
||||||
{
|
|
||||||
private static readonly string[] UploadActions =
|
|
||||||
[
|
|
||||||
"name/cos:PutObject",
|
|
||||||
"name/cos:PostObject",
|
|
||||||
"name/cos:InitiateMultipartUpload",
|
|
||||||
"name/cos:ListMultipartUploads",
|
|
||||||
"name/cos:ListParts",
|
|
||||||
"name/cos:UploadPart",
|
|
||||||
"name/cos:CompleteMultipartUpload",
|
|
||||||
"name/cos:AbortMultipartUpload"
|
|
||||||
];
|
|
||||||
|
|
||||||
private readonly TencentCosOptions _options;
|
|
||||||
private readonly CosXmlServer _cosXml;
|
|
||||||
private readonly ILogger<TencentCosFileStorage> _logger;
|
|
||||||
private readonly string _publicBaseUrl;
|
|
||||||
private readonly string _keyPrefix;
|
|
||||||
private readonly string _appId;
|
|
||||||
private readonly string? _customHost;
|
|
||||||
|
|
||||||
public TencentCosFileStorage(
|
|
||||||
IOptions<FileStorageOptions> options,
|
|
||||||
ILogger<TencentCosFileStorage> logger)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_options = options.Value.Cos ?? new TencentCosOptions();
|
|
||||||
ValidateOptions(_options);
|
|
||||||
|
|
||||||
_appId = ExtractAppId(_options.Bucket);
|
|
||||||
var config = new CosXmlConfig.Builder()
|
|
||||||
.IsHttps(_options.IsHttps)
|
|
||||||
.SetRegion(_options.Region)
|
|
||||||
.SetAppid(_appId)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var signSeconds = Math.Max(60, Math.Max(_options.DurationSecond, _options.UploadDurationSecond));
|
|
||||||
var credential = new DefaultQCloudCredentialProvider(
|
|
||||||
_options.SecretId,
|
|
||||||
_options.SecretKey,
|
|
||||||
signSeconds);
|
|
||||||
|
|
||||||
_cosXml = new CosXmlServer(config, credential);
|
|
||||||
_publicBaseUrl = ResolvePublicBaseUrl(_options);
|
|
||||||
_keyPrefix = (_options.KeyPrefix ?? string.Empty).Replace('\\', '/').Trim('/');
|
|
||||||
_customHost = Uri.TryCreate(_publicBaseUrl, UriKind.Absolute, out var baseUri)
|
|
||||||
? baseUri.Host
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool SupportsClientDirectUpload => true;
|
|
||||||
|
|
||||||
public Task<string> SaveAsync(
|
|
||||||
Stream content,
|
|
||||||
string relativeKey,
|
|
||||||
string? contentType = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var logicalKey = FileStoragePath.NormalizeKey(relativeKey);
|
|
||||||
var objectKey = ToObjectKey(logicalKey);
|
|
||||||
if (content.CanSeek)
|
|
||||||
{
|
|
||||||
content.Position = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("COS 服务端上传成功: {ObjectKey}", objectKey);
|
|
||||||
return Task.FromResult(logicalKey);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
owned?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetPublicUrl(string? keyOrUrl)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0")
|
|
||||||
{
|
|
||||||
return keyOrUrl ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
|
|
||||||
{
|
|
||||||
if (!IsManagedPublicUrl(keyOrUrl))
|
|
||||||
{
|
|
||||||
return keyOrUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 已是签名 URL:原样返回,避免重复签名
|
|
||||||
if (keyOrUrl.Contains("q-signature=", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| keyOrUrl.Contains("sign=", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return keyOrUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
var logical = StripKeyPrefix(FileStoragePath.NormalizeKey(keyOrUrl));
|
|
||||||
return BuildSignedGetUrl(logical);
|
|
||||||
}
|
|
||||||
|
|
||||||
return BuildSignedGetUrl(keyOrUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<PresignedUploadTicket> CreatePresignedUploadAsync(
|
|
||||||
string folder,
|
|
||||||
string fileName,
|
|
||||||
string? contentType = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var logicalKey = FileStorageUploadPaths.BuildLogicalKey(folder, fileName);
|
|
||||||
var objectKey = ToObjectKey(logicalKey);
|
|
||||||
var duration = Math.Max(60, _options.UploadDurationSecond);
|
|
||||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
if (!string.IsNullOrWhiteSpace(contentType))
|
|
||||||
{
|
|
||||||
headers["Content-Type"] = contentType.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
var preSign = new PreSignatureStruct
|
|
||||||
{
|
|
||||||
appid = _appId,
|
|
||||||
bucket = _options.Bucket,
|
|
||||||
region = _options.Region,
|
|
||||||
key = objectKey,
|
|
||||||
httpMethod = "PUT",
|
|
||||||
isHttps = _options.IsHttps,
|
|
||||||
signDurationSecond = duration,
|
|
||||||
headers = headers.Count > 0 ? headers : null,
|
|
||||||
queryParameters = null
|
|
||||||
};
|
|
||||||
ApplyCustomHost(preSign);
|
|
||||||
|
|
||||||
var uploadUrl = _cosXml.GenerateSignURL(preSign);
|
|
||||||
return Task.FromResult(new PresignedUploadTicket
|
|
||||||
{
|
|
||||||
Key = logicalKey,
|
|
||||||
UploadUrl = uploadUrl,
|
|
||||||
Method = "PUT",
|
|
||||||
ContentType = string.IsNullOrWhiteSpace(contentType) ? null : contentType.Trim(),
|
|
||||||
Headers = headers,
|
|
||||||
ExpireAt = DateTime.UtcNow.AddSeconds(duration)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<StsUploadTicket> CreateStsUploadAsync(
|
|
||||||
string folder,
|
|
||||||
string fileName,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var logicalKey = FileStorageUploadPaths.BuildLogicalKey(folder, fileName);
|
|
||||||
var objectKey = ToObjectKey(logicalKey);
|
|
||||||
var duration = (int)Math.Max(60, _options.UploadDurationSecond);
|
|
||||||
|
|
||||||
var values = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["bucket"] = _options.Bucket,
|
|
||||||
["region"] = _options.Region,
|
|
||||||
["allowPrefix"] = objectKey,
|
|
||||||
["allowActions"] = UploadActions,
|
|
||||||
["durationSeconds"] = duration,
|
|
||||||
["secretId"] = _options.SecretId,
|
|
||||||
["secretKey"] = _options.SecretKey
|
|
||||||
};
|
|
||||||
|
|
||||||
Dictionary<string, object> credential;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
credential = STSClient.genCredential(values);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "申请 COS STS 临时密钥失败");
|
|
||||||
throw new BusinessException("申请 COS 临时上传凭证失败,请检查 SecretId/SecretKey 与 CAM 权限");
|
|
||||||
}
|
|
||||||
|
|
||||||
var credentials = ExtractCredentials(credential["Credentials"]);
|
|
||||||
var startTime = ToUnixSeconds(credential.GetValueOrDefault("StartTime"));
|
|
||||||
var expiredTime = ToUnixSeconds(credential.GetValueOrDefault("ExpiredTime"));
|
|
||||||
if (startTime <= 0)
|
|
||||||
{
|
|
||||||
startTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expiredTime <= 0)
|
|
||||||
{
|
|
||||||
expiredTime = startTime + duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.FromResult(new StsUploadTicket
|
|
||||||
{
|
|
||||||
Key = logicalKey,
|
|
||||||
CosKey = objectKey,
|
|
||||||
Bucket = _options.Bucket,
|
|
||||||
Region = _options.Region,
|
|
||||||
TmpSecretId = credentials.tmpSecretId,
|
|
||||||
TmpSecretKey = credentials.tmpSecretKey,
|
|
||||||
SessionToken = credentials.token,
|
|
||||||
StartTime = startTime,
|
|
||||||
ExpiredTime = expiredTime
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public string? ToStorageKey(string? keyOrUrl)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(keyOrUrl))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyOrUrl == "0")
|
|
||||||
{
|
|
||||||
return "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
|
|
||||||
{
|
|
||||||
if (!IsManagedPublicUrl(keyOrUrl))
|
|
||||||
{
|
|
||||||
return keyOrUrl.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
var pathKey = FileStoragePath.NormalizeKey(keyOrUrl);
|
|
||||||
return StripKeyPrefix(pathKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
return FileStoragePath.NormalizeKey(keyOrUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
private string BuildSignedGetUrl(string logicalOrRelativeKey)
|
|
||||||
{
|
|
||||||
var objectKey = ToObjectKey(logicalOrRelativeKey);
|
|
||||||
if (!_options.SignUrls)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Cos.SignUrls=false,私有桶下浏览器将无法直接打开对象。建议保持 SignUrls=true。ObjectKey={ObjectKey}",
|
|
||||||
objectKey);
|
|
||||||
return $"{_publicBaseUrl}/{objectKey}";
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var preSign = new PreSignatureStruct
|
|
||||||
{
|
|
||||||
appid = _appId,
|
|
||||||
bucket = _options.Bucket,
|
|
||||||
region = _options.Region,
|
|
||||||
key = objectKey,
|
|
||||||
httpMethod = "GET",
|
|
||||||
isHttps = _options.IsHttps,
|
|
||||||
signDurationSecond = Math.Max(60, _options.DurationSecond),
|
|
||||||
headers = null,
|
|
||||||
queryParameters = null
|
|
||||||
};
|
|
||||||
ApplyCustomHost(preSign);
|
|
||||||
return _cosXml.GenerateSignURL(preSign);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "生成 COS 预签名 GET 失败: {ObjectKey}", objectKey);
|
|
||||||
throw new BusinessException("生成临时访问地址失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyCustomHost(PreSignatureStruct preSign)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(_customHost)
|
|
||||||
&& !_customHost.EndsWith(".myqcloud.com", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
preSign.host = _customHost;
|
|
||||||
preSign.signHost = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ToObjectKey(string pathOrUrl)
|
|
||||||
{
|
|
||||||
var key = FileStoragePath.NormalizeKey(pathOrUrl);
|
|
||||||
if (!string.IsNullOrEmpty(_keyPrefix)
|
|
||||||
&& !key.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& !string.Equals(key, _keyPrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
key = $"{_keyPrefix}/{key}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string StripKeyPrefix(string objectOrLogicalKey)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(_keyPrefix))
|
|
||||||
{
|
|
||||||
return objectOrLogicalKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (objectOrLogicalKey.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return objectOrLogicalKey[(_keyPrefix.Length + 1)..];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(objectOrLogicalKey, _keyPrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
return objectOrLogicalKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsManagedPublicUrl(string url)
|
|
||||||
{
|
|
||||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Uri.TryCreate(_publicBaseUrl, UriKind.Absolute, out var baseUri)
|
|
||||||
&& string.Equals(uri.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultHost = $"{_options.Bucket}.cos.{_options.Region}.myqcloud.com";
|
|
||||||
return string.Equals(uri.Host, defaultHost, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (string tmpSecretId, string tmpSecretKey, string token) ExtractCredentials(object? raw)
|
|
||||||
{
|
|
||||||
if (raw == null)
|
|
||||||
{
|
|
||||||
throw new BusinessException("STS 返回 Credentials 为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = JsonConvert.SerializeObject(raw);
|
|
||||||
var obj = JObject.Parse(json);
|
|
||||||
var id = obj.Value<string>("TmpSecretId") ?? obj.Value<string>("tmpSecretId");
|
|
||||||
var key = obj.Value<string>("TmpSecretKey") ?? obj.Value<string>("tmpSecretKey");
|
|
||||||
var token = obj.Value<string>("Token") ?? obj.Value<string>("sessionToken") ?? obj.Value<string>("token");
|
|
||||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(token))
|
|
||||||
{
|
|
||||||
throw new BusinessException("STS 凭证字段不完整");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (id, key, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long ToUnixSeconds(object? value) =>
|
|
||||||
value switch
|
|
||||||
{
|
|
||||||
null => 0,
|
|
||||||
long l => l,
|
|
||||||
int i => i,
|
|
||||||
string s when long.TryParse(s, out var n) => n,
|
|
||||||
JValue j when j.Type == JTokenType.Integer => j.Value<long>(),
|
|
||||||
_ => 0
|
|
||||||
};
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Fake.ObjectStorage;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.CustomersAggregate.Entities;
|
using Oms.Domain.CustomersAggregate.Entities;
|
||||||
@ -43,7 +44,7 @@ public partial class AuthService(
|
|||||||
IWeChatLoginSessionStore weChatLoginSessionStore,
|
IWeChatLoginSessionStore weChatLoginSessionStore,
|
||||||
IMemoryCache memoryCache,
|
IMemoryCache memoryCache,
|
||||||
IRbacAuthCache rbacAuthCache,
|
IRbacAuthCache rbacAuthCache,
|
||||||
IFileStorage fileStorage)
|
IObjectStorage fileStorage)
|
||||||
: ApplicationService
|
: ApplicationService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3);
|
private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3);
|
||||||
@ -418,7 +419,7 @@ public partial class AuthService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
output.Position = 0;
|
output.Position = 0;
|
||||||
var avatarKey = await fileStorage.SaveAsync(output, relativeKey, "image/jpeg", cancellationToken);
|
var avatarKey = await fileStorage.SaveObjectAsync(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(avatarKey);
|
user.UpdateAvatar(avatarKey);
|
||||||
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
|
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
|
||||||
|
|||||||
@ -15,6 +15,9 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Fake.Autofac" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.Autofac" Version="$(FakePackageVersion)" />
|
||||||
<PackageReference Include="Fake.Consul" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.Consul" Version="$(FakePackageVersion)" />
|
||||||
|
<PackageReference Include="Fake.FileManagement.Application" Version="$(FakePackageVersion)" />
|
||||||
|
<PackageReference Include="Fake.FileManagement.Infrastructure" Version="$(FakePackageVersion)" />
|
||||||
|
<PackageReference Include="Fake.ObjectStorage.TencentCos" Version="$(FakePackageVersion)" />
|
||||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||||
<PackageReference Include="Fake.Serilog" Version="$(FakePackageVersion)" />
|
<PackageReference Include="Fake.Serilog" Version="$(FakePackageVersion)" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3"/>
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3"/>
|
||||||
|
|||||||
@ -8,15 +8,20 @@ using Fake.AspNetCore.ExceptionHandling;
|
|||||||
using Fake.AspNetCore.Newtonsoft;
|
using Fake.AspNetCore.Newtonsoft;
|
||||||
using Fake.AspNetCore.Swagger;
|
using Fake.AspNetCore.Swagger;
|
||||||
using Fake.FeiShu;
|
using Fake.FeiShu;
|
||||||
|
using Fake.FileManagement.Application;
|
||||||
|
using Fake.FileManagement.Infrastructure;
|
||||||
|
using Fake.ObjectStorage;
|
||||||
using Fake.Security.Claims;
|
using Fake.Security.Claims;
|
||||||
using Fake.Serilog;
|
using Fake.Serilog;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Mvc.Authorization;
|
using Microsoft.AspNetCore.Mvc.Authorization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Oms.Application;
|
using Oms.Application;
|
||||||
using Oms.SqlSugarCore;
|
using Oms.SqlSugarCore;
|
||||||
using SimpleAdmin.Api.Auth;
|
using SimpleAdmin.Api.Auth;
|
||||||
|
using SimpleAdmin.Api.Storage;
|
||||||
using SimpleAdmin.Api.Swagger;
|
using SimpleAdmin.Api.Swagger;
|
||||||
using SimpleAdmin.Api.WeChat;
|
using SimpleAdmin.Api.WeChat;
|
||||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
@ -29,6 +34,9 @@ namespace SimpleAdmin.Api;
|
|||||||
typeof(OmsSqlSugarCoreModule),
|
typeof(OmsSqlSugarCoreModule),
|
||||||
typeof(FakeRbacApplicationModule),
|
typeof(FakeRbacApplicationModule),
|
||||||
typeof(FakeRbacInfrastructureModule),
|
typeof(FakeRbacInfrastructureModule),
|
||||||
|
typeof(FakeFileManagementApplicationModule),
|
||||||
|
typeof(FakeFileManagementInfrastructureModule),
|
||||||
|
typeof(FakeObjectStorageTencentCosModule),
|
||||||
typeof(FakeAspNetCoreModule),
|
typeof(FakeAspNetCoreModule),
|
||||||
typeof(FakeSerilogModule)
|
typeof(FakeSerilogModule)
|
||||||
)]
|
)]
|
||||||
@ -46,7 +54,7 @@ public class SimpleAdminApiModule : FakeModule
|
|||||||
var services = context.Services;
|
var services = context.Services;
|
||||||
var configuration = context.Services.GetConfiguration();
|
var configuration = context.Services.GetConfiguration();
|
||||||
|
|
||||||
// 配置动态 API - 扫描 OMS 与 RBAC 应用服务
|
// 配置动态 API - 扫描 OMS / RBAC / FileManagement 应用服务
|
||||||
services.Configure<FakeAspNetCoreMvcOptions>(options =>
|
services.Configure<FakeAspNetCoreMvcOptions>(options =>
|
||||||
{
|
{
|
||||||
options.ApplicationServices2Controller<OmsApplicationModule>();
|
options.ApplicationServices2Controller<OmsApplicationModule>();
|
||||||
@ -63,6 +71,10 @@ public class SimpleAdminApiModule : FakeModule
|
|||||||
type != typeof(Fake.Rbac.Application.Services.AuthService) &&
|
type != typeof(Fake.Rbac.Application.Services.AuthService) &&
|
||||||
type != typeof(Fake.Rbac.Application.Services.UserService);
|
type != typeof(Fake.Rbac.Application.Services.UserService);
|
||||||
});
|
});
|
||||||
|
options.ApplicationServices2Controller<FakeFileManagementApplicationModule>(settings =>
|
||||||
|
{
|
||||||
|
settings.RootPath = "files";
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddOmsWeChat(configuration);
|
services.AddOmsWeChat(configuration);
|
||||||
@ -128,6 +140,8 @@ public class SimpleAdminApiModule : FakeModule
|
|||||||
});
|
});
|
||||||
// 添加授权服务
|
// 添加授权服务
|
||||||
services.AddAuthorization();
|
services.AddAuthorization();
|
||||||
|
|
||||||
|
services.AddHostedService<ObjectStorageStartupLogger>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void ConfigureApplication(ApplicationConfigureContext context)
|
public override void ConfigureApplication(ApplicationConfigureContext context)
|
||||||
@ -163,6 +177,13 @@ public class SimpleAdminApiModule : FakeModule
|
|||||||
// 端点映射
|
// 端点映射
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
// FileManagement 与 RBAC 同库并行(EF);应用 Pending migrations 建 stored_file
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var fileDb = scope.ServiceProvider.GetRequiredService<FileManagementDbContext>();
|
||||||
|
fileDb.Database.Migrate();
|
||||||
|
}
|
||||||
|
|
||||||
app.Services.GetRequiredService<IFeiShuNotificationService>().Send("Application started");
|
app.Services.GetRequiredService<IFeiShuNotificationService>().Send("Application started");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs
Normal file
36
src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using Fake.ObjectStorage;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace SimpleAdmin.Api.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启动时打印实际 IObjectStorage 实现,便于核对 Consul ObjectStorage 配置是否生效。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ObjectStorageStartupLogger(
|
||||||
|
IObjectStorage storage,
|
||||||
|
IOptions<FakeObjectStorageOptions> options,
|
||||||
|
ILogger<ObjectStorageStartupLogger> logger) : IHostedService
|
||||||
|
{
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cfg = options.Value;
|
||||||
|
logger.LogInformation(
|
||||||
|
"IObjectStorage 实际类型={Implementation},Name={Name},配置 Provider={ConfiguredProvider},SignUrlsByDefault={SignUrlsByDefault},KeyPrefix={KeyPrefix}",
|
||||||
|
storage.GetType().FullName,
|
||||||
|
storage.Name,
|
||||||
|
string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider,
|
||||||
|
cfg.SignUrlsByDefault,
|
||||||
|
string.IsNullOrWhiteSpace(cfg.KeyPrefix) ? "(empty)" : cfg.KeyPrefix);
|
||||||
|
|
||||||
|
if (string.Equals(storage.Name, "Local", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(cfg.Provider, "TencentCos", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"配置 Provider=TencentCos,但仍在使用 Local。请确认已引用 Fake.ObjectStorage.TencentCos 且 Consul 中 ObjectStorage:Provider 已生效");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@ -59,22 +59,34 @@
|
|||||||
"FollowUrl": ""
|
"FollowUrl": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Oms": {
|
"ObjectStorage": {
|
||||||
"FileStorage": {
|
|
||||||
"Provider": "Local",
|
"Provider": "Local",
|
||||||
"Cos": {
|
"KeyPrefix": "",
|
||||||
|
"SignUrlsByDefault": true,
|
||||||
|
"DefaultSignedUrlExpiresSeconds": 3600,
|
||||||
|
"DefaultUploadExpiresSeconds": 600,
|
||||||
|
"Local": {
|
||||||
|
"BasePath": "wwwroot",
|
||||||
|
"BaseUrl": ""
|
||||||
|
},
|
||||||
|
"TencentCos": {
|
||||||
"SecretId": "",
|
"SecretId": "",
|
||||||
"SecretKey": "",
|
"SecretKey": "",
|
||||||
"Region": "ap-guangzhou",
|
"Region": "ap-guangzhou",
|
||||||
"Bucket": "your-bucket-1250000000",
|
"Bucket": "your-bucket-1250000000",
|
||||||
"BaseUrl": "",
|
"BaseUrl": "",
|
||||||
"KeyPrefix": "",
|
|
||||||
"DurationSecond": 3600,
|
|
||||||
"UploadDurationSecond": 600,
|
|
||||||
"SignUrls": true,
|
"SignUrls": true,
|
||||||
"IsHttps": true
|
"UseHttps": true,
|
||||||
|
"DurationSecond": 600
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"FileManagement": {
|
||||||
|
"SignUrlsByDefault": true,
|
||||||
|
"DefaultSignedUrlExpiresSeconds": 3600,
|
||||||
|
"DefaultMaxSizeBytes": 20971520,
|
||||||
|
"DefaultStorageSource": "default"
|
||||||
|
},
|
||||||
|
"Oms": {
|
||||||
"TrackService": {
|
"TrackService": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"BaseUrl": "http://api.nanafa.com/track",
|
"BaseUrl": "http://api.nanafa.com/track",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user