feat:对接fake oss
All checks were successful
Build and Deploy / deploy (push) Successful in 59s

This commit is contained in:
xiaolipro 2026-09-22 16:59:33 +08:00
parent d1e3672808
commit dd1318bc00
29 changed files with 263 additions and 1213 deletions

View File

@ -1,10 +1,10 @@
# 复制到 admin .gitea/workflows/deploy.yml
# 复制到 admin .gitea/workflows/deploy.yml
# push master → prod;push test → test
name: Build and Deploy
on:
push:
branches: [master, main]
workflow_dispatch:
branches: [master, test]
env:
CSPROJ: src/SimpleAdmin.Api/SimpleAdmin.Api.csproj
@ -21,14 +21,47 @@ jobs:
- name: Build and deploy
run: |
set -euxo pipefail
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
TAG="${{ gitea.sha }}"
IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}"
REF="${{ gitea.ref_name }}"
case "$REF" in
test) TARGET=test ;;
master) TARGET=prod ;;
*) echo "unsupported branch: $REF"; exit 1 ;;
esac
SHA="${{ gitea.sha }}"
REPO="${{ gitea.repository }}"
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
./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 }}" \
"${{ env.CONTAINER_NAME }}" "$IMG"

View File

@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<!-- All Fake packages -->
<FakePackageVersion>8.0.5.16</FakePackageVersion>
<FakePackageVersion>8.0.5.17</FakePackageVersion>
</PropertyGroup>
</Project>

View File

@ -1,4 +1,5 @@
using Fake.Application;
using Fake.ObjectStorage;
using Microsoft.Extensions.Logging;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
@ -22,7 +23,7 @@ public sealed class LabelPdfGenerationService(
ISystemOtherConfigRepository systemOtherConfigRepository,
IOrderRepository orderRepository,
OrderFileManager orderFileManager,
IFileStorage fileStorage,
IObjectStorage fileStorage,
ILogger<LabelPdfGenerationService> logger)
{
public async Task<LabelPdfGenerateResult> GenerateAndPersistAsync(
@ -167,7 +168,7 @@ public sealed class LabelPdfGenerationService(
var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf";
var relativeKey = $"uploads/prints/orders/{fileName}";
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);
}
}

View File

@ -20,6 +20,7 @@
<ItemGroup>
<PackageReference Include="Fake.EventBus.RabbitMQ" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.ObjectStorage" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.RabbitMQ" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.Rbac.Infrastructure" Version="$(FakePackageVersion)" />
@ -29,8 +30,6 @@
<PackageReference Include="MiniExcel" Version="1.43.1" />
<PackageReference Include="QuestPDF" Version="2025.7.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.49" />
<PackageReference Include="Tencent.QCloud.Cos.Sts.Sdk" Version="3.0.5" />
<PackageReference Include="ZXing.Net" Version="0.16.10" />
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" />
</ItemGroup>

View File

@ -13,7 +13,6 @@ using Oms.Application.Integrations.PlatformShop;
using Oms.Application.Labeling;
using Oms.Application.Labeling.Hiprint;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Application.Workers;
using Oms.Domain;
@ -21,6 +20,7 @@ namespace Oms.Application;
/// <summary>
/// OMS 应用层模块注册。
/// 对象存储由 Api 宿主注册 Fake.ObjectStorage(Local / TencentCos);文件元数据走 Fake.FileManagement。
/// </summary>
[DependsOn(
typeof(OmsDomainModule),
@ -52,7 +52,6 @@ public class OmsApplicationModule : FakeModule
context.Services.AddTransient<ILabelPdfRenderer, QuestPdfLabelRenderer>();
context.Services.AddTransient<LabelPdfGenerationService>();
context.Services.AddOmsFileStorage(configuration);
context.Services.AddOmsSmallBagTrack(configuration);
context.Services.AddOmsItfxIntegration(configuration);
context.Services.AddOmsPlatformShopIntegration(configuration);

View File

@ -1,5 +1,6 @@
using Fake.Application;
using Fake.DependencyInjection;
using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http;
using Oms.Application.Dtos.Orders;
using Oms.Application.Storage;
@ -14,7 +15,7 @@ namespace Oms.Application.Services.Orders;
/// </summary>
public sealed class OrderFileManager(
IOrderFileRepository orderFileRepository,
IFileStorage fileStorage) : IScopedDependency
IObjectStorage fileStorage) : IScopedDependency
{
private static readonly HashSet<string> ImageExt =
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
@ -94,7 +95,7 @@ public sealed class OrderFileManager(
OrderId = orderId,
FileType = OrderFileTypeCodes.SystemLabel,
FileName = fileName,
FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
ContentType = contentType,
FileSize = fileSize,
Sort = 0,
@ -143,7 +144,7 @@ public sealed class OrderFileManager(
OrderId = orderId,
FileType = OrderFileTypeCodes.DownstreamLabel,
FileName = TryFileNameFromUrl(fileUrl),
FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
ContentType = null,
Sort = existing.Count,
Source = OrderFileSourceCodes.Downstream,
@ -305,7 +306,7 @@ public sealed class OrderFileManager(
var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
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);
}

View File

@ -488,12 +488,12 @@ public partial class OrderService
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
}
if (!await FileStorage.ExistsAsync(template.SourceFilePath))
if (!await FileStorage.ExistsAsync(FileStoragePath.NormalizeKey(template.SourceFilePath)))
{
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();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
@ -628,7 +628,7 @@ public partial class OrderService
""";
var relativeKey = $"uploads/prints/orders/{fileName}";
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);
}
@ -647,7 +647,7 @@ public partial class OrderService
private async Task CleanupExpiredLocalExportFilesAsync()
{
// COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。
if (FileStorage is not LocalFileStorage)
if (!FileStorage.IsLocalProvider())
{
return;
}

View File

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;

View File

@ -1,5 +1,6 @@
using Fake.Application;
using Fake.Domain.Exceptions;
using Fake.ObjectStorage;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using MiniExcelLibs;
@ -35,7 +36,7 @@ public partial class OrderService : ApplicationService
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService<IPickupOrderRepository>();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService<IObjectStorage>();
private LabelPdfGenerationService LabelPdfGenerationService =>
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
@ -345,7 +346,7 @@ public partial class OrderService : ApplicationService
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders));
stream.Position = 0;
var storageKey = await FileStorage.SaveAsync(
var storageKey = await FileStorage.SaveObjectAsync(
stream,
relativeKey,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");

View File

@ -6,6 +6,7 @@ using Fake.Rbac.Application.Dtos.User;
using Fake.Rbac.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Fake.ObjectStorage;
using Oms.Application.Dtos.Rbac;
using Oms.Application.Rbac;
using Oms.Application.Storage;
@ -24,7 +25,7 @@ public class OmsUserService(
UserService userService,
UserProfileRepository profileRepository,
IEmployeeRepository employeeRepository,
IFileStorage fileStorage) : ApplicationService
IObjectStorage fileStorage) : ApplicationService
{
/// <summary>
/// 获取用户详情(含扩展资料)。
@ -98,7 +99,7 @@ public class OmsUserService(
Account = input.Account,
Password = input.Password,
Email = input.Email,
Avatar = fileStorage.ToStorageKey(input.Avatar),
Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
OrganizationId = input.OrganizationId,
RoleIds = input.RoleIds,
}, cancellationToken);
@ -136,7 +137,7 @@ public class OmsUserService(
{
Name = input.Name,
Email = input.Email,
Avatar = fileStorage.ToStorageKey(input.Avatar),
Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
OrganizationId = input.OrganizationId,
},
cancellationToken);
@ -174,7 +175,7 @@ public class OmsUserService(
[FromQuery] Guid id,
[FromQuery] string avatarUrl,
CancellationToken cancellationToken = default) =>
userService.UpdateAvatarAsync(id, fileStorage.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken);
userService.UpdateAvatarAsync(id, OmsObjectStorageExtensions.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken);
/// <summary>
/// 删除用户。

View File

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using MiniExcelLibs;
using Oms.Application.Dtos.Recharge;
using Oms.Application.Dtos.Shared;
using Fake.ObjectStorage;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.CustomersAggregate.Repositories;
@ -22,7 +23,7 @@ public class RechargeService : ApplicationService
{
private IRechargeRecordRepository RechargeRecordRepository => LazyServiceProvider.GetRequiredService<IRechargeRecordRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService<IObjectStorage>();
/// <summary>
/// 提交充值申请。

View File

@ -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; }
}

View File

@ -178,7 +178,7 @@ public partial class WarehouseService
throw new BusinessException("商品数量必须大于 0");
}
var imageUrl = FileStorage.ToStorageKey(itemInput.ImageUrl);
var imageUrl = OmsObjectStorageExtensions.ToStorageKey(itemInput.ImageUrl);
if (imageUrl != null && imageUrl.Length > 1024)
{
imageUrl = imageUrl[..1024];

View File

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
using Fake.ObjectStorage;
using Oms.Application.Shared;
using Oms.Application.Storage;
@ -11,8 +12,8 @@ namespace Oms.Application.Services.Warehouse;
public partial class WarehouseService
{
private IFileStorage FileStorage =>
LazyServiceProvider.GetRequiredService<IFileStorage>();
private IObjectStorage FileStorage =>
LazyServiceProvider.GetRequiredService<IObjectStorage>();
/// <summary>
/// 查询 SKU 列表。
@ -117,7 +118,7 @@ public partial class WarehouseService
entity.SkuCode = skuCode;
entity.SkuName = skuName;
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.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim();
entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim();

View File

@ -1,4 +1,5 @@
using Fake.Application;
using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Xhs;
@ -15,7 +16,7 @@ public class XhsMediaService : ApplicationService
{
private IXhsMediaRepository MediaRepository => LazyServiceProvider.GetRequiredService<IXhsMediaRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService<IObjectStorage>();
/// <summary>
/// 上传媒体文件
@ -34,7 +35,7 @@ public class XhsMediaService : ApplicationService
var relativeKey = $"uploads/xhs/{customerId}/{storedName}";
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 media = new XhsMedia

View File

@ -1,12 +1,14 @@
using Fake.Application;
using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http;
using Oms.Application.Storage;
namespace Oms.Application.Shared;
/// <summary>
/// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。
/// 返回值为 object key,对外展示请再调用 <see cref="IFileStorage.GetPublicUrl"/>。
/// OMS 上传文件辅助(经 <see cref="IObjectStorage"/>)。
/// 返回值为 object key,对外展示请再调用 <see cref="OmsObjectStorageExtensions.GetPublicUrl"/>。
/// 用户直传请走 Fake.FileManagement(Presign/Sts + Confirm → FileId)。
/// </summary>
public static class OmsFileUploadHelper
{
@ -15,27 +17,18 @@ public static class OmsFileUploadHelper
private const long MaxImageBytes = 5 * 1024 * 1024;
/// <summary>
/// 保存充值截图,返回可访问 URL。
/// </summary>
public static Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IFileStorage storage) =>
public static Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IObjectStorage storage) =>
SaveImageAsync(file, storage, "recharge", "充值截图");
/// <summary>
/// 保存仓储 SKU 图片。
/// </summary>
public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IFileStorage storage) =>
public static Task<string> SaveWarehouseSkuImageAsync(IFormFile file, IObjectStorage storage) =>
SaveImageAsync(file, storage, "sku", "SKU 图片");
/// <summary>
/// 保存包裹商品图片。
/// </summary>
public static Task<string> SaveWarehouseParcelImageAsync(IFormFile file, IFileStorage storage) =>
public static Task<string> SaveWarehouseParcelImageAsync(IFormFile file, IObjectStorage storage) =>
SaveImageAsync(file, storage, "parcel", "包裹商品图片");
private static async Task<string> SaveImageAsync(
IFormFile? file,
IFileStorage storage,
IObjectStorage storage,
string folder,
string label)
{
@ -70,7 +63,7 @@ public static class OmsFileUploadHelper
var relativeKey = $"uploads/{folder}/{monthFolder}/{fileName}";
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 =
@ -84,7 +77,7 @@ public static class OmsFileUploadHelper
public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync(
IFormFile file,
int customerId,
IFileStorage storage)
IObjectStorage storage)
{
if (file == null || file.Length <= 0)
{
@ -106,7 +99,7 @@ public static class OmsFileUploadHelper
var relativeKey = $"uploads/order-templates/{customerId}/{fileName}";
await using var stream = file.OpenReadStream();
var key = await storage.SaveAsync(
var key = await storage.SaveObjectAsync(
stream,
relativeKey,
file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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()}";
}
}

View File

@ -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);
}

View File

@ -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;
}

View 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);
}

View File

@ -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");
}
}
}

View File

@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Fake.ObjectStorage;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.CustomersAggregate.Entities;
@ -43,7 +44,7 @@ public partial class AuthService(
IWeChatLoginSessionStore weChatLoginSessionStore,
IMemoryCache memoryCache,
IRbacAuthCache rbacAuthCache,
IFileStorage fileStorage)
IObjectStorage fileStorage)
: ApplicationService
{
private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3);
@ -418,7 +419,7 @@ public partial class AuthService(
}
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);
user.UpdateAvatar(avatarKey);
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);

View File

@ -15,6 +15,9 @@
<ItemGroup>
<PackageReference Include="Fake.Autofac" 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.Serilog" Version="$(FakePackageVersion)" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3"/>

View File

@ -8,15 +8,20 @@ using Fake.AspNetCore.ExceptionHandling;
using Fake.AspNetCore.Newtonsoft;
using Fake.AspNetCore.Swagger;
using Fake.FeiShu;
using Fake.FileManagement.Application;
using Fake.FileManagement.Infrastructure;
using Fake.ObjectStorage;
using Fake.Security.Claims;
using Fake.Serilog;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Oms.Application;
using Oms.SqlSugarCore;
using SimpleAdmin.Api.Auth;
using SimpleAdmin.Api.Storage;
using SimpleAdmin.Api.Swagger;
using SimpleAdmin.Api.WeChat;
using Swashbuckle.AspNetCore.SwaggerGen;
@ -29,6 +34,9 @@ namespace SimpleAdmin.Api;
typeof(OmsSqlSugarCoreModule),
typeof(FakeRbacApplicationModule),
typeof(FakeRbacInfrastructureModule),
typeof(FakeFileManagementApplicationModule),
typeof(FakeFileManagementInfrastructureModule),
typeof(FakeObjectStorageTencentCosModule),
typeof(FakeAspNetCoreModule),
typeof(FakeSerilogModule)
)]
@ -46,7 +54,7 @@ public class SimpleAdminApiModule : FakeModule
var services = context.Services;
var configuration = context.Services.GetConfiguration();
// 配置动态 API - 扫描 OMS 与 RBAC 应用服务
// 配置动态 API - 扫描 OMS / RBAC / FileManagement 应用服务
services.Configure<FakeAspNetCoreMvcOptions>(options =>
{
options.ApplicationServices2Controller<OmsApplicationModule>();
@ -63,6 +71,10 @@ public class SimpleAdminApiModule : FakeModule
type != typeof(Fake.Rbac.Application.Services.AuthService) &&
type != typeof(Fake.Rbac.Application.Services.UserService);
});
options.ApplicationServices2Controller<FakeFileManagementApplicationModule>(settings =>
{
settings.RootPath = "files";
});
});
services.AddOmsWeChat(configuration);
@ -128,6 +140,8 @@ public class SimpleAdminApiModule : FakeModule
});
// 添加授权服务
services.AddAuthorization();
services.AddHostedService<ObjectStorageStartupLogger>();
}
public override void ConfigureApplication(ApplicationConfigureContext context)
@ -162,6 +176,13 @@ public class SimpleAdminApiModule : FakeModule
// 端点映射
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");
}

View 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;
}

View File

@ -59,22 +59,34 @@
"FollowUrl": ""
}
},
"Oms": {
"FileStorage": {
"Provider": "Local",
"Cos": {
"SecretId": "",
"SecretKey": "",
"Region": "ap-guangzhou",
"Bucket": "your-bucket-1250000000",
"BaseUrl": "",
"KeyPrefix": "",
"DurationSecond": 3600,
"UploadDurationSecond": 600,
"SignUrls": true,
"IsHttps": true
}
"ObjectStorage": {
"Provider": "Local",
"KeyPrefix": "",
"SignUrlsByDefault": true,
"DefaultSignedUrlExpiresSeconds": 3600,
"DefaultUploadExpiresSeconds": 600,
"Local": {
"BasePath": "wwwroot",
"BaseUrl": ""
},
"TencentCos": {
"SecretId": "",
"SecretKey": "",
"Region": "ap-guangzhou",
"Bucket": "your-bucket-1250000000",
"BaseUrl": "",
"SignUrls": true,
"UseHttps": true,
"DurationSecond": 600
}
},
"FileManagement": {
"SignUrlsByDefault": true,
"DefaultSignedUrlExpiresSeconds": 3600,
"DefaultMaxSizeBytes": 20971520,
"DefaultStorageSource": "default"
},
"Oms": {
"TrackService": {
"Enabled": true,
"BaseUrl": "http://api.nanafa.com/track",