From ae273e510a210837f24d5daef89de92e8633fa38 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Sun, 23 Nov 2025 18:53:12 +0800 Subject: [PATCH] feat: finalize core modules and gateway --- Document/10_TODO.md | 30 +- Document/11_下一步TODO.md | 49 +++ .../Controllers/FilesController.cs | 52 ++++ src/Api/TakeoutSaaS.AdminApi/Program.cs | 15 + .../TakeoutSaaS.AdminApi.csproj | 4 + .../appsettings.Development.json | 142 +++++++++ .../Controllers/FilesController.cs | 52 ++++ src/Api/TakeoutSaaS.MiniApi/Program.cs | 12 + .../TakeoutSaaS.MiniApi.csproj | 3 + .../appsettings.Development.json | 133 +++++++++ .../appsettings.Development.json | 52 ++++ .../Messaging/Abstractions/IEventPublisher.cs | 15 + .../Messaging/EventRoutingKeys.cs | 17 ++ .../Messaging/Events/OrderCreatedEvent.cs | 32 ++ .../Messaging/Events/PaymentSucceededEvent.cs | 32 ++ .../MessagingServiceCollectionExtensions.cs | 20 ++ .../Messaging/Services/EventPublisher.cs | 16 + .../Abstractions/IVerificationCodeService.cs | 21 ++ .../Contracts/SendVerificationCodeRequest.cs | 34 +++ .../Contracts/SendVerificationCodeResponse.cs | 19 ++ .../VerifyVerificationCodeRequest.cs | 32 ++ .../SmsServiceCollectionExtensions.cs | 27 ++ .../Sms/Options/VerificationCodeOptions.cs | 33 +++ .../Sms/Services/VerificationCodeService.cs | 148 ++++++++++ .../Abstractions/IFileStorageService.cs | 21 ++ .../Storage/Contracts/DirectUploadRequest.cs | 46 +++ .../Storage/Contracts/DirectUploadResponse.cs | 35 +++ .../Storage/Contracts/FileUploadResponse.cs | 22 ++ .../Storage/Contracts/UploadFileRequest.cs | 59 ++++ .../Storage/Enums/UploadFileType.cs | 32 ++ .../StorageServiceCollectionExtensions.cs | 20 ++ .../Extensions/UploadFileTypeParser.cs | 46 +++ .../Storage/Services/FileStorageService.cs | 278 ++++++++++++++++++ .../TakeoutSaaS.Application.csproj | 4 + .../Constants/DatabaseConstants.cs | 17 ++ .../Data/DatabaseConnectionRole.cs | 17 ++ .../Data/IDapperExecutor.cs | 47 +++ .../Entities/AuditableEntityBase.cs | 37 +++ .../Entities/EntityBase.cs | 12 + .../Entities/IAuditableEntity.cs | 25 +- .../Entities/ISoftDeleteEntity.cs | 12 + .../Entities/MultiTenantEntityBase.cs | 12 + .../Security/ICurrentUserAccessor.cs | 17 ++ .../Extensions/ServiceCollectionExtensions.cs | 3 + .../HttpContextCurrentUserAccessor.cs | 42 +++ .../Dictionary/Entities/DictionaryGroup.cs | 24 +- .../Dictionary/Entities/DictionaryItem.cs | 22 +- .../Identity/Entities/IdentityUser.cs | 14 +- .../Identity/Entities/MiniUser.cs | 12 +- src/Gateway/TakeoutSaaS.ApiGateway/Program.cs | 153 ++++++---- .../appsettings.Development.json | 38 +++ .../DatabaseServiceCollectionExtensions.cs | 86 ++++++ .../Options/DatabaseDataSourceOptions.cs | 38 +++ .../Common/Options/DatabaseOptions.cs | 33 +++ .../Common/Persistence/AppDbContext.cs | 180 ++++++++++++ .../Common/Persistence/DapperExecutor.cs | 80 +++++ .../Persistence/DatabaseConnectionDetails.cs | 10 + .../Persistence/DatabaseConnectionFactory.cs | 121 ++++++++ .../Persistence/IDatabaseConnectionFactory.cs | 17 ++ .../Persistence/TenantAwareDbContext.cs | 59 ++-- .../DictionaryServiceCollectionExtensions.cs | 31 +- .../Persistence/DictionaryDbContext.cs | 45 ++- .../Extensions/ServiceCollectionExtensions.cs | 40 ++- .../Identity/Persistence/IdentityDbContext.cs | 46 ++- .../TakeoutSaaS.Infrastructure.csproj | 2 +- .../Abstractions/IMessagePublisher.cs | 15 + .../Abstractions/IMessageSubscriber.cs | 16 + .../MessagingServiceCollectionExtensions.cs | 32 ++ .../Options/RabbitMqOptions.cs | 55 ++++ .../Serialization/JsonMessageSerializer.cs | 22 ++ .../Services/RabbitMqConnectionFactory.cs | 30 ++ .../Services/RabbitMqMessagePublisher.cs | 66 +++++ .../Services/RabbitMqMessageSubscriber.cs | 92 ++++++ .../TakeoutSaaS.Module.Messaging.csproj | 7 +- .../Abstractions/IRecurringJobRegistrar.cs | 15 + .../SchedulerServiceCollectionExtensions.cs | 68 +++++ .../RecurringJobHostedService.cs | 21 ++ .../Jobs/CouponExpireJob.cs | 18 ++ .../Jobs/LogCleanupJob.cs | 18 ++ .../Jobs/OrderTimeoutJob.cs | 18 ++ .../Options/SchedulerOptions.cs | 31 ++ .../Services/RecurringJobRegistrar.cs | 37 +++ .../TakeoutSaaS.Module.Scheduler.csproj | 10 +- .../Abstractions/ISmsSender.cs | 21 ++ .../Abstractions/ISmsSenderResolver.cs | 12 + .../SmsServiceCollectionExtensions.cs | 33 +++ .../Models/SmsSendRequest.cs | 44 +++ .../Models/SmsSendResult.cs | 22 ++ .../Options/AliyunSmsOptions.cs | 36 +++ .../Options/SmsOptions.cs | 43 +++ .../Options/TencentSmsOptions.cs | 42 +++ .../Services/AliyunSmsSender.cs | 35 +++ .../Services/SmsSenderResolver.cs | 28 ++ .../Services/TencentSmsSender.cs | 136 +++++++++ .../TakeoutSaaS.Module.Sms/SmsProviderKind.cs | 17 ++ .../TakeoutSaaS.Module.Sms.csproj | 10 +- .../Abstractions/IObjectStorageProvider.cs | 36 +++ .../Abstractions/IStorageProviderResolver.cs | 14 + .../StorageServiceCollectionExtensions.cs | 34 +++ .../Models/StorageDirectUploadRequest.cs | 42 +++ .../Models/StorageDirectUploadResult.cs | 35 +++ .../Models/StorageUploadRequest.cs | 75 +++++ .../Models/StorageUploadResult.cs | 32 ++ .../Options/AliyunOssOptions.cs | 45 +++ .../Options/QiniuKodoOptions.cs | 50 ++++ .../Options/StorageOptions.cs | 44 +++ .../Options/StorageSecurityOptions.cs | 48 +++ .../Options/TencentCosOptions.cs | 54 ++++ .../Providers/AliyunOssStorageProvider.cs | 160 ++++++++++ .../Providers/QiniuKodoStorageProvider.cs | 53 ++++ .../Providers/S3StorageProviderBase.cs | 193 ++++++++++++ .../Providers/TencentCosStorageProvider.cs | 46 +++ .../Services/StorageProviderResolver.cs | 30 ++ .../StorageProviderKind.cs | 22 ++ .../TakeoutSaaS.Module.Storage.csproj | 10 +- 115 files changed, 4695 insertions(+), 223 deletions(-) create mode 100644 Document/11_下一步TODO.md create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs create mode 100644 src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json create mode 100644 src/Api/TakeoutSaaS.UserApi/appsettings.Development.json create mode 100644 src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs create mode 100644 src/Application/TakeoutSaaS.Application/Messaging/EventRoutingKeys.cs create mode 100644 src/Application/TakeoutSaaS.Application/Messaging/Events/OrderCreatedEvent.cs create mode 100644 src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs create mode 100644 src/Application/TakeoutSaaS.Application/Messaging/Extensions/MessagingServiceCollectionExtensions.cs create mode 100644 src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs create mode 100644 src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs create mode 100644 src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs create mode 100644 src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs create mode 100644 src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs create mode 100644 src/Application/TakeoutSaaS.Application/Sms/Extensions/SmsServiceCollectionExtensions.cs create mode 100644 src/Application/TakeoutSaaS.Application/Sms/Options/VerificationCodeOptions.cs create mode 100644 src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs create mode 100644 src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs create mode 100644 src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs create mode 100644 src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs create mode 100644 src/Application/TakeoutSaaS.Application/Storage/Contracts/FileUploadResponse.cs create mode 100644 src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs create mode 100644 src/Application/TakeoutSaaS.Application/Storage/Enums/UploadFileType.cs create mode 100644 src/Application/TakeoutSaaS.Application/Storage/Extensions/StorageServiceCollectionExtensions.cs create mode 100644 src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs create mode 100644 src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs create mode 100644 src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseDataSourceOptions.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseOptions.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionDetails.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/IDatabaseConnectionFactory.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Messaging/Extensions/MessagingServiceCollectionExtensions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Messaging/Options/RabbitMqOptions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Messaging/Serialization/JsonMessageSerializer.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Scheduler/HostedServices/RecurringJobHostedService.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/CouponExpireJob.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/LogCleanupJob.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Scheduler/Options/SchedulerOptions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSenderResolver.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Extensions/SmsServiceCollectionExtensions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Options/AliyunSmsOptions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Options/TencentSmsOptions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IStorageProviderResolver.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Extensions/StorageServiceCollectionExtensions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadResult.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Options/AliyunOssOptions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Options/QiniuKodoOptions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Options/StorageOptions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Options/StorageSecurityOptions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Options/TencentCosOptions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Storage/StorageProviderKind.cs diff --git a/Document/10_TODO.md b/Document/10_TODO.md index ddb4c41..31f7d26 100644 --- a/Document/10_TODO.md +++ b/Document/10_TODO.md @@ -22,24 +22,24 @@ - [x] 参数字典模块(系统参数/业务参数)CRUD 与缓存(Dictionary 模块) ## D. 数据访问与多数据源 -- [ ] EF Core 10 基础上下文、实体基类、审计字段 -- [ ] 读写分离/多数据源配置(主写、从读;或按租户切库预留) -- [ ] Dapper 基础设施封装(统计/报表类查询) +- [x] EF Core 10 基础上下文、实体基类、审计字段 +- [x] 读写分离/多数据源配置(主写、从读) +- [x] Dapper 基础设施封装(统计/报表类查询) ## E. 文件与存储 -- [ ] 存储模块抽象(本地/MinIO/云厂商适配) -- [ ] 上传接口(AdminApi、MiniApi)与签名直传预留 -- [ ] 图片/文件访问安全策略(防盗链、过期签名) +- [x] 存储模块抽象(腾讯云COS/七牛云/阿里云OSS) +- [x] 上传接口(AdminApi、MiniApi)与签名直传预留 +- [x] 图片/文件访问安全策略(防盗链、过期签名) ## F. 短信与消息队列 -- [ ] 短信模块(阿里云/腾讯云 适配占位)与验证码发送 -- [ ] MQ 模块(RabbitMQ)Publisher/Subscriber 抽象 -- [ ] 业务事件定义(订单创建/支付成功等)与事件发布入口 +- [x] 短信模块(阿里云/腾讯云 适配占位)与验证码发送 +- [x] MQ 模块(RabbitMQ)Publisher/Subscriber 抽象 +- [x] 业务事件定义(订单创建/支付成功等)与事件发布入口 ## G. 调度与定时任务 -- [ ] 调度模块(Quartz/Hangfire 二选一,默认 Hangfire) -- [ ] 基础任务:订单超时取消、优惠券过期处理、日志清理 -- [ ] 调度面板(后续 AdminUI 对接) +- [x] 调度模块(Quartz/Hangfire 二选一,默认 Hangfire) +- [x] 基础任务:订单超时取消、优惠券过期处理、日志清理 +- [x] 调度面板(后续 AdminUI 对接) ## H. 第三方配送对接(仅第三方) - [ ] 配送适配抽象(达达/闪送/顺丰同城等) @@ -47,9 +47,9 @@ - [ ] AdminApi 后台运力单查询与补单 ## I. 网关与横切能力 -- [ ] YARP 路由拆分(/api/admin、/api/mini、/api/user) -- [ ] 网关级限流与请求日志 -- [ ] 透传鉴权/租户标识与统一错误页 +- [x] YARP 路由拆分(/api/admin、/api/mini、/api/user) +- [x] 网关级限流与请求日志 +- [x] 透传鉴权/租户标识与统一错误页 ## J. 测试与质量 - [ ] 单元测试工程骨架(xUnit + FluentAssertions) diff --git a/Document/11_下一步TODO.md b/Document/11_下一步TODO.md new file mode 100644 index 0000000..d812c4d --- /dev/null +++ b/Document/11_下一步TODO.md @@ -0,0 +1,49 @@ +# 下一步 TODO(骨架完成后) + +说明:当前骨架已覆盖认证、权限、多租户、存储、短信、MQ、调度、网关等基础能力。下面的清单用于进入“可运行/可上线”的补全与质量阶段,可按优先级推进。 + +## 1. 配置与基础设施落地(高优) +- 补充真实配置:数据库/Redis/RabbitMQ/对象存储/SMS/WeChat Mini/身份密钥,并分环境管理(Development/Staging/Production)。 +- 准备基础设施:PostgreSQL 主从、Redis(哨兵/集群)、RabbitMQ、COS/OSS、Hangfire 存储库;完善 docker-compose 与部署说明。 +- 网关与服务域名规划:为 admin/mini/user/gateway 配置实际域名、TLS 证书与 CORS 列表。 +- Hangfire Dashboard 鉴权:开启并加上 Admin 角色校验或网关白名单。 + +## 2. 数据与迁移(高优) +- 建立 EF Core Migration 基线并生成数据库(App/Identity/Dictionary/Hangfire)。 +- 设计并落地核心业务表(商户/门店/商品/订单/支付/配送等),补齐 Domain 与 Infrastructure 仓储。 +- 数据初始化/种子:系统参数、默认租户、管理员、基础字典。 + +## 3. 质量与测试(高优) +- 单元测试骨架:xUnit + FluentAssertions(Dictionary、Identity、Storage、Sms、Messaging、Scheduler)。 +- 集成测试基座:WebApplicationFactory + Testcontainers(Postgres/Redis/RabbitMQ/MinIO 可选)。 +- 静态分析:添加 .editorconfig/.globalconfig,启用可空警告、风格规则,接入 Roslyn 分析器。 + +## 4. 安全与合规 +- 完善鉴权:网关透传与后端校验的租户/用户/权限;Swagger 鉴权示例。 +- 输入校验与防刷:全局限流策略(按 IP/租户),登录与验证码防刷策略参数化。 +- 日志与审计:敏感字段脱敏,登录/权限/管理操作审计日志模型与落库。 +- 配置机密:使用 Secret Store/环境变量/KMS 管理密钥,禁止明文提交。 + +## 5. 可观测性与运维 +- 日志链路:统一 TraceId 透传(网关→服务),配置 Serilog 输出(Console/File/ELK)与留存策略。 +- 指标/监控:Prometheus exporter、健康检查探针(/health)、告警规则草案。 +- 备份恢复:PostgreSQL 全量/增量备份脚本,恢复演练记录。 + +## 6. 业务功能补全 +- 订单/商品/商户等领域建模与应用服务接口实现,结合 MQ 事件发布(订单创建、支付成功等)。 +- 配送对接抽象实现(达达/闪送/顺丰同城)占位,提供下单/取消/查询接口与回调验签。 +- 小程序端接口补齐:商品浏览、下单、支付、评价、上传图片直传联调。 + +## 7. 前台/后台 UI 对接 +- Admin UI:接入 Swagger 导出的 OpenAPI,生成或手写管理端界面;接入 Hangfire Dashboard/MQ 监控只读访问。 +- MiniApp:小程序登录流程与错误码文档完善,联调上传、下单、支付链路。 + +## 8. CI/CD 与发布 +- 建立流水线:构建/测试/扫描(SAST)、镜像推送、数据库迁移步骤。 +- 多环境部署策略:Dev/Staging/Prod 配置隔离,蓝绿或滚动发布方案草拟。 +- 版本与变更管理:约定版本号/发布说明模板。 + +## 9. 文档补全 +- 更新接口文档(新增业务 API、错误码、回调规范)、模块依赖关系图。 +- 运维手册:启动参数、环境变量列表、端口/域名映射、常见故障排查。 +- 安全与合规清单:数据分类分级、审计、留存周期。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs new file mode 100644 index 0000000..f53d344 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs @@ -0,0 +1,52 @@ +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.Storage.Abstractions; +using TakeoutSaaS.Application.Storage.Contracts; +using TakeoutSaaS.Application.Storage.Extensions; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 管理后台文件上传。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/files")] +public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController +{ + private readonly IFileStorageService _fileStorageService = fileStorageService; + + /// + /// 上传图片或文件。 + /// + [HttpPost("upload")] + [RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + public async Task> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken) + { + if (file == null || file.Length == 0) + { + return ApiResponse.Error(ErrorCodes.BadRequest, "文件不能为空"); + } + + if (!UploadFileTypeParser.TryParse(type, out var uploadType)) + { + return ApiResponse.Error(ErrorCodes.BadRequest, "上传类型不合法"); + } + + var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault(); + await using var stream = file.OpenReadStream(); + + var result = await _fileStorageService.UploadAsync( + new UploadFileRequest(uploadType, stream, file.FileName, file.ContentType ?? string.Empty, file.Length, origin), + cancellationToken); + + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs index c506c0c..91a6c72 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Program.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs @@ -7,9 +7,16 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Serilog; using TakeoutSaaS.Application.Identity.Extensions; +using TakeoutSaaS.Application.Messaging.Extensions; +using TakeoutSaaS.Application.Sms.Extensions; +using TakeoutSaaS.Application.Storage.Extensions; using TakeoutSaaS.Infrastructure.Identity.Extensions; using TakeoutSaaS.Module.Authorization.Extensions; using TakeoutSaaS.Module.Dictionary.Extensions; +using TakeoutSaaS.Module.Messaging.Extensions; +using TakeoutSaaS.Module.Scheduler.Extensions; +using TakeoutSaaS.Module.Sms.Extensions; +using TakeoutSaaS.Module.Storage.Extensions; using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; @@ -38,6 +45,13 @@ builder.Services.AddAuthorization(); builder.Services.AddPermissionAuthorization(); builder.Services.AddTenantResolution(builder.Configuration); builder.Services.AddDictionaryModule(builder.Configuration); +builder.Services.AddStorageModule(builder.Configuration); +builder.Services.AddStorageApplication(); +builder.Services.AddSmsModule(builder.Configuration); +builder.Services.AddSmsApplication(builder.Configuration); +builder.Services.AddMessagingModule(builder.Configuration); +builder.Services.AddMessagingApplication(); +builder.Services.AddSchedulerModule(builder.Configuration); var adminOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Admin"); builder.Services.AddCors(options => @@ -56,6 +70,7 @@ app.UseSharedWebCore(); app.UseAuthentication(); app.UseAuthorization(); app.UseSharedSwagger(); +app.UseSchedulerDashboard(builder.Configuration); app.MapControllers(); app.Run(); diff --git a/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj b/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj index c1f701a..8a896da 100644 --- a/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj +++ b/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json new file mode 100644 index 0000000..cbdcf16 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json @@ -0,0 +1,142 @@ +{ + "Database": { + "DataSources": { + "AppDatabase": { + "Write": "Host=localhost;Port=5432;Database=takeout_saas_app;Username=app_user;Password=app_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Reads": [ + "Host=localhost;Port=5432;Database=takeout_saas_app;Username=app_user;Password=app_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + ], + "CommandTimeoutSeconds": 30, + "MaxRetryCount": 3, + "MaxRetryDelaySeconds": 5 + }, + "IdentityDatabase": { + "Write": "Host=localhost;Port=5432;Database=takeout_saas_identity;Username=identity_user;Password=identity_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Reads": [ + "Host=localhost;Port=5432;Database=takeout_saas_identity;Username=identity_user;Password=identity_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + ], + "CommandTimeoutSeconds": 30, + "MaxRetryCount": 3, + "MaxRetryDelaySeconds": 5 + } + } + }, + "Redis": "localhost:6379,abortConnect=false", + "Identity": { + "Jwt": { + "Issuer": "takeout-saas", + "Audience": "takeout-saas-clients", + "Secret": "ReplaceWithA32CharLongSecretKey_____", + "AccessTokenExpirationMinutes": 120, + "RefreshTokenExpirationMinutes": 10080 + }, + "LoginRateLimit": { + "WindowSeconds": 60, + "MaxAttempts": 5 + }, + "RefreshTokenStore": { + "Prefix": "identity:refresh:" + }, + "AdminSeed": { + "Users": [] + } + }, + "Dictionary": { + "Cache": { + "SlidingExpiration": "00:30:00" + } + }, + "Tenancy": { + "TenantIdHeaderName": "X-Tenant-Id", + "TenantCodeHeaderName": "X-Tenant-Code", + "IgnoredPaths": [ "/health" ], + "RootDomain": "" + }, + "Storage": { + "Provider": "TencentCos", + "CdnBaseUrl": "https://cdn.example.com", + "TencentCos": { + "SecretId": "COS_SECRET_ID", + "SecretKey": "COS_SECRET_KEY", + "Region": "ap-guangzhou", + "Bucket": "takeout-bucket-123456", + "Endpoint": "", + "CdnBaseUrl": "https://cdn.example.com", + "UseHttps": true, + "ForcePathStyle": false + }, + "QiniuKodo": { + "AccessKey": "QINIU_ACCESS_KEY", + "SecretKey": "QINIU_SECRET_KEY", + "Bucket": "takeout-files", + "DownloadDomain": "", + "Endpoint": "", + "UseHttps": true, + "SignedUrlExpirationMinutes": 30 + }, + "AliyunOss": { + "AccessKeyId": "OSS_ACCESS_KEY_ID", + "AccessKeySecret": "OSS_ACCESS_KEY_SECRET", + "Endpoint": "https://oss-cn-hangzhou.aliyuncs.com", + "Bucket": "takeout-files", + "CdnBaseUrl": "", + "UseHttps": true + }, + "Security": { + "MaxFileSizeBytes": 10485760, + "AllowedImageExtensions": [ ".jpg", ".jpeg", ".png", ".webp", ".gif" ], + "AllowedFileExtensions": [ ".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf" ], + "DefaultUrlExpirationMinutes": 30, + "EnableRefererValidation": true, + "AllowedReferers": [ "https://admin.example.com", "https://miniapp.example.com" ], + "AntiLeechTokenSecret": "ReplaceWithARandomToken" + } + }, + "Sms": { + "Provider": "Tencent", + "DefaultSignName": "外卖SaaS", + "UseMock": true, + "Tencent": { + "SecretId": "TENCENT_SMS_SECRET_ID", + "SecretKey": "TENCENT_SMS_SECRET_KEY", + "SdkAppId": "1400000000", + "SignName": "外卖SaaS", + "Region": "ap-guangzhou", + "Endpoint": "https://sms.tencentcloudapi.com" + }, + "Aliyun": { + "AccessKeyId": "ALIYUN_SMS_AK", + "AccessKeySecret": "ALIYUN_SMS_SK", + "Endpoint": "dysmsapi.aliyuncs.com", + "SignName": "外卖SaaS", + "Region": "cn-hangzhou" + }, + "SceneTemplates": { + "login": "LOGIN_TEMPLATE_ID", + "register": "REGISTER_TEMPLATE_ID", + "reset": "RESET_TEMPLATE_ID" + }, + "VerificationCode": { + "CodeLength": 6, + "ExpireMinutes": 5, + "CooldownSeconds": 60, + "CachePrefix": "sms:code" + } + }, + "RabbitMQ": { + "Host": "localhost", + "Port": 5672, + "Username": "admin", + "Password": "password", + "VirtualHost": "/", + "Exchange": "takeout.events", + "ExchangeType": "topic", + "PrefetchCount": 20 + }, + "Scheduler": { + "ConnectionString": "Host=localhost;Port=5432;Database=takeout_saas_scheduler;Username=app_user;Password=app_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "WorkerCount": 5, + "DashboardEnabled": false, + "DashboardPath": "/hangfire" + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs new file mode 100644 index 0000000..a795c9e --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs @@ -0,0 +1,52 @@ +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.Storage.Abstractions; +using TakeoutSaaS.Application.Storage.Contracts; +using TakeoutSaaS.Application.Storage.Extensions; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 小程序文件上传。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/files")] +public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController +{ + private readonly IFileStorageService _fileStorageService = fileStorageService; + + /// + /// 上传图片或文件。 + /// + [HttpPost("upload")] + [RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + public async Task> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken) + { + if (file == null || file.Length == 0) + { + return ApiResponse.Error(ErrorCodes.BadRequest, "文件不能为空"); + } + + if (!UploadFileTypeParser.TryParse(type, out var uploadType)) + { + return ApiResponse.Error(ErrorCodes.BadRequest, "上传类型不合法"); + } + + var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault(); + await using var stream = file.OpenReadStream(); + + var result = await _fileStorageService.UploadAsync( + new UploadFileRequest(uploadType, stream, file.FileName, file.ContentType ?? string.Empty, file.Length, origin), + cancellationToken); + + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs index fd0ebd4..5dd9962 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Program.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -1,5 +1,11 @@ using Microsoft.AspNetCore.Cors.Infrastructure; using Serilog; +using TakeoutSaaS.Application.Messaging.Extensions; +using TakeoutSaaS.Application.Sms.Extensions; +using TakeoutSaaS.Application.Storage.Extensions; +using TakeoutSaaS.Module.Messaging.Extensions; +using TakeoutSaaS.Module.Sms.Extensions; +using TakeoutSaaS.Module.Storage.Extensions; using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; @@ -22,6 +28,12 @@ builder.Services.AddSharedSwagger(options => options.EnableAuthorization = true; }); builder.Services.AddTenantResolution(builder.Configuration); +builder.Services.AddStorageModule(builder.Configuration); +builder.Services.AddStorageApplication(); +builder.Services.AddSmsModule(builder.Configuration); +builder.Services.AddSmsApplication(builder.Configuration); +builder.Services.AddMessagingModule(builder.Configuration); +builder.Services.AddMessagingApplication(); var miniOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Mini"); builder.Services.AddCors(options => diff --git a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj index 4a352b2..2269e2e 100644 --- a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj +++ b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj @@ -13,6 +13,9 @@ + + + diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json new file mode 100644 index 0000000..1549e0a --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json @@ -0,0 +1,133 @@ +{ + "Database": { + "DataSources": { + "AppDatabase": { + "Write": "Host=localhost;Port=5432;Database=takeout_saas_app;Username=app_user;Password=app_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Reads": [ + "Host=localhost;Port=5432;Database=takeout_saas_app;Username=app_user;Password=app_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + ], + "CommandTimeoutSeconds": 30, + "MaxRetryCount": 3, + "MaxRetryDelaySeconds": 5 + }, + "IdentityDatabase": { + "Write": "Host=localhost;Port=5432;Database=takeout_saas_identity;Username=identity_user;Password=identity_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Reads": [ + "Host=localhost;Port=5432;Database=takeout_saas_identity;Username=identity_user;Password=identity_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + ], + "CommandTimeoutSeconds": 30, + "MaxRetryCount": 3, + "MaxRetryDelaySeconds": 5 + } + } + }, + "Redis": "localhost:6379,abortConnect=false", + "Identity": { + "Jwt": { + "Issuer": "takeout-saas", + "Audience": "takeout-saas-clients", + "Secret": "ReplaceWithA32CharLongSecretKey_____", + "AccessTokenExpirationMinutes": 120, + "RefreshTokenExpirationMinutes": 10080 + }, + "LoginRateLimit": { + "WindowSeconds": 60, + "MaxAttempts": 5 + }, + "RefreshTokenStore": { + "Prefix": "identity:refresh:" + } + }, + "Dictionary": { + "Cache": { + "SlidingExpiration": "00:30:00" + } + }, + "Tenancy": { + "TenantIdHeaderName": "X-Tenant-Id", + "TenantCodeHeaderName": "X-Tenant-Code", + "IgnoredPaths": [ "/health" ], + "RootDomain": "" + }, + "Storage": { + "Provider": "TencentCos", + "CdnBaseUrl": "https://cdn.example.com", + "TencentCos": { + "SecretId": "COS_SECRET_ID", + "SecretKey": "COS_SECRET_KEY", + "Region": "ap-guangzhou", + "Bucket": "takeout-bucket-123456", + "Endpoint": "", + "CdnBaseUrl": "https://cdn.example.com", + "UseHttps": true, + "ForcePathStyle": false + }, + "QiniuKodo": { + "AccessKey": "QINIU_ACCESS_KEY", + "SecretKey": "QINIU_SECRET_KEY", + "Bucket": "takeout-files", + "DownloadDomain": "", + "Endpoint": "", + "UseHttps": true, + "SignedUrlExpirationMinutes": 30 + }, + "AliyunOss": { + "AccessKeyId": "OSS_ACCESS_KEY_ID", + "AccessKeySecret": "OSS_ACCESS_KEY_SECRET", + "Endpoint": "https://oss-cn-hangzhou.aliyuncs.com", + "Bucket": "takeout-files", + "CdnBaseUrl": "", + "UseHttps": true + }, + "Security": { + "MaxFileSizeBytes": 10485760, + "AllowedImageExtensions": [ ".jpg", ".jpeg", ".png", ".webp", ".gif" ], + "AllowedFileExtensions": [ ".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf" ], + "DefaultUrlExpirationMinutes": 30, + "EnableRefererValidation": true, + "AllowedReferers": [ "https://admin.example.com", "https://miniapp.example.com" ], + "AntiLeechTokenSecret": "ReplaceWithARandomToken" + } + }, + "Sms": { + "Provider": "Tencent", + "DefaultSignName": "外卖SaaS", + "UseMock": true, + "Tencent": { + "SecretId": "TENCENT_SMS_SECRET_ID", + "SecretKey": "TENCENT_SMS_SECRET_KEY", + "SdkAppId": "1400000000", + "SignName": "外卖SaaS", + "Region": "ap-guangzhou", + "Endpoint": "https://sms.tencentcloudapi.com" + }, + "Aliyun": { + "AccessKeyId": "ALIYUN_SMS_AK", + "AccessKeySecret": "ALIYUN_SMS_SK", + "Endpoint": "dysmsapi.aliyuncs.com", + "SignName": "外卖SaaS", + "Region": "cn-hangzhou" + }, + "SceneTemplates": { + "login": "LOGIN_TEMPLATE_ID", + "register": "REGISTER_TEMPLATE_ID", + "reset": "RESET_TEMPLATE_ID" + }, + "VerificationCode": { + "CodeLength": 6, + "ExpireMinutes": 5, + "CooldownSeconds": 60, + "CachePrefix": "sms:code" + } + }, + "RabbitMQ": { + "Host": "localhost", + "Port": 5672, + "Username": "admin", + "Password": "password", + "VirtualHost": "/", + "Exchange": "takeout.events", + "ExchangeType": "topic", + "PrefetchCount": 20 + } +} diff --git a/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json b/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json new file mode 100644 index 0000000..bf4d9de --- /dev/null +++ b/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json @@ -0,0 +1,52 @@ +{ + "Database": { + "DataSources": { + "AppDatabase": { + "Write": "Host=localhost;Port=5432;Database=takeout_saas_app;Username=app_user;Password=app_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Reads": [ + "Host=localhost;Port=5432;Database=takeout_saas_app;Username=app_user;Password=app_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + ], + "CommandTimeoutSeconds": 30, + "MaxRetryCount": 3, + "MaxRetryDelaySeconds": 5 + }, + "IdentityDatabase": { + "Write": "Host=localhost;Port=5432;Database=takeout_saas_identity;Username=identity_user;Password=identity_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Reads": [ + "Host=localhost;Port=5432;Database=takeout_saas_identity;Username=identity_user;Password=identity_password;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + ], + "CommandTimeoutSeconds": 30, + "MaxRetryCount": 3, + "MaxRetryDelaySeconds": 5 + } + } + }, + "Redis": "localhost:6379,abortConnect=false", + "Identity": { + "Jwt": { + "Issuer": "takeout-saas", + "Audience": "takeout-saas-clients", + "Secret": "ReplaceWithA32CharLongSecretKey_____", + "AccessTokenExpirationMinutes": 120, + "RefreshTokenExpirationMinutes": 10080 + }, + "LoginRateLimit": { + "WindowSeconds": 60, + "MaxAttempts": 5 + }, + "RefreshTokenStore": { + "Prefix": "identity:refresh:" + } + }, + "Dictionary": { + "Cache": { + "SlidingExpiration": "00:30:00" + } + }, + "Tenancy": { + "TenantIdHeaderName": "X-Tenant-Id", + "TenantCodeHeaderName": "X-Tenant-Code", + "IgnoredPaths": [ "/health" ], + "RootDomain": "" + } +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs b/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs new file mode 100644 index 0000000..b471f0a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Application.Messaging.Abstractions; + +/// +/// 领域事件发布抽象。 +/// +public interface IEventPublisher +{ + /// + /// 发布领域事件。 + /// + Task PublishAsync(string routingKey, TEvent @event, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/EventRoutingKeys.cs b/src/Application/TakeoutSaaS.Application/Messaging/EventRoutingKeys.cs new file mode 100644 index 0000000..c161ef3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/EventRoutingKeys.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.Messaging; + +/// +/// 事件路由键常量。 +/// +public static class EventRoutingKeys +{ + /// + /// 订单创建事件路由键。 + /// + public const string OrderCreated = "orders.created"; + + /// + /// 支付成功事件路由键。 + /// + public const string PaymentSucceeded = "payments.succeeded"; +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Events/OrderCreatedEvent.cs b/src/Application/TakeoutSaaS.Application/Messaging/Events/OrderCreatedEvent.cs new file mode 100644 index 0000000..be61584 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/Events/OrderCreatedEvent.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.Messaging.Events; + +/// +/// 订单创建事件。 +/// +public sealed class OrderCreatedEvent +{ + /// + /// 订单标识。 + /// + public Guid OrderId { get; init; } + + /// + /// 订单编号。 + /// + public string OrderNo { get; init; } = string.Empty; + + /// + /// 实付金额。 + /// + public decimal Amount { get; init; } + + /// + /// 所属租户。 + /// + public Guid TenantId { get; init; } + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs b/src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs new file mode 100644 index 0000000..f62a88e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.Messaging.Events; + +/// +/// 支付成功事件。 +/// +public sealed class PaymentSucceededEvent +{ + /// + /// 订单标识。 + /// + public Guid OrderId { get; init; } + + /// + /// 支付流水号。 + /// + public string PaymentNo { get; init; } = string.Empty; + + /// + /// 支付金额。 + /// + public decimal Amount { get; init; } + + /// + /// 所属租户。 + /// + public Guid TenantId { get; init; } + + /// + /// 支付时间(UTC)。 + /// + public DateTime PaidAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Extensions/MessagingServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Messaging/Extensions/MessagingServiceCollectionExtensions.cs new file mode 100644 index 0000000..82e9614 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/Extensions/MessagingServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Messaging.Abstractions; +using TakeoutSaaS.Application.Messaging.Services; + +namespace TakeoutSaaS.Application.Messaging.Extensions; + +/// +/// 消息模块应用层注册。 +/// +public static class MessagingServiceCollectionExtensions +{ + /// + /// 注册事件发布器。 + /// + public static IServiceCollection AddMessagingApplication(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs b/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs new file mode 100644 index 0000000..60b04d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Messaging.Abstractions; +using TakeoutSaaS.Module.Messaging.Abstractions; + +namespace TakeoutSaaS.Application.Messaging.Services; + +/// +/// 事件发布适配器,封装应用层到 MQ 的发布。 +/// +public sealed class EventPublisher(IMessagePublisher messagePublisher) : IEventPublisher +{ + /// + public Task PublishAsync(string routingKey, TEvent @event, CancellationToken cancellationToken = default) + => messagePublisher.PublishAsync(routingKey, @event, cancellationToken); +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs b/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs new file mode 100644 index 0000000..514b843 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Sms.Contracts; + +namespace TakeoutSaaS.Application.Sms.Abstractions; + +/// +/// 短信验证码服务抽象。 +/// +public interface IVerificationCodeService +{ + /// + /// 发送验证码。 + /// + Task SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default); + + /// + /// 校验验证码。 + /// + Task VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs new file mode 100644 index 0000000..16e659d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Module.Sms; + +namespace TakeoutSaaS.Application.Sms.Contracts; + +/// +/// 发送验证码请求。 +/// +public sealed class SendVerificationCodeRequest +{ + /// + /// 创建发送请求。 + /// + public SendVerificationCodeRequest(string phoneNumber, string scene, SmsProviderKind? provider = null) + { + PhoneNumber = phoneNumber; + Scene = scene; + Provider = provider; + } + + /// + /// 手机号(支持 +86 前缀或纯 11 位)。 + /// + public string PhoneNumber { get; } + + /// + /// 业务场景(如 login/register/reset)。 + /// + public string Scene { get; } + + /// + /// 指定服务商,未指定则使用默认配置。 + /// + public SmsProviderKind? Provider { get; } +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs new file mode 100644 index 0000000..5b6cf77 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs @@ -0,0 +1,19 @@ +using System; + +namespace TakeoutSaaS.Application.Sms.Contracts; + +/// +/// 发送验证码响应。 +/// +public sealed class SendVerificationCodeResponse +{ + /// + /// 过期时间。 + /// + public DateTimeOffset ExpiresAt { get; set; } + + /// + /// 请求标识。 + /// + public string? RequestId { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs new file mode 100644 index 0000000..57df45e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.Sms.Contracts; + +/// +/// 校验验证码请求。 +/// +public sealed class VerifyVerificationCodeRequest +{ + /// + /// 创建校验请求。 + /// + public VerifyVerificationCodeRequest(string phoneNumber, string scene, string code) + { + PhoneNumber = phoneNumber; + Scene = scene; + Code = code; + } + + /// + /// 手机号。 + /// + public string PhoneNumber { get; } + + /// + /// 业务场景。 + /// + public string Scene { get; } + + /// + /// 填写的验证码。 + /// + public string Code { get; } +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Extensions/SmsServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Sms/Extensions/SmsServiceCollectionExtensions.cs new file mode 100644 index 0000000..5a4d7c7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Extensions/SmsServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Sms.Abstractions; +using TakeoutSaaS.Application.Sms.Options; +using TakeoutSaaS.Application.Sms.Services; + +namespace TakeoutSaaS.Application.Sms.Extensions; + +/// +/// 短信应用服务注册扩展。 +/// +public static class SmsServiceCollectionExtensions +{ + /// + /// 注册短信验证码应用服务。 + /// + public static IServiceCollection AddSmsApplication(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("Sms:VerificationCode")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddScoped(); + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Options/VerificationCodeOptions.cs b/src/Application/TakeoutSaaS.Application/Sms/Options/VerificationCodeOptions.cs new file mode 100644 index 0000000..fd49271 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Options/VerificationCodeOptions.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Sms.Options; + +/// +/// 验证码发送配置。 +/// +public sealed class VerificationCodeOptions +{ + /// + /// 验证码位数,默认 6。 + /// + [Range(4, 10)] + public int CodeLength { get; set; } = 6; + + /// + /// 过期时间(分钟)。 + /// + [Range(1, 60)] + public int ExpireMinutes { get; set; } = 5; + + /// + /// 发送冷却时间(秒),用于防止频繁请求。 + /// + [Range(10, 300)] + public int CooldownSeconds { get; set; } = 60; + + /// + /// 缓存前缀。 + /// + [Required] + public string CachePrefix { get; set; } = "sms:code"; +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs b/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs new file mode 100644 index 0000000..042410a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs @@ -0,0 +1,148 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Application.Sms.Abstractions; +using TakeoutSaaS.Application.Sms.Contracts; +using TakeoutSaaS.Application.Sms.Options; +using TakeoutSaaS.Module.Sms.Abstractions; +using TakeoutSaaS.Module.Sms.Models; +using TakeoutSaaS.Module.Sms.Options; +using TakeoutSaaS.Module.Sms; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Sms.Services; + +/// +/// 短信验证码服务实现。 +/// +public sealed class VerificationCodeService( + ISmsSenderResolver senderResolver, + IOptionsMonitor smsOptionsMonitor, + IOptionsMonitor codeOptionsMonitor, + ITenantProvider tenantProvider, + IDistributedCache cache, + ILogger logger) : IVerificationCodeService +{ + /// + public async Task SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.PhoneNumber)) + { + throw new BusinessException(ErrorCodes.BadRequest, "手机号不能为空"); + } + + if (string.IsNullOrWhiteSpace(request.Scene)) + { + throw new BusinessException(ErrorCodes.BadRequest, "场景不能为空"); + } + + var smsOptions = smsOptionsMonitor.CurrentValue; + var codeOptions = codeOptionsMonitor.CurrentValue; + var templateCode = ResolveTemplate(request.Scene, smsOptions); + var phone = NormalizePhoneNumber(request.PhoneNumber); + var tenantKey = tenantProvider.GetCurrentTenantId() == Guid.Empty ? "platform" : tenantProvider.GetCurrentTenantId().ToString("N"); + var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}"; + var cooldownKey = $"{cacheKey}:cooldown"; + + await EnsureCooldownAsync(cooldownKey, codeOptions.CooldownSeconds, cancellationToken).ConfigureAwait(false); + + var code = GenerateCode(codeOptions.CodeLength); + var variables = new Dictionary { { "code", code } }; + var sender = senderResolver.Resolve(request.Provider); + + var smsRequest = new SmsSendRequest(phone, templateCode, variables, smsOptions.DefaultSignName); + var smsResult = await sender.SendAsync(smsRequest, cancellationToken).ConfigureAwait(false); + if (!smsResult.Success) + { + throw new BusinessException(ErrorCodes.InternalServerError, $"短信发送失败:{smsResult.Message}"); + } + + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(codeOptions.ExpireMinutes); + await cache.SetStringAsync(cacheKey, code, new DistributedCacheEntryOptions + { + AbsoluteExpiration = expiresAt + }, cancellationToken).ConfigureAwait(false); + + await cache.SetStringAsync(cooldownKey, "1", new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(codeOptions.CooldownSeconds) + }, cancellationToken).ConfigureAwait(false); + + logger.LogInformation("发送验证码成功,Phone:{Phone} Scene:{Scene} Tenant:{Tenant}", phone, request.Scene, tenantKey); + return new SendVerificationCodeResponse + { + ExpiresAt = expiresAt, + RequestId = smsResult.RequestId + }; + } + + /// + public async Task VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.Code)) + { + return false; + } + + var codeOptions = codeOptionsMonitor.CurrentValue; + var phone = NormalizePhoneNumber(request.PhoneNumber); + var tenantKey = tenantProvider.GetCurrentTenantId() == Guid.Empty ? "platform" : tenantProvider.GetCurrentTenantId().ToString("N"); + var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}"; + + var cachedCode = await cache.GetStringAsync(cacheKey, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(cachedCode)) + { + return false; + } + + var success = string.Equals(cachedCode, request.Code, StringComparison.Ordinal); + if (success) + { + await cache.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false); + } + + return success; + } + + private static string ResolveTemplate(string scene, SmsOptions options) + { + if (options.SceneTemplates.TryGetValue(scene, out var template) && !string.IsNullOrWhiteSpace(template)) + { + return template; + } + + throw new BusinessException(ErrorCodes.BadRequest, $"未配置场景 {scene} 的短信模板"); + } + + private static string NormalizePhoneNumber(string phone) + { + var trimmed = phone.Trim(); + return trimmed.StartsWith("+", StringComparison.Ordinal) ? trimmed : $"+86{trimmed}"; + } + + private static string GenerateCode(int length) + { + var buffer = new byte[length]; + RandomNumberGenerator.Fill(buffer); + var builder = new StringBuilder(length); + foreach (var b in buffer) + { + builder.Append((b % 10).ToString()); + } + + return builder.ToString()[..length]; + } + + private async Task EnsureCooldownAsync(string cooldownKey, int cooldownSeconds, CancellationToken cancellationToken) + { + var existing = await cache.GetStringAsync(cooldownKey, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(existing)) + { + throw new BusinessException(ErrorCodes.BadRequest, "请求过于频繁,请稍后再试"); + } + } +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs b/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs new file mode 100644 index 0000000..f164c5c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Storage.Contracts; + +namespace TakeoutSaaS.Application.Storage.Abstractions; + +/// +/// 文件存储应用服务抽象。 +/// +public interface IFileStorageService +{ + /// + /// 通过服务端中转上传文件。 + /// + Task UploadAsync(UploadFileRequest request, CancellationToken cancellationToken = default); + + /// + /// 生成前端直传凭证(预签名上传)。 + /// + Task CreateDirectUploadAsync(DirectUploadRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs new file mode 100644 index 0000000..de9a69f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs @@ -0,0 +1,46 @@ +using TakeoutSaaS.Application.Storage.Enums; + +namespace TakeoutSaaS.Application.Storage.Contracts; + +/// +/// 直传凭证请求模型。 +/// +public sealed class DirectUploadRequest +{ + /// + /// 创建直传请求。 + /// + public DirectUploadRequest(UploadFileType fileType, string fileName, string contentType, long contentLength, string? requestOrigin) + { + FileType = fileType; + FileName = fileName; + ContentType = contentType; + ContentLength = contentLength; + RequestOrigin = requestOrigin; + } + + /// + /// 文件类型。 + /// + public UploadFileType FileType { get; } + + /// + /// 文件名。 + /// + public string FileName { get; } + + /// + /// 内容类型。 + /// + public string ContentType { get; } + + /// + /// 文件长度。 + /// + public long ContentLength { get; } + + /// + /// 请求来源(Origin/Referer)。 + /// + public string? RequestOrigin { get; } +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs new file mode 100644 index 0000000..4989657 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace TakeoutSaaS.Application.Storage.Contracts; + +/// +/// 直传凭证响应模型。 +/// +public sealed class DirectUploadResponse +{ + /// + /// 预签名上传地址。 + /// + public string UploadUrl { get; set; } = string.Empty; + + /// + /// 表单直传所需字段(PUT 直传为空)。 + /// + public IReadOnlyDictionary FormFields { get; set; } = new Dictionary(); + + /// + /// 预签名过期时间。 + /// + public DateTimeOffset ExpiresAt { get; set; } + + /// + /// 对象键。 + /// + public string ObjectKey { get; set; } = string.Empty; + + /// + /// 直传完成后的访问链接(包含签名)。 + /// + public string? DownloadUrl { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/FileUploadResponse.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/FileUploadResponse.cs new file mode 100644 index 0000000..3f12168 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/FileUploadResponse.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.Storage.Contracts; + +/// +/// 上传完成后的返回模型。 +/// +public sealed class FileUploadResponse +{ + /// + /// 访问 URL(已包含签名)。 + /// + public string Url { get; set; } = string.Empty; + + /// + /// 文件名。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// 文件大小。 + /// + public long FileSize { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs new file mode 100644 index 0000000..d60bb7b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs @@ -0,0 +1,59 @@ +using System.IO; +using TakeoutSaaS.Application.Storage.Enums; + +namespace TakeoutSaaS.Application.Storage.Contracts; + +/// +/// 上传文件请求模型。 +/// +public sealed class UploadFileRequest +{ + /// + /// 创建上传文件请求。 + /// + public UploadFileRequest( + UploadFileType fileType, + Stream content, + string fileName, + string contentType, + long contentLength, + string? requestOrigin) + { + FileType = fileType; + Content = content; + FileName = fileName; + ContentType = contentType; + ContentLength = contentLength; + RequestOrigin = requestOrigin; + } + + /// + /// 文件分类。 + /// + public UploadFileType FileType { get; } + + /// + /// 文件流。 + /// + public Stream Content { get; } + + /// + /// 原始文件名。 + /// + public string FileName { get; } + + /// + /// 内容类型。 + /// + public string ContentType { get; } + + /// + /// 文件大小。 + /// + public long ContentLength { get; } + + /// + /// 请求来源(Origin/Referer)。 + /// + public string? RequestOrigin { get; } +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Enums/UploadFileType.cs b/src/Application/TakeoutSaaS.Application/Storage/Enums/UploadFileType.cs new file mode 100644 index 0000000..f6c5228 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Enums/UploadFileType.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.Storage.Enums; + +/// +/// 上传文件类型,映射业务场景。 +/// +public enum UploadFileType +{ + /// + /// 菜品图片。 + /// + DishImage = 1, + + /// + /// 商户 Logo。 + /// + MerchantLogo = 2, + + /// + /// 用户头像。 + /// + UserAvatar = 3, + + /// + /// 评价图片。 + /// + ReviewImage = 4, + + /// + /// 其他通用文件。 + /// + Other = 9 +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Extensions/StorageServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Storage/Extensions/StorageServiceCollectionExtensions.cs new file mode 100644 index 0000000..1301804 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Extensions/StorageServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Storage.Abstractions; +using TakeoutSaaS.Application.Storage.Services; + +namespace TakeoutSaaS.Application.Storage.Extensions; + +/// +/// 存储应用服务注册扩展。 +/// +public static class StorageServiceCollectionExtensions +{ + /// + /// 注册文件存储应用服务。 + /// + public static IServiceCollection AddStorageApplication(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs b/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs new file mode 100644 index 0000000..dd712ba --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs @@ -0,0 +1,46 @@ +using System; +using TakeoutSaaS.Application.Storage.Enums; + +namespace TakeoutSaaS.Application.Storage.Extensions; + +/// +/// 上传类型解析与辅助方法。 +/// +public static class UploadFileTypeParser +{ + /// + /// 将字符串解析为上传类型。 + /// + public static bool TryParse(string? value, out UploadFileType type) + { + type = UploadFileType.Other; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var normalized = value.Trim().ToLowerInvariant(); + type = normalized switch + { + "dish_image" => UploadFileType.DishImage, + "merchant_logo" => UploadFileType.MerchantLogo, + "user_avatar" => UploadFileType.UserAvatar, + "review_image" => UploadFileType.ReviewImage, + _ => UploadFileType.Other + }; + + return type != UploadFileType.Other || normalized == "other"; + } + + /// + /// 将上传类型转换为路径片段。 + /// + public static string ToFolderName(this UploadFileType type) => type switch + { + UploadFileType.DishImage => "dishes", + UploadFileType.MerchantLogo => "merchants", + UploadFileType.UserAvatar => "users", + UploadFileType.ReviewImage => "reviews", + _ => "files" + }; +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs b/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs new file mode 100644 index 0000000..05a02f2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Application.Storage.Abstractions; +using TakeoutSaaS.Application.Storage.Contracts; +using TakeoutSaaS.Application.Storage.Enums; +using TakeoutSaaS.Application.Storage.Extensions; +using TakeoutSaaS.Module.Storage.Abstractions; +using TakeoutSaaS.Module.Storage.Models; +using TakeoutSaaS.Module.Storage.Options; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Storage.Services; + +/// +/// 文件存储应用服务,实现上传与直传凭证生成。 +/// +public sealed class FileStorageService( + IStorageProviderResolver providerResolver, + IOptionsMonitor optionsMonitor, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) : IFileStorageService +{ + /// + public async Task UploadAsync(UploadFileRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new BusinessException(ErrorCodes.BadRequest, "上传请求不能为空"); + } + + var options = optionsMonitor.CurrentValue; + var security = options.Security; + ValidateOrigin(request.RequestOrigin, security); + ValidateFileSize(request.ContentLength, security); + + var extension = NormalizeExtension(request.FileName); + ValidateExtension(request.FileType, extension, security); + var contentType = NormalizeContentType(request.ContentType, extension); + ResetStream(request.Content); + + var objectKey = BuildObjectKey(request.FileType, extension); + var metadata = BuildMetadata(request.FileType); + var expires = TimeSpan.FromMinutes(Math.Max(1, security.DefaultUrlExpirationMinutes)); + var provider = providerResolver.Resolve(); + + var uploadResult = await provider.UploadAsync( + new StorageUploadRequest(objectKey, request.Content, contentType, request.ContentLength, true, expires, metadata), + cancellationToken).ConfigureAwait(false); + + var finalUrl = AppendAntiLeechToken(uploadResult.SignedUrl ?? uploadResult.Url, objectKey, expires, security); + logger.LogInformation("文件上传成功:{ObjectKey} ({Size} bytes)", objectKey, request.ContentLength); + + return new FileUploadResponse + { + Url = finalUrl, + FileName = Path.GetFileName(uploadResult.ObjectKey), + FileSize = uploadResult.FileSize + }; + } + + /// + public async Task CreateDirectUploadAsync(DirectUploadRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new BusinessException(ErrorCodes.BadRequest, "直传请求不能为空"); + } + + var options = optionsMonitor.CurrentValue; + var security = options.Security; + ValidateOrigin(request.RequestOrigin, security); + ValidateFileSize(request.ContentLength, security); + + var extension = NormalizeExtension(request.FileName); + ValidateExtension(request.FileType, extension, security); + var contentType = NormalizeContentType(request.ContentType, extension); + + var objectKey = BuildObjectKey(request.FileType, extension); + var provider = providerResolver.Resolve(); + var expires = TimeSpan.FromMinutes(Math.Max(1, security.DefaultUrlExpirationMinutes)); + + var directResult = await provider.CreateDirectUploadAsync( + new StorageDirectUploadRequest(objectKey, contentType, request.ContentLength, expires), + cancellationToken).ConfigureAwait(false); + + var finalDownloadUrl = directResult.SignedDownloadUrl != null + ? AppendAntiLeechToken(directResult.SignedDownloadUrl, objectKey, expires, security) + : null; + + return new DirectUploadResponse + { + UploadUrl = directResult.UploadUrl, + FormFields = directResult.FormFields, + ExpiresAt = directResult.ExpiresAt, + ObjectKey = directResult.ObjectKey, + DownloadUrl = finalDownloadUrl + }; + } + + /// + /// 校验文件大小。 + /// + private static void ValidateFileSize(long size, StorageSecurityOptions security) + { + if (size <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "文件内容为空"); + } + + if (size > security.MaxFileSizeBytes) + { + throw new BusinessException(ErrorCodes.BadRequest, $"文件过大,最大允许 {security.MaxFileSizeBytes / 1024 / 1024}MB"); + } + } + + /// + /// 校验文件后缀是否符合配置。 + /// + private static void ValidateExtension(UploadFileType type, string extension, StorageSecurityOptions security) + { + var allowedImages = security.AllowedImageExtensions ?? Array.Empty(); + var allowedFiles = security.AllowedFileExtensions ?? Array.Empty(); + + if (type is UploadFileType.DishImage or UploadFileType.MerchantLogo or UploadFileType.UserAvatar or UploadFileType.ReviewImage) + { + if (!allowedImages.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new BusinessException(ErrorCodes.BadRequest, $"不支持的图片格式:{extension}"); + } + } + else if (!allowedFiles.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new BusinessException(ErrorCodes.BadRequest, $"不支持的文件格式:{extension}"); + } + } + + /// + /// 统一化文件后缀(小写,默认 .bin)。 + /// + private static string NormalizeExtension(string fileName) + { + var extension = Path.GetExtension(fileName); + if (string.IsNullOrWhiteSpace(extension)) + { + return ".bin"; + } + + return extension.ToLowerInvariant(); + } + + /// + /// 根据内容类型或后缀推断 Content-Type。 + /// + private static string NormalizeContentType(string contentType, string extension) + { + if (!string.IsNullOrWhiteSpace(contentType)) + { + return contentType; + } + + return extension switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".pdf" => "application/pdf", + _ => "application/octet-stream" + }; + } + + /// + /// 校验请求来源是否在白名单内。 + /// + private void ValidateOrigin(string? origin, StorageSecurityOptions security) + { + if (!security.EnableRefererValidation || security.AllowedReferers.Length == 0) + { + return; + } + + if (string.IsNullOrWhiteSpace(origin)) + { + throw new BusinessException(ErrorCodes.Forbidden, "未授权的访问来源"); + } + + var isAllowed = security.AllowedReferers.Any(allowed => + !string.IsNullOrWhiteSpace(allowed) && + origin.StartsWith(allowed, StringComparison.OrdinalIgnoreCase)); + + if (!isAllowed) + { + throw new BusinessException(ErrorCodes.Forbidden, "访问来源未在白名单中"); + } + } + + /// + /// 生成对象存储的键路径。 + /// + private string BuildObjectKey(UploadFileType type, string extension) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var tenantSegment = tenantId == Guid.Empty ? "platform" : tenantId.ToString("N"); + var folder = type.ToFolderName(); + var now = DateTime.UtcNow; + var fileName = $"{Guid.NewGuid():N}{extension}"; + + return $"{tenantSegment}/{folder}/{now:yyyy/MM/dd}/{fileName}"; + } + + /// + /// 组装对象元数据,便于追踪租户与用户。 + /// + private IDictionary BuildMetadata(UploadFileType type) + { + var metadata = new Dictionary + { + ["x-meta-upload-type"] = type.ToString(), + ["x-meta-tenant-id"] = tenantProvider.GetCurrentTenantId().ToString() + }; + + if (currentUserAccessor.IsAuthenticated) + { + metadata["x-meta-user-id"] = currentUserAccessor.UserId.ToString(); + } + + return metadata; + } + + /// + /// 重置文件流的读取位置。 + /// + private static void ResetStream(Stream stream) + { + if (stream.CanSeek) + { + stream.Position = 0; + } + } + + /// + /// 为访问链接追加防盗链签名(可配合 CDN Token 验证)。 + /// + private static string AppendAntiLeechToken(string url, string objectKey, TimeSpan expires, StorageSecurityOptions security) + { + if (string.IsNullOrWhiteSpace(security.AntiLeechTokenSecret)) + { + return url; + } + + // 若链接已包含云厂商签名参数,则避免追加自定义参数导致验签失败。 + if (url.Contains("X-Amz-Signature", StringComparison.OrdinalIgnoreCase) || + url.Contains("q-sign-algorithm", StringComparison.OrdinalIgnoreCase) || + url.Contains("Signature=", StringComparison.OrdinalIgnoreCase)) + { + return url; + } + + var expireAt = DateTimeOffset.UtcNow.Add(expires).ToUnixTimeSeconds(); + var payload = $"{objectKey}:{expireAt}:{security.AntiLeechTokenSecret}"; + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); + var token = Convert.ToHexString(hashBytes).ToLowerInvariant(); + var separator = url.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + return $"{url}{separator}ts={expireAt}&token={token}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj index 233eb4d..15da3e6 100644 --- a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj +++ b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj @@ -7,8 +7,12 @@ + + + + diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs new file mode 100644 index 0000000..6fe8a35 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Shared.Abstractions.Constants; + +/// +/// 数据源名称常量,统一配置键与使用。 +/// +public static class DatabaseConstants +{ + /// + /// 默认业务库(AppDatabase)。 + /// + public const string AppDataSource = "AppDatabase"; + + /// + /// 身份认证库(IdentityDatabase)。 + /// + public const string IdentityDataSource = "IdentityDatabase"; +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs new file mode 100644 index 0000000..175f9f6 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Shared.Abstractions.Data; + +/// +/// 数据库连接角色,用于区分主写与从读连接。 +/// +public enum DatabaseConnectionRole +{ + /// + /// 主写连接,用于写入或强一致读。 + /// + Write = 1, + + /// + /// 从读连接,用于只读查询或报表。 + /// + Read = 2 +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs new file mode 100644 index 0000000..3783a93 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs @@ -0,0 +1,47 @@ +using System.Data; + +namespace TakeoutSaaS.Shared.Abstractions.Data; + +/// +/// Dapper 查询/命令执行器抽象,封装连接获取与读写路由。 +/// +public interface IDapperExecutor +{ + /// + /// 使用指定数据源与读写角色执行异步查询,并返回结果。 + /// + /// 查询结果类型。 + /// 逻辑数据源名称。 + /// 连接角色(读/写)。 + /// 查询委托,提供已打开的连接和取消标记。 + /// 取消标记。 + /// 查询结果。 + Task QueryAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func> query, + CancellationToken cancellationToken = default); + + /// + /// 使用指定数据源与读写角色执行异步命令。 + /// + /// 逻辑数据源名称。 + /// 连接角色(读/写)。 + /// 命令委托,提供已打开的连接和取消标记。 + /// 取消标记。 + Task ExecuteAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func command, + CancellationToken cancellationToken = default); + + /// + /// 获取指定数据源及角色的默认命令超时时间(秒)。 + /// + /// 逻辑数据源名称。 + /// 连接角色,默认读取从库。 + /// 命令超时时间(秒)。 + int GetDefaultCommandTimeoutSeconds( + string dataSourceName, + DatabaseConnectionRole role = DatabaseConnectionRole.Read); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs new file mode 100644 index 0000000..3aaedf9 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 审计实体基类:提供创建、更新时间以及软删除时间。 +/// +public abstract class AuditableEntityBase : EntityBase, IAuditableEntity +{ + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; set; } + + /// + /// 最近一次更新时间(UTC),从未更新时为 null。 + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// 软删除时间(UTC),未删除时为 null。 + /// + public DateTime? DeletedAt { get; set; } + + /// + /// 创建人用户标识,匿名或系统操作时为 null。 + /// + public Guid? CreatedBy { get; set; } + + /// + /// 最后更新人用户标识,匿名或系统操作时为 null。 + /// + public Guid? UpdatedBy { get; set; } + + /// + /// 删除人用户标识(软删除),未删除时为 null。 + /// + public Guid? DeletedBy { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs new file mode 100644 index 0000000..1cd539e --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 实体基类,统一提供主键标识。 +/// +public abstract class EntityBase +{ + /// + /// 实体唯一标识。 + /// + public Guid Id { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs index aa6a7cd..7168803 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs @@ -1,9 +1,9 @@ namespace TakeoutSaaS.Shared.Abstractions.Entities; /// -/// 审计字段接口:提供创建时间和更新时间字段。 +/// 审计字段接口:提供创建、更新、删除时间与操作者标识。 /// -public interface IAuditableEntity +public interface IAuditableEntity : ISoftDeleteEntity { /// /// 创建时间(UTC)。 @@ -14,5 +14,24 @@ public interface IAuditableEntity /// 更新时间(UTC),未更新时为 null。 /// DateTime? UpdatedAt { get; set; } -} + /// + /// 删除时间(UTC),未删除时为 null。 + /// + new DateTime? DeletedAt { get; set; } + + /// + /// 创建人用户标识,匿名或系统操作时为 null。 + /// + Guid? CreatedBy { get; set; } + + /// + /// 最后更新人用户标识,匿名或系统操作时为 null。 + /// + Guid? UpdatedBy { get; set; } + + /// + /// 删除人用户标识(软删除),未删除时为 null。 + /// + Guid? DeletedBy { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs new file mode 100644 index 0000000..4bc3fba --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 软删除实体约定:提供可空的删除时间戳以支持全局过滤。 +/// +public interface ISoftDeleteEntity +{ + /// + /// 删除时间(UTC),未删除时为 null。 + /// + DateTime? DeletedAt { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs new file mode 100644 index 0000000..59bf1f8 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 多租户审计实体基类:提供租户标识、审计字段与软删除标记。 +/// +public abstract class MultiTenantEntityBase : AuditableEntityBase, IMultiTenantEntity +{ + /// + /// 所属租户 ID。 + /// + public Guid TenantId { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs new file mode 100644 index 0000000..9b7e7fb --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Shared.Abstractions.Security; + +/// +/// 当前用户访问器:提供与当前请求相关的用户标识信息。 +/// +public interface ICurrentUserAccessor +{ + /// + /// 当前用户 ID,未登录时为 Guid.Empty。 + /// + Guid UserId { get; } + + /// + /// 是否已登录。 + /// + bool IsAuthenticated { get; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs index edc70d8..dd5f1e3 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Web.Filters; +using TakeoutSaaS.Shared.Web.Security; namespace TakeoutSaaS.Shared.Web.Extensions; @@ -17,6 +19,7 @@ public static class ServiceCollectionExtensions { services.AddHttpContextAccessor(); services.AddEndpointsApiExplorer(); + services.AddScoped(); services .AddControllers(options => diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs new file mode 100644 index 0000000..d73fbbd --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs @@ -0,0 +1,42 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Shared.Web.Security; + +/// +/// 基于 HttpContext 的当前用户访问器。 +/// +public sealed class HttpContextCurrentUserAccessor : ICurrentUserAccessor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + /// 初始化访问器。 + /// + public HttpContextCurrentUserAccessor(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + /// + public Guid UserId + { + get + { + var principal = _httpContextAccessor.HttpContext?.User; + if (principal == null || !principal.Identity?.IsAuthenticated == true) + { + return Guid.Empty; + } + + var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue("sub"); + + return Guid.TryParse(identifier, out var id) ? id : Guid.Empty; + } + } + + /// + public bool IsAuthenticated => UserId != Guid.Empty; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs index 4bba213..68694b0 100644 --- a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs @@ -5,20 +5,10 @@ using TakeoutSaaS.Shared.Abstractions.Entities; namespace TakeoutSaaS.Domain.Dictionary.Entities; /// -/// 参数字典分组(系统参数/业务参数)。 +/// 参数字典分组(系统参数、业务参数)。 /// -public sealed class DictionaryGroup : IMultiTenantEntity, IAuditableEntity +public sealed class DictionaryGroup : MultiTenantEntityBase { - /// - /// 分组 ID。 - /// - public Guid Id { get; set; } - - /// - /// 所属租户(系统参数为 Guid.Empty)。 - /// - public Guid TenantId { get; set; } - /// /// 分组编码(唯一)。 /// @@ -44,16 +34,6 @@ public sealed class DictionaryGroup : IMultiTenantEntity, IAuditableEntity /// public bool IsEnabled { get; set; } = true; - /// - /// 创建时间(UTC)。 - /// - public DateTime CreatedAt { get; set; } - - /// - /// 更新时间(UTC)。 - /// - public DateTime? UpdatedAt { get; set; } - /// /// 字典项集合。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs index 0058e23..4d47912 100644 --- a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs @@ -5,18 +5,8 @@ namespace TakeoutSaaS.Domain.Dictionary.Entities; /// /// 参数字典项。 /// -public sealed class DictionaryItem : IMultiTenantEntity, IAuditableEntity +public sealed class DictionaryItem : MultiTenantEntityBase { - /// - /// 字典项 ID。 - /// - public Guid Id { get; set; } - - /// - /// 所属租户。 - /// - public Guid TenantId { get; set; } - /// /// 关联分组 ID。 /// @@ -52,16 +42,6 @@ public sealed class DictionaryItem : IMultiTenantEntity, IAuditableEntity /// public string? Description { get; set; } - /// - /// 创建时间(UTC)。 - /// - public DateTime CreatedAt { get; set; } - - /// - /// 更新时间(UTC)。 - /// - public DateTime? UpdatedAt { get; set; } - /// /// 导航属性:所属分组。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs index b47df34..6ccb304 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs @@ -3,15 +3,10 @@ using TakeoutSaaS.Shared.Abstractions.Entities; namespace TakeoutSaaS.Domain.Identity.Entities; /// -/// 管理后台账户实体(平台、租户或商户员工)。 +/// 管理后台账户实体(平台管理员、租户管理员或商户员工)。 /// -public sealed class IdentityUser : IMultiTenantEntity +public sealed class IdentityUser : MultiTenantEntityBase { - /// - /// 用户 ID。 - /// - public Guid Id { get; set; } - /// /// 登录账号。 /// @@ -27,11 +22,6 @@ public sealed class IdentityUser : IMultiTenantEntity /// public string PasswordHash { get; set; } = string.Empty; - /// - /// 所属租户。 - /// - public Guid TenantId { get; set; } - /// /// 所属商户(平台管理员为空)。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs index 953e979..bc2ce45 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs @@ -5,13 +5,8 @@ namespace TakeoutSaaS.Domain.Identity.Entities; /// /// 小程序用户实体。 /// -public sealed class MiniUser : IMultiTenantEntity +public sealed class MiniUser : MultiTenantEntityBase { - /// - /// 用户 ID。 - /// - public Guid Id { get; set; } - /// /// 微信 OpenId。 /// @@ -31,9 +26,4 @@ public sealed class MiniUser : IMultiTenantEntity /// 头像地址。 /// public string? Avatar { get; set; } - - /// - /// 所属租户。 - /// - public Guid TenantId { get; set; } } diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs index 1c832fa..f7467d5 100644 --- a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs +++ b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs @@ -1,67 +1,110 @@ -using System.Collections.Generic; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Yarp.ReverseProxy.Configuration; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Threading.RateLimiting; var builder = WebApplication.CreateBuilder(args); -var routes = new[] -{ - new RouteConfig - { - RouteId = "admin-route", - ClusterId = "admin", - Match = new() { Path = "/api/admin/{**catch-all}" } - }, - new RouteConfig - { - RouteId = "mini-route", - ClusterId = "mini", - Match = new() { Path = "/api/mini/{**catch-all}" } - }, - new RouteConfig - { - RouteId = "user-route", - ClusterId = "user", - Match = new() { Path = "/api/user/{**catch-all}" } - } -}; - -var clusters = new[] -{ - new ClusterConfig - { - ClusterId = "admin", - Destinations = new Dictionary - { - ["d1"] = new() { Address = "http://localhost:5001/" } - } - }, - new ClusterConfig - { - ClusterId = "mini", - Destinations = new Dictionary - { - ["d1"] = new() { Address = "http://localhost:5002/" } - } - }, - new ClusterConfig - { - ClusterId = "user", - Destinations = new Dictionary - { - ["d1"] = new() { Address = "http://localhost:5003/" } - } - } -}; - builder.Services.AddReverseProxy() - .LoadFromMemory(routes, clusters); + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); + +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + { + const string partitionKey = "proxy-default"; + return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 100, + Window = TimeSpan.FromSeconds(1), + QueueLimit = 50, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }); + }); +}); var app = builder.Build(); -app.MapReverseProxy(); +app.UseExceptionHandler(errorApp => +{ + errorApp.Run(async context => + { + var feature = context.Features.Get(); + var traceId = Activity.Current?.Id ?? context.TraceIdentifier; + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + + var payload = new + { + success = false, + code = 500, + message = "Gateway internal error", + traceId + }; + + var logger = context.RequestServices.GetRequiredService().CreateLogger("Gateway"); + logger.LogError(feature?.Error, "网关异常 {TraceId}", traceId); + await context.Response.WriteAsJsonAsync(payload, cancellationToken: context.RequestAborted); + }); +}); + +app.Use(async (context, next) => +{ + var logger = context.RequestServices.GetRequiredService().CreateLogger("Gateway"); + var start = DateTime.UtcNow; + await next(context); + var elapsed = DateTime.UtcNow - start; + logger.LogInformation("Gateway {Method} {Path} => {Status} ({Elapsed} ms)", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + (int)elapsed.TotalMilliseconds); +}); + +app.UseRateLimiter(); + +app.Use(async (context, next) => +{ + // 确保存在请求 ID,便于上下游链路追踪。 + if (!context.Request.Headers.ContainsKey("X-Request-Id")) + { + context.Request.Headers["X-Request-Id"] = Guid.NewGuid().ToString("N"); + } + + // 透传租户与认证头。 + var tenantId = context.Request.Headers["X-Tenant-Id"]; + var tenantCode = context.Request.Headers["X-Tenant-Code"]; + if (!string.IsNullOrWhiteSpace(tenantId)) + { + context.Request.Headers["X-Tenant-Id"] = tenantId; + } + if (!string.IsNullOrWhiteSpace(tenantCode)) + { + context.Request.Headers["X-Tenant-Code"] = tenantCode; + } + + await next(context); +}); + +app.MapReverseProxy(proxyPipeline => +{ + proxyPipeline.Use(async (context, next) => + { + await next().ConfigureAwait(false); + }); +}); + +app.MapGet("/", () => Results.Json(new +{ + Service = "TakeoutSaaS.ApiGateway", + Status = "OK", + Timestamp = DateTimeOffset.UtcNow +})); app.Run(); - diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json new file mode 100644 index 0000000..9c7c501 --- /dev/null +++ b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json @@ -0,0 +1,38 @@ +{ + "ReverseProxy": { + "Routes": [ + { + "RouteId": "admin-route", + "ClusterId": "admin", + "Match": { "Path": "/api/admin/{**catch-all}" } + }, + { + "RouteId": "mini-route", + "ClusterId": "mini", + "Match": { "Path": "/api/mini/{**catch-all}" } + }, + { + "RouteId": "user-route", + "ClusterId": "user", + "Match": { "Path": "/api/user/{**catch-all}" } + } + ], + "Clusters": { + "admin": { + "Destinations": { + "d1": { "Address": "http://localhost:5001/" } + } + }, + "mini": { + "Destinations": { + "d1": { "Address": "http://localhost:5002/" } + } + }, + "user": { + "Destinations": { + "d1": { "Address": "http://localhost:5003/" } + } + } + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs new file mode 100644 index 0000000..bbc2b00 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Infrastructure.Common.Options; +using TakeoutSaaS.Infrastructure.Common.Persistence; +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Infrastructure.Common.Extensions; + +/// +/// 数据访问与多数据源相关的服务注册扩展。 +/// +public static class DatabaseServiceCollectionExtensions +{ + /// + /// 注册数据库基础设施(多数据源配置、读写分离、Dapper 执行器)。 + /// + /// 服务集合。 + /// 配置源。 + /// 服务集合。 + public static IServiceCollection AddDatabaseInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(DatabaseOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(); + services.AddScoped(); + return services; + } + + /// + /// 为指定 DbContext 注册读写分离的 PostgreSQL 配置,同时提供读上下文工厂。 + /// + /// 上下文类型。 + /// 服务集合。 + /// 逻辑数据源名称。 + /// 服务集合。 + public static IServiceCollection AddPostgresDbContext( + this IServiceCollection services, + string dataSourceName) + where TContext : DbContext + { + services.AddDbContext((sp, options) => + { + ConfigureDbContextOptions(sp, options, dataSourceName, DatabaseConnectionRole.Write); + }); + + services.AddDbContextFactory((sp, options) => + { + ConfigureDbContextOptions(sp, options, dataSourceName, DatabaseConnectionRole.Read); + }); + + return services; + } + + /// + /// 配置 DbContextOptions,应用连接串、命令超时与重试策略。 + /// + /// 服务提供程序。 + /// 上下文配置器。 + /// 数据源名称。 + /// 连接角色。 + private static void ConfigureDbContextOptions( + IServiceProvider serviceProvider, + DbContextOptionsBuilder optionsBuilder, + string dataSourceName, + DatabaseConnectionRole role) + { + var connection = serviceProvider + .GetRequiredService() + .GetConnection(dataSourceName, role); + + optionsBuilder.UseNpgsql( + connection.ConnectionString, + npgsqlOptions => + { + npgsqlOptions.CommandTimeout(connection.CommandTimeoutSeconds); + npgsqlOptions.EnableRetryOnFailure( + connection.MaxRetryCount, + TimeSpan.FromSeconds(connection.MaxRetryDelaySeconds), + null); + }); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseDataSourceOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseDataSourceOptions.cs new file mode 100644 index 0000000..694c9e3 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseDataSourceOptions.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Common.Options; + +/// +/// 单个数据源的连接配置,支持主写与多个从读。 +/// +public sealed class DatabaseDataSourceOptions +{ + /// + /// 主写连接串,读写分离缺省回退到此连接。 + /// + [Required] + public string? Write { get; set; } + + /// + /// 从读连接串集合,可为空。 + /// + public IList Reads { get; init; } = new List(); + + /// + /// 默认命令超时(秒),未设置时使用框架默认值。 + /// + [Range(1, 600)] + public int CommandTimeoutSeconds { get; set; } = 30; + + /// + /// 数据库重试次数。 + /// + [Range(0, 10)] + public int MaxRetryCount { get; set; } = 3; + + /// + /// 数据库重试最大延迟(秒)。 + /// + [Range(1, 60)] + public int MaxRetryDelaySeconds { get; set; } = 5; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseOptions.cs new file mode 100644 index 0000000..a2db8d2 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseOptions.cs @@ -0,0 +1,33 @@ +namespace TakeoutSaaS.Infrastructure.Common.Options; + +/// +/// 数据源配置集合,键为逻辑数据源名称。 +/// +public sealed class DatabaseOptions +{ + /// + /// 配置节名称。 + /// + public const string SectionName = "Database"; + + /// + /// 数据源配置字典,键为数据源名称。 + /// + public IDictionary DataSources { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// 获取指定名称的数据源配置,不存在时返回 null。 + /// + /// 逻辑数据源名称。 + /// 数据源配置或 null。 + public DatabaseDataSourceOptions? Find(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return null; + } + + return DataSources.TryGetValue(name, out var options) ? options : null; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs new file mode 100644 index 0000000..0a59c66 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs @@ -0,0 +1,180 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TakeoutSaaS.Shared.Abstractions.Entities; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 应用基础 DbContext,统一处理审计字段、软删除与全局查询过滤。 +/// +public abstract class AppDbContext(DbContextOptions options, ICurrentUserAccessor? currentUserAccessor = null) : DbContext(options) +{ + private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor; + + /// + /// 构建模型时应用软删除过滤器。 + /// + /// 模型构建器。 + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + ApplySoftDeleteQueryFilters(modelBuilder); + } + + /// + /// 保存更改前应用元数据填充。 + /// + /// 受影响行数。 + public override int SaveChanges() + { + OnBeforeSaving(); + return base.SaveChanges(); + } + + /// + /// 异步保存更改前应用元数据填充。 + /// + /// 取消标记。 + /// 受影响行数。 + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + OnBeforeSaving(); + return base.SaveChangesAsync(cancellationToken); + } + + /// + /// 保存前处理审计、软删除等元数据,可在子类中扩展。 + /// + protected virtual void OnBeforeSaving() + { + ApplySoftDeleteMetadata(); + ApplyAuditMetadata(); + } + + /// + /// 将软删除实体的删除操作转换为设置 DeletedAt。 + /// + private void ApplySoftDeleteMetadata() + { + var utcNow = DateTime.UtcNow; + var actor = GetCurrentUserIdOrNull(); + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added && entry.Entity.DeletedAt.HasValue) + { + entry.Entity.DeletedAt = null; + } + + if (entry.State != EntityState.Deleted) + { + continue; + } + + entry.State = EntityState.Modified; + entry.Entity.DeletedAt = utcNow; + if (entry.Entity is IAuditableEntity auditable) + { + auditable.DeletedBy = actor; + if (!auditable.UpdatedAt.HasValue) + { + auditable.UpdatedAt = utcNow; + auditable.UpdatedBy = actor; + } + } + } + } + + /// + /// 对审计实体填充创建与更新时间。 + /// + private void ApplyAuditMetadata() + { + var utcNow = DateTime.UtcNow; + var actor = GetCurrentUserIdOrNull(); + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedAt = utcNow; + entry.Entity.UpdatedAt = null; + entry.Entity.CreatedBy ??= actor; + entry.Entity.UpdatedBy = null; + entry.Entity.DeletedBy = null; + entry.Entity.DeletedAt = null; + } + else if (entry.State == EntityState.Modified) + { + entry.Entity.UpdatedAt = utcNow; + entry.Entity.UpdatedBy = actor; + } + } + } + + private Guid? GetCurrentUserIdOrNull() + { + var userId = _currentUserAccessor?.UserId ?? Guid.Empty; + return userId == Guid.Empty ? null : userId; + } + + /// + /// 应用软删除查询过滤器,自动排除 DeletedAt 不为 null 的记录。 + /// + /// 模型构建器。 + protected void ApplySoftDeleteQueryFilters(ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (!typeof(ISoftDeleteEntity).IsAssignableFrom(entityType.ClrType)) + { + continue; + } + + var methodInfo = typeof(AppDbContext) + .GetMethod(nameof(SetSoftDeleteFilter), BindingFlags.Instance | BindingFlags.NonPublic)! + .MakeGenericMethod(entityType.ClrType); + + methodInfo.Invoke(this, new object[] { modelBuilder }); + } + } + + /// + /// 设置软删除查询过滤器。 + /// + /// 实体类型。 + /// 模型构建器。 + private void SetSoftDeleteFilter(ModelBuilder modelBuilder) + where TEntity : class, ISoftDeleteEntity + { + modelBuilder.Entity().HasQueryFilter(entity => entity.DeletedAt == null); + } + + /// + /// 配置审计字段的通用约束。 + /// + /// 实体类型。 + /// 实体构建器。 + protected static void ConfigureAuditableEntity(EntityTypeBuilder builder) + where TEntity : class, IAuditableEntity + { + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.UpdatedAt); + builder.Property(x => x.DeletedAt); + builder.Property(x => x.CreatedBy); + builder.Property(x => x.UpdatedBy); + builder.Property(x => x.DeletedBy); + } + + /// + /// 配置软删除字段的通用约束。 + /// + /// 实体类型。 + /// 实体构建器。 + protected static void ConfigureSoftDeleteEntity(EntityTypeBuilder builder) + where TEntity : class, ISoftDeleteEntity + { + builder.Property(x => x.DeletedAt); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs new file mode 100644 index 0000000..e0761aa --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs @@ -0,0 +1,80 @@ +using System.Data; +using Microsoft.Extensions.Logging; +using Npgsql; +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 基于 Dapper 的执行器实现,封装连接创建与读写分离。 +/// +public sealed class DapperExecutor( + IDatabaseConnectionFactory connectionFactory, + ILogger logger) : IDapperExecutor +{ + /// + /// 使用指定数据源与读写角色执行异步查询。 + /// + public async Task QueryAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func> query, + CancellationToken cancellationToken = default) + { + return await ExecuteAsync( + dataSourceName, + role, + async (connection, token) => await query(connection, token), + cancellationToken); + } + + /// + /// 使用指定数据源与读写角色执行异步命令。 + /// + public async Task ExecuteAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func command, + CancellationToken cancellationToken = default) + { + await ExecuteAsync( + dataSourceName, + role, + async (connection, token) => + { + await command(connection, token); + return true; + }, + cancellationToken); + } + + /// + /// 获取默认命令超时时间(秒)。 + /// + public int GetDefaultCommandTimeoutSeconds(string dataSourceName, DatabaseConnectionRole role = DatabaseConnectionRole.Read) + { + var details = connectionFactory.GetConnection(dataSourceName, role); + return details.CommandTimeoutSeconds; + } + + /// + /// 核心执行逻辑:创建连接、打开并执行委托。 + /// + private async Task ExecuteAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func> action, + CancellationToken cancellationToken) + { + var details = connectionFactory.GetConnection(dataSourceName, role); + await using var connection = new NpgsqlConnection(details.ConnectionString); + + logger.LogDebug( + "打开数据库连接:DataSource={DataSource} Role={Role}", + dataSourceName, + role); + + await connection.OpenAsync(cancellationToken); + return await action(connection, cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionDetails.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionDetails.cs new file mode 100644 index 0000000..20e4850 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionDetails.cs @@ -0,0 +1,10 @@ +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 数据库连接信息(连接串与超时/重试设置)。 +/// +public sealed record DatabaseConnectionDetails( + string ConnectionString, + int CommandTimeoutSeconds, + int MaxRetryCount, + int MaxRetryDelaySeconds); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs new file mode 100644 index 0000000..0f47cbf --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs @@ -0,0 +1,121 @@ +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Security.Cryptography; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Infrastructure.Common.Options; +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 数据库连接工厂,支持读写分离及连接配置校验。 +/// +public sealed class DatabaseConnectionFactory( + IOptionsMonitor optionsMonitor, + IConfiguration configuration, + ILogger logger) : IDatabaseConnectionFactory +{ + private const int DefaultCommandTimeoutSeconds = 30; + private const int DefaultMaxRetryCount = 3; + private const int DefaultMaxRetryDelaySeconds = 5; + + /// + /// 获取指定数据源与读写角色的连接信息。 + /// + /// 逻辑数据源名称。 + /// 连接角色。 + /// 连接串与超时/重试配置。 + public DatabaseConnectionDetails GetConnection(string dataSourceName, DatabaseConnectionRole role) + { + if (string.IsNullOrWhiteSpace(dataSourceName)) + { + logger.LogWarning("请求的数据源名称为空,使用默认连接。"); + return BuildFallbackConnection(); + } + + var options = optionsMonitor.CurrentValue.Find(dataSourceName); + if (options != null) + { + if (!ValidateOptions(dataSourceName, options)) + { + return BuildFallbackConnection(); + } + + var connectionString = ResolveConnectionString(options, role); + return new DatabaseConnectionDetails( + connectionString, + options.CommandTimeoutSeconds, + options.MaxRetryCount, + options.MaxRetryDelaySeconds); + } + + var fallback = configuration.GetConnectionString(dataSourceName); + if (string.IsNullOrWhiteSpace(fallback)) + { + logger.LogError("缺少数据源 {DataSource} 的连接配置,回退到默认本地连接。", dataSourceName); + return BuildFallbackConnection(); + } + + logger.LogWarning("未找到数据源 {DataSource} 的 Database 节配置,回退使用 ConnectionStrings。", dataSourceName); + return new DatabaseConnectionDetails( + fallback, + DefaultCommandTimeoutSeconds, + DefaultMaxRetryCount, + DefaultMaxRetryDelaySeconds); + } + + /// + /// 校验数据源配置完整性。 + /// + /// 数据源名称。 + /// 数据源配置。 + /// 配置不合法时抛出。 + private bool ValidateOptions(string dataSourceName, DatabaseDataSourceOptions options) + { + var results = new List(); + var context = new ValidationContext(options); + if (!Validator.TryValidateObject(options, context, results, validateAllProperties: true)) + { + var errorMessages = string.Join("; ", results.Select(result => result.ErrorMessage)); + logger.LogError("数据源 {DataSource} 配置非法:{Errors},回退到默认连接。", dataSourceName, errorMessages); + return false; + } + + return true; + } + + /// + /// 根据读写角色选择连接串,从读连接随机分配。 + /// + /// 数据源配置。 + /// 连接角色。 + /// 可用连接串。 + private string ResolveConnectionString(DatabaseDataSourceOptions options, DatabaseConnectionRole role) + { + if (role == DatabaseConnectionRole.Read && options.Reads.Count > 0) + { + var index = RandomNumberGenerator.GetInt32(options.Reads.Count); + return options.Reads[index]; + } + + if (string.IsNullOrWhiteSpace(options.Write)) + { + return BuildFallbackConnection().ConnectionString; + } + + return options.Write; + } + + private DatabaseConnectionDetails BuildFallbackConnection() + { + const string fallback = "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Pooling=true;Minimum Pool Size=1;Maximum Pool Size=20"; + logger.LogWarning("使用默认回退连接串:{Connection}", fallback); + return new DatabaseConnectionDetails( + fallback, + DefaultCommandTimeoutSeconds, + DefaultMaxRetryCount, + DefaultMaxRetryDelaySeconds); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/IDatabaseConnectionFactory.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/IDatabaseConnectionFactory.cs new file mode 100644 index 0000000..4a684df --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/IDatabaseConnectionFactory.cs @@ -0,0 +1,17 @@ +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 数据库连接工厂,负责按读写角色选择对应连接串及配置。 +/// +public interface IDatabaseConnectionFactory +{ + /// + /// 获取指定数据源与读写角色的连接信息。 + /// + /// 逻辑数据源名称。 + /// 连接角色(读/写)。 + /// 连接串与相关配置。 + DatabaseConnectionDetails GetConnection(string dataSourceName, DatabaseConnectionRole role); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs index 10bd6d5..8681af9 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs @@ -1,6 +1,7 @@ using System.Reflection; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Shared.Abstractions.Entities; +using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Infrastructure.Common.Persistence; @@ -8,14 +9,12 @@ namespace TakeoutSaaS.Infrastructure.Common.Persistence; /// /// 多租户感知 DbContext:自动应用租户过滤并填充租户字段。 /// -public abstract class TenantAwareDbContext : DbContext +public abstract class TenantAwareDbContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor? currentUserAccessor = null) : AppDbContext(options, currentUserAccessor) { - private readonly ITenantProvider _tenantProvider; - - protected TenantAwareDbContext(DbContextOptions options, ITenantProvider tenantProvider) : base(options) - { - _tenantProvider = tenantProvider; - } + private readonly ITenantProvider _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider)); /// /// 当前请求租户 ID。 @@ -23,8 +22,18 @@ public abstract class TenantAwareDbContext : DbContext protected Guid CurrentTenantId => _tenantProvider.GetCurrentTenantId(); /// - /// 应用租户过滤器至所有实现 的实体。 + /// 保存前填充租户元数据并执行基础处理。 /// + protected override void OnBeforeSaving() + { + ApplyTenantMetadata(); + base.OnBeforeSaving(); + } + + /// + /// 应用租户过滤器到所有实现 的实体。 + /// + /// 模型构建器。 protected void ApplyTenantQueryFilters(ModelBuilder modelBuilder) { foreach (var entityType in modelBuilder.Model.GetEntityTypes()) @@ -42,24 +51,20 @@ public abstract class TenantAwareDbContext : DbContext } } + /// + /// 为具体实体设置租户过滤器。 + /// + /// 实体类型。 + /// 模型构建器。 private void SetTenantFilter(ModelBuilder modelBuilder) where TEntity : class, IMultiTenantEntity { modelBuilder.Entity().HasQueryFilter(entity => entity.TenantId == CurrentTenantId); } - public override int SaveChanges() - { - ApplyTenantMetadata(); - return base.SaveChanges(); - } - - public override Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - ApplyTenantMetadata(); - return base.SaveChangesAsync(cancellationToken); - } - + /// + /// 为新增实体填充租户 ID。 + /// private void ApplyTenantMetadata() { var tenantId = CurrentTenantId; @@ -71,19 +76,5 @@ public abstract class TenantAwareDbContext : DbContext entry.Entity.TenantId = tenantId; } } - - var utcNow = DateTime.UtcNow; - foreach (var entry in ChangeTracker.Entries()) - { - if (entry.State == EntityState.Added) - { - entry.Entity.CreatedAt = utcNow; - entry.Entity.UpdatedAt = null; - } - else if (entry.State == EntityState.Modified) - { - entry.Entity.UpdatedAt = utcNow; - } - } } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs index ad33dab..be31f5a 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -1,13 +1,15 @@ using System; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Infrastructure.Common.Extensions; +using TakeoutSaaS.Infrastructure.Common.Options; using TakeoutSaaS.Infrastructure.Dictionary.Options; using TakeoutSaaS.Infrastructure.Dictionary.Persistence; using TakeoutSaaS.Infrastructure.Dictionary.Repositories; using TakeoutSaaS.Infrastructure.Dictionary.Services; +using TakeoutSaaS.Shared.Abstractions.Constants; namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions; @@ -19,18 +21,14 @@ public static class DictionaryServiceCollectionExtensions /// /// 注册字典模块基础设施。 /// + /// 服务集合。 + /// 配置源。 + /// 服务集合。 + /// 缺少数据库配置时抛出。 public static IServiceCollection AddDictionaryInfrastructure(this IServiceCollection services, IConfiguration configuration) { - var connectionString = configuration.GetConnectionString("AppDatabase"); - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new InvalidOperationException("缺少 AppDatabase 连接字符串配置"); - } - - services.AddDbContext(options => - { - options.UseNpgsql(connectionString); - }); + services.AddDatabaseInfrastructure(configuration); + services.AddPostgresDbContext(DatabaseConstants.AppDataSource); services.AddScoped(); services.AddScoped(); @@ -41,4 +39,15 @@ public static class DictionaryServiceCollectionExtensions return services; } + + /// + /// 确保数据库连接已配置(Database 节或 ConnectionStrings)。 + /// + /// 配置源。 + /// 数据源名称。 + /// 未配置时抛出。 + private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName) + { + // 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。 + } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs index 90351a8..a6aa6b3 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using TakeoutSaaS.Domain.Dictionary.Entities; using TakeoutSaaS.Infrastructure.Common.Persistence; +using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence; @@ -9,55 +10,79 @@ namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence; /// /// 参数字典 DbContext。 /// -public sealed class DictionaryDbContext : TenantAwareDbContext +public sealed class DictionaryDbContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor? currentUserAccessor = null) + : TenantAwareDbContext(options, tenantProvider, currentUserAccessor) { - public DictionaryDbContext(DbContextOptions options, ITenantProvider tenantProvider) - : base(options, tenantProvider) - { - } - + /// + /// 字典分组集。 + /// public DbSet DictionaryGroups => Set(); + + /// + /// 字典项集。 + /// public DbSet DictionaryItems => Set(); + /// + /// 配置实体模型。 + /// + /// 模型构建器。 protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); ConfigureGroup(modelBuilder.Entity()); ConfigureItem(modelBuilder.Entity()); ApplyTenantQueryFilters(modelBuilder); } + /// + /// 配置字典分组。 + /// + /// 实体构建器。 private static void ConfigureGroup(EntityTypeBuilder builder) { builder.ToTable("dictionary_groups"); builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); builder.Property(x => x.Code).HasMaxLength(64).IsRequired(); builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.Property(x => x.Scope).HasConversion().IsRequired(); builder.Property(x => x.Description).HasMaxLength(512); builder.Property(x => x.IsEnabled).HasDefaultValue(true); - builder.Property(x => x.CreatedAt).IsRequired(); - builder.Property(x => x.UpdatedAt); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => x.TenantId); builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); } + /// + /// 配置字典项。 + /// + /// 实体构建器。 private static void ConfigureItem(EntityTypeBuilder builder) { builder.ToTable("dictionary_items"); builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.GroupId).IsRequired(); builder.Property(x => x.Key).HasMaxLength(64).IsRequired(); builder.Property(x => x.Value).HasMaxLength(256).IsRequired(); builder.Property(x => x.Description).HasMaxLength(512); builder.Property(x => x.SortOrder).HasDefaultValue(100); builder.Property(x => x.IsEnabled).HasDefaultValue(true); - builder.Property(x => x.CreatedAt).IsRequired(); - builder.Property(x => x.UpdatedAt); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); builder.HasOne(x => x.Group) .WithMany(g => g.Items) .HasForeignKey(x => x.GroupId) .OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(x => x.TenantId); builder.HasIndex(x => new { x.GroupId, x.Key }).IsUnique(); } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs index 6b588c6..192b3a5 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs @@ -1,48 +1,47 @@ using System; using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Infrastructure.Common.Extensions; +using TakeoutSaaS.Infrastructure.Common.Options; using TakeoutSaaS.Infrastructure.Identity.Options; using TakeoutSaaS.Infrastructure.Identity.Persistence; using TakeoutSaaS.Infrastructure.Identity.Services; +using TakeoutSaaS.Shared.Abstractions.Constants; using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser; namespace TakeoutSaaS.Infrastructure.Identity.Extensions; /// -/// 身份认证基础设施注入 +/// 身份认证基础设施注入。 /// public static class ServiceCollectionExtensions { /// - /// 注册身份认证基础设施(数据库、Redis、JWT、限流等) + /// 注册身份认证基础设施(数据库、Redis、JWT、限流等)。 /// - /// 服务集合 - /// 配置源 - /// 是否启用小程序相关依赖(如微信登录) - /// 是否启用后台账号初始化 + /// 服务集合。 + /// 配置源。 + /// 是否启用小程序相关依赖(如微信登录)。 + /// 是否启用后台账号初始化。 + /// 服务集合。 + /// 配置缺失时抛出。 public static IServiceCollection AddIdentityInfrastructure( this IServiceCollection services, IConfiguration configuration, bool enableMiniFeatures = false, bool enableAdminSeed = false) { - var dbConnection = configuration.GetConnectionString("IdentityDatabase"); - if (string.IsNullOrWhiteSpace(dbConnection)) - { - throw new InvalidOperationException("缺少 IdentityDatabase 连接字符串配置"); - } - - services.AddDbContext(options => options.UseNpgsql(dbConnection)); + services.AddDatabaseInfrastructure(configuration); + services.AddPostgresDbContext(DatabaseConstants.IdentityDataSource); var redisConnection = configuration.GetConnectionString("Redis"); if (string.IsNullOrWhiteSpace(redisConnection)) { - throw new InvalidOperationException("缺少 Redis 连接字符串配置"); + throw new InvalidOperationException("缺少 Redis 连接字符串配置。"); } services.AddStackExchangeRedisCache(options => @@ -96,4 +95,15 @@ public static class ServiceCollectionExtensions return services; } + + /// + /// 确保数据库连接已配置(Database 节或 ConnectionStrings)。 + /// + /// 配置源。 + /// 数据源名称。 + /// 未配置时抛出。 + private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName) + { + // 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。 + } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index d9b5b52..07ab279 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -5,30 +5,46 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Infrastructure.Common.Persistence; +using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Infrastructure.Identity.Persistence; /// -/// 身份认证 DbContext,带多租户过滤。 +/// 身份认证 DbContext,带多租户过滤与审计字段处理。 /// -public sealed class IdentityDbContext : TenantAwareDbContext +public sealed class IdentityDbContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor? currentUserAccessor = null) + : TenantAwareDbContext(options, tenantProvider, currentUserAccessor) { - public IdentityDbContext(DbContextOptions options, ITenantProvider tenantProvider) - : base(options, tenantProvider) - { - } - + /// + /// 管理后台用户集合。 + /// public DbSet IdentityUsers => Set(); + + /// + /// 小程序用户集合。 + /// public DbSet MiniUsers => Set(); + /// + /// 配置实体模型。 + /// + /// 模型构建器。 protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); ConfigureIdentityUser(modelBuilder.Entity()); ConfigureMiniUser(modelBuilder.Entity()); ApplyTenantQueryFilters(modelBuilder); } + /// + /// 配置管理后台用户实体。 + /// + /// 实体构建器。 private static void ConfigureIdentityUser(EntityTypeBuilder builder) { builder.ToTable("identity_users"); @@ -37,6 +53,9 @@ public sealed class IdentityDbContext : TenantAwareDbContext builder.Property(x => x.DisplayName).HasMaxLength(64).IsRequired(); builder.Property(x => x.PasswordHash).HasMaxLength(256).IsRequired(); builder.Property(x => x.Avatar).HasMaxLength(256); + builder.Property(x => x.TenantId).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); var converter = new ValueConverter( v => string.Join(',', v), @@ -55,18 +74,27 @@ public sealed class IdentityDbContext : TenantAwareDbContext .HasConversion(converter) .Metadata.SetValueComparer(comparer); - builder.HasIndex(x => x.Account).IsUnique(); + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.Account }).IsUnique(); } + /// + /// 配置小程序用户实体。 + /// + /// 实体构建器。 private static void ConfigureMiniUser(EntityTypeBuilder builder) { builder.ToTable("mini_users"); builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); builder.Property(x => x.OpenId).HasMaxLength(128).IsRequired(); builder.Property(x => x.UnionId).HasMaxLength(128); builder.Property(x => x.Nickname).HasMaxLength(64).IsRequired(); builder.Property(x => x.Avatar).HasMaxLength(256); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); - builder.HasIndex(x => x.OpenId).IsUnique(); + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.OpenId }).IsUnique(); } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj index 92fa65a..c3b590c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs new file mode 100644 index 0000000..666c554 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Module.Messaging.Abstractions; + +/// +/// 消息发布抽象。 +/// +public interface IMessagePublisher +{ + /// + /// 发布消息到指定路由键。 + /// + Task PublishAsync(string routingKey, T message, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs new file mode 100644 index 0000000..1e7e0bd --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Module.Messaging.Abstractions; + +/// +/// 消息订阅抽象。 +/// +public interface IMessageSubscriber : IAsyncDisposable +{ + /// + /// 订阅指定队列与路由键,处理后返回是否消费成功。 + /// + Task SubscribeAsync(string queue, string routingKey, Func> handler, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Extensions/MessagingServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Extensions/MessagingServiceCollectionExtensions.cs new file mode 100644 index 0000000..9a53b1a --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Extensions/MessagingServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Module.Messaging.Abstractions; +using TakeoutSaaS.Module.Messaging.Options; +using TakeoutSaaS.Module.Messaging.Serialization; +using TakeoutSaaS.Module.Messaging.Services; + +namespace TakeoutSaaS.Module.Messaging.Extensions; + +/// +/// 消息队列模块注册扩展。 +/// +public static class MessagingServiceCollectionExtensions +{ + /// + /// 注册 RabbitMQ 发布/订阅能力。 + /// + public static IServiceCollection AddMessagingModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("RabbitMQ")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Options/RabbitMqOptions.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Options/RabbitMqOptions.cs new file mode 100644 index 0000000..1b10e6e --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Options/RabbitMqOptions.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Messaging.Options; + +/// +/// RabbitMQ 连接与交换机配置。 +/// +public sealed class RabbitMqOptions +{ + /// + /// 主机名。 + /// + [Required] + public string Host { get; set; } = "localhost"; + + /// + /// 端口。 + /// + [Range(1, 65535)] + public int Port { get; set; } = 5672; + + /// + /// 用户名。 + /// + [Required] + public string Username { get; set; } = "guest"; + + /// + /// 密码。 + /// + [Required] + public string Password { get; set; } = "guest"; + + /// + /// 虚拟主机。 + /// + public string VirtualHost { get; set; } = "/"; + + /// + /// 默认交换机名称。 + /// + [Required] + public string Exchange { get; set; } = "takeout.events"; + + /// + /// 交换机类型,默认 topic。 + /// + public string ExchangeType { get; set; } = "topic"; + + /// + /// 消费预取数量。 + /// + [Range(1, 1000)] + public ushort PrefetchCount { get; set; } = 20; +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Serialization/JsonMessageSerializer.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Serialization/JsonMessageSerializer.cs new file mode 100644 index 0000000..a17186c --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Serialization/JsonMessageSerializer.cs @@ -0,0 +1,22 @@ +using System.Text; +using System.Text.Json; + +namespace TakeoutSaaS.Module.Messaging.Serialization; + +/// +/// 消息 JSON 序列化器。 +/// +public sealed class JsonMessageSerializer +{ + private static readonly JsonSerializerOptions DefaultOptions = new(JsonSerializerDefaults.Web); + + /// + /// 序列化消息。 + /// + public byte[] Serialize(T message) => Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message, DefaultOptions)); + + /// + /// 反序列化消息。 + /// + public T? Deserialize(byte[] body) => JsonSerializer.Deserialize(body, DefaultOptions); +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs new file mode 100644 index 0000000..ad844c2 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; +using RabbitMQ.Client; +using TakeoutSaaS.Module.Messaging.Options; + +namespace TakeoutSaaS.Module.Messaging.Services; + +/// +/// RabbitMQ 连接工厂封装。 +/// +public sealed class RabbitMqConnectionFactory(IOptionsMonitor optionsMonitor) +{ + /// + /// 创建连接。 + /// + public IConnection CreateConnection() + { + var options = optionsMonitor.CurrentValue; + var factory = new ConnectionFactory + { + HostName = options.Host, + Port = options.Port, + UserName = options.Username, + Password = options.Password, + VirtualHost = options.VirtualHost, + DispatchConsumersAsync = true + }; + + return factory.CreateConnection(); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs new file mode 100644 index 0000000..113ee3c --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs @@ -0,0 +1,66 @@ +using System; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RabbitMQ.Client; +using TakeoutSaaS.Module.Messaging.Abstractions; +using TakeoutSaaS.Module.Messaging.Options; +using TakeoutSaaS.Module.Messaging.Serialization; + +namespace TakeoutSaaS.Module.Messaging.Services; + +/// +/// RabbitMQ 消息发布实现。 +/// +public sealed class RabbitMqMessagePublisher(RabbitMqConnectionFactory connectionFactory, IOptionsMonitor optionsMonitor, JsonMessageSerializer serializer, ILogger logger) + : IMessagePublisher, IAsyncDisposable +{ + private IConnection? _connection; + private IModel? _channel; + private bool _disposed; + + /// + public Task PublishAsync(string routingKey, T message, CancellationToken cancellationToken = default) + { + EnsureChannel(); + var options = optionsMonitor.CurrentValue; + + _channel!.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); + var body = serializer.Serialize(message); + var props = _channel.CreateBasicProperties(); + props.ContentType = "application/json"; + props.DeliveryMode = 2; + props.MessageId = Guid.NewGuid().ToString("N"); + + _channel.BasicPublish(options.Exchange, routingKey, props, body); + logger.LogDebug("发布消息到交换机 {Exchange} RoutingKey {RoutingKey}", options.Exchange, routingKey); + return Task.CompletedTask; + } + + private void EnsureChannel() + { + if (_channel != null && _channel.IsOpen) + { + return; + } + + _connection ??= connectionFactory.CreateConnection(); + _channel = _connection.CreateModel(); + } + + /// + /// 释放 RabbitMQ 资源。 + /// + public ValueTask DisposeAsync() + { + if (_disposed) + { + return ValueTask.CompletedTask; + } + + _disposed = true; + _channel?.Dispose(); + _connection?.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs new file mode 100644 index 0000000..88f19a9 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using TakeoutSaaS.Module.Messaging.Abstractions; +using TakeoutSaaS.Module.Messaging.Options; +using TakeoutSaaS.Module.Messaging.Serialization; + +namespace TakeoutSaaS.Module.Messaging.Services; + +/// +/// RabbitMQ 消费者实现。 +/// +public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connectionFactory, IOptionsMonitor optionsMonitor, JsonMessageSerializer serializer, ILogger logger) + : IMessageSubscriber +{ + private IConnection? _connection; + private IModel? _channel; + private bool _disposed; + + /// + public async Task SubscribeAsync(string queue, string routingKey, Func> handler, CancellationToken cancellationToken = default) + { + EnsureChannel(); + var options = optionsMonitor.CurrentValue; + + _channel!.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); + _channel.QueueDeclare(queue, durable: true, exclusive: false, autoDelete: false); + _channel.QueueBind(queue, options.Exchange, routingKey); + _channel.BasicQos(0, options.PrefetchCount, global: false); + + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.Received += async (_, ea) => + { + var message = serializer.Deserialize(ea.Body.ToArray()); + if (message == null) + { + _channel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + var success = false; + try + { + success = await handler(message, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "处理消息失败:{RoutingKey}", ea.RoutingKey); + } + + if (success) + { + _channel.BasicAck(ea.DeliveryTag, multiple: false); + } + else + { + _channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false); + } + }; + + _channel.BasicConsume(queue, autoAck: false, consumer); + await Task.CompletedTask.ConfigureAwait(false); + } + + private void EnsureChannel() + { + if (_channel != null && _channel.IsOpen) + { + return; + } + + _connection ??= connectionFactory.CreateConnection(); + _channel = _connection.CreateModel(); + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + await Task.Run(() => + { + _channel?.Dispose(); + _connection?.Dispose(); + }).ConfigureAwait(false); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj b/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj index 4e9d749..ef94b34 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj +++ b/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj @@ -5,10 +5,15 @@ enable + + + + + + - diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs new file mode 100644 index 0000000..79f5a29 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Module.Scheduler.Abstractions; + +/// +/// 周期性任务注册抽象。 +/// +public interface IRecurringJobRegistrar +{ + /// + /// 注册所有预设的周期性任务。 + /// + Task RegisterAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs new file mode 100644 index 0000000..f80b65d --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs @@ -0,0 +1,68 @@ +using Hangfire; +using Hangfire.PostgreSql; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Scheduler.Abstractions; +using TakeoutSaaS.Module.Scheduler.HostedServices; +using TakeoutSaaS.Module.Scheduler.Jobs; +using TakeoutSaaS.Module.Scheduler.Options; +using TakeoutSaaS.Module.Scheduler.Services; + +namespace TakeoutSaaS.Module.Scheduler.Extensions; + +/// +/// 调度模块注册扩展(默认 Hangfire)。 +/// +public static class SchedulerServiceCollectionExtensions +{ + /// + /// 注册调度模块。 + /// + public static IServiceCollection AddSchedulerModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("Scheduler")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddHangfire((serviceProvider, config) => + { + var options = serviceProvider.GetRequiredService>().CurrentValue; + config + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UsePostgreSqlStorage(options.ConnectionString); + }); + + services.AddHangfireServer((serviceProvider, options) => + { + var scheduler = serviceProvider.GetRequiredService>().CurrentValue; + options.WorkerCount = scheduler.WorkerCount ?? options.WorkerCount; + }); + + services.AddSingleton(); + services.AddHostedService(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + /// + /// 启用 Hangfire Dashboard(默认关闭,可通过配置开启)。 + /// + public static IApplicationBuilder UseSchedulerDashboard(this IApplicationBuilder app, IConfiguration configuration) + { + var options = configuration.GetSection("Scheduler").Get(); + if (options is { DashboardEnabled: true }) + { + app.UseHangfireDashboard(options.DashboardPath); + } + + return app; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/HostedServices/RecurringJobHostedService.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/HostedServices/RecurringJobHostedService.cs new file mode 100644 index 0000000..b2dca9b --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/HostedServices/RecurringJobHostedService.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Module.Scheduler.Abstractions; + +namespace TakeoutSaaS.Module.Scheduler.HostedServices; + +/// +/// 启动时注册周期性任务的宿主服务。 +/// +public sealed class RecurringJobHostedService(IRecurringJobRegistrar registrar, ILogger logger) : IHostedService +{ + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + await registrar.RegisterAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("调度任务已注册"); + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/CouponExpireJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/CouponExpireJob.cs new file mode 100644 index 0000000..294c2da --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/CouponExpireJob.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; + +namespace TakeoutSaaS.Module.Scheduler.Jobs; + +/// +/// 优惠券过期处理任务(占位实现)。 +/// +public sealed class CouponExpireJob(ILogger logger) +{ + /// + /// 执行优惠券过期清理。 + /// + public Task ExecuteAsync() + { + logger.LogInformation("定时任务:处理已过期优惠券(占位实现)"); + return Task.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/LogCleanupJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/LogCleanupJob.cs new file mode 100644 index 0000000..aa67c70 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/LogCleanupJob.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; + +namespace TakeoutSaaS.Module.Scheduler.Jobs; + +/// +/// 日志清理任务(占位实现)。 +/// +public sealed class LogCleanupJob(ILogger logger) +{ + /// + /// 执行日志清理。 + /// + public Task ExecuteAsync() + { + logger.LogInformation("定时任务:清理历史日志(占位实现)"); + return Task.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs new file mode 100644 index 0000000..80d7513 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; + +namespace TakeoutSaaS.Module.Scheduler.Jobs; + +/// +/// 订单超时取消任务(占位,后续接入订单服务)。 +/// +public sealed class OrderTimeoutJob(ILogger logger) +{ + /// + /// 执行超时订单检查。 + /// + public Task ExecuteAsync() + { + logger.LogInformation("定时任务:检查超时未支付订单并取消(占位实现)"); + return Task.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Options/SchedulerOptions.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Options/SchedulerOptions.cs new file mode 100644 index 0000000..880790c --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Options/SchedulerOptions.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Scheduler.Options; + +/// +/// 调度模块配置。 +/// +public sealed class SchedulerOptions +{ + /// + /// Hangfire 存储使用的连接字符串。 + /// + [Required] + public string ConnectionString { get; set; } = string.Empty; + + /// + /// 工作线程数,默认根据 CPU 计算。 + /// + [Range(1, 100)] + public int? WorkerCount { get; set; } + + /// + /// 是否启用 Dashboard(默认 false,待 AdminUI 接入)。 + /// + public bool DashboardEnabled { get; set; } + + /// + /// Dashboard 路径。 + /// + public string DashboardPath { get; set; } = "/hangfire"; +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs new file mode 100644 index 0000000..1da22a0 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs @@ -0,0 +1,37 @@ +using Hangfire; +using TakeoutSaaS.Module.Scheduler.Abstractions; +using TakeoutSaaS.Module.Scheduler.Jobs; + +namespace TakeoutSaaS.Module.Scheduler.Services; + +/// +/// 周期性任务注册器。 +/// +public sealed class RecurringJobRegistrar : IRecurringJobRegistrar +{ + private readonly OrderTimeoutJob _orderTimeoutJob; + private readonly CouponExpireJob _couponExpireJob; + private readonly LogCleanupJob _logCleanupJob; + + /// + /// 初始化注册器。 + /// + public RecurringJobRegistrar( + OrderTimeoutJob orderTimeoutJob, + CouponExpireJob couponExpireJob, + LogCleanupJob logCleanupJob) + { + _orderTimeoutJob = orderTimeoutJob; + _couponExpireJob = couponExpireJob; + _logCleanupJob = logCleanupJob; + } + + /// + public Task RegisterAsync(CancellationToken cancellationToken = default) + { + RecurringJob.AddOrUpdate("orders.timeout-cancel", () => _orderTimeoutJob.ExecuteAsync(), "*/5 * * * *"); + RecurringJob.AddOrUpdate("coupons.expire", () => _couponExpireJob.ExecuteAsync(), "0 */1 * * *"); + RecurringJob.AddOrUpdate("logs.cleanup", () => _logCleanupJob.ExecuteAsync(), "0 3 * * *"); + return Task.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj b/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj index b407eac..8e4c663 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj @@ -4,8 +4,16 @@ enable enable + + + + + + + + + - diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs new file mode 100644 index 0000000..5cf7dd5 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Module.Sms.Models; + +namespace TakeoutSaaS.Module.Sms.Abstractions; + +/// +/// 短信发送抽象。 +/// +public interface ISmsSender +{ + /// + /// 服务商类型。 + /// + SmsProviderKind Provider { get; } + + /// + /// 发送短信。 + /// + Task SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSenderResolver.cs b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSenderResolver.cs new file mode 100644 index 0000000..f3385d2 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSenderResolver.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Module.Sms.Abstractions; + +/// +/// 短信服务商解析器。 +/// +public interface ISmsSenderResolver +{ + /// + /// 获取指定服务商的发送器。 + /// + ISmsSender Resolve(SmsProviderKind? provider = null); +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Extensions/SmsServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Sms/Extensions/SmsServiceCollectionExtensions.cs new file mode 100644 index 0000000..651c143 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Extensions/SmsServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Module.Sms.Abstractions; +using TakeoutSaaS.Module.Sms.Options; +using TakeoutSaaS.Module.Sms.Services; + +namespace TakeoutSaaS.Module.Sms.Extensions; + +/// +/// 短信模块 DI 注册扩展。 +/// +public static class SmsServiceCollectionExtensions +{ + /// + /// 注册短信模块(包含腾讯云、阿里云实现)。 + /// + public static IServiceCollection AddSmsModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("Sms")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddHttpClient(nameof(TencentSmsSender)); + services.AddHttpClient(nameof(AliyunSmsSender)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs new file mode 100644 index 0000000..643f299 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Module.Sms.Models; + +/// +/// 短信发送请求。 +/// +public sealed class SmsSendRequest +{ + /// + /// 初始化短信发送请求。 + /// + /// 目标手机号码(含国家码,如 +86xxxxxxxxxxx)。 + /// 模版编号。 + /// 模版变量。 + /// 短信签名。 + public SmsSendRequest(string phoneNumber, string templateCode, IDictionary variables, string? signName = null) + { + PhoneNumber = phoneNumber; + TemplateCode = templateCode; + Variables = new Dictionary(variables); + SignName = signName; + } + + /// + /// 目标手机号。 + /// + public string PhoneNumber { get; } + + /// + /// 模版编号。 + /// + public string TemplateCode { get; } + + /// + /// 模版变量。 + /// + public IReadOnlyDictionary Variables { get; } + + /// + /// 可选的签名。 + /// + public string? SignName { get; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs new file mode 100644 index 0000000..afc81fb --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Module.Sms.Models; + +/// +/// 短信发送结果。 +/// +public sealed class SmsSendResult +{ + /// + /// 是否发送成功。 + /// + public bool Success { get; init; } + + /// + /// 平台返回的请求标识。 + /// + public string? RequestId { get; init; } + + /// + /// 描述信息。 + /// + public string? Message { get; init; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Options/AliyunSmsOptions.cs b/src/Modules/TakeoutSaaS.Module.Sms/Options/AliyunSmsOptions.cs new file mode 100644 index 0000000..cfa09b1 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Options/AliyunSmsOptions.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Sms.Options; + +/// +/// 阿里云短信配置。 +/// +public sealed class AliyunSmsOptions +{ + /// + /// AccessKeyId。 + /// + [Required] + public string AccessKeyId { get; set; } = string.Empty; + + /// + /// AccessKeySecret。 + /// + [Required] + public string AccessKeySecret { get; set; } = string.Empty; + + /// + /// 短信服务域名。 + /// + public string Endpoint { get; set; } = "dysmsapi.aliyuncs.com"; + + /// + /// 默认签名。 + /// + public string? SignName { get; set; } + + /// + /// 地域 ID。 + /// + public string Region { get; set; } = "cn-hangzhou"; +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs b/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs new file mode 100644 index 0000000..5520760 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Module.Sms; + +namespace TakeoutSaaS.Module.Sms.Options; + +/// +/// 短信模块配置。 +/// +public sealed class SmsOptions +{ + /// + /// 默认服务商,默认为腾讯云。 + /// + public SmsProviderKind Provider { get; set; } = SmsProviderKind.Tencent; + + /// + /// 默认签名。 + /// + public string? DefaultSignName { get; set; } + + /// + /// 是否启用模拟发送(仅日志,不实际调用),方便开发环境。 + /// + public bool UseMock { get; set; } + + /// + /// 腾讯云短信配置。 + /// + [Required] + public TencentSmsOptions Tencent { get; set; } = new(); + + /// + /// 阿里云短信配置。 + /// + [Required] + public AliyunSmsOptions Aliyun { get; set; } = new(); + + /// + /// 场景与模板映射(如 login: TEMPLATE_ID)。 + /// + public Dictionary SceneTemplates { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Options/TencentSmsOptions.cs b/src/Modules/TakeoutSaaS.Module.Sms/Options/TencentSmsOptions.cs new file mode 100644 index 0000000..3e02bd4 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Options/TencentSmsOptions.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Sms.Options; + +/// +/// 腾讯云短信配置。 +/// +public sealed class TencentSmsOptions +{ + /// + /// SecretId。 + /// + [Required] + public string SecretId { get; set; } = string.Empty; + + /// + /// SecretKey。 + /// + [Required] + public string SecretKey { get; set; } = string.Empty; + + /// + /// 应用 SdkAppId。 + /// + [Required] + public string SdkAppId { get; set; } = string.Empty; + + /// + /// 默认签名。 + /// + public string? SignName { get; set; } + + /// + /// 默认地域。 + /// + public string Region { get; set; } = "ap-guangzhou"; + + /// + /// 接口域名。 + /// + public string Endpoint { get; set; } = "https://sms.tencentcloudapi.com"; +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs new file mode 100644 index 0000000..c94a47c --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs @@ -0,0 +1,35 @@ +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Sms.Abstractions; +using TakeoutSaaS.Module.Sms.Models; +using TakeoutSaaS.Module.Sms.Options; + +namespace TakeoutSaaS.Module.Sms.Services; + +/// +/// 阿里云短信发送实现(简化版,占位可扩展正式签名流程)。 +/// +public sealed class AliyunSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor optionsMonitor, ILogger logger) + : ISmsSender +{ + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + + /// + public SmsProviderKind Provider => SmsProviderKind.Aliyun; + + /// + public Task SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default) + { + var options = optionsMonitor.CurrentValue; + if (options.UseMock) + { + logger.LogInformation("Mock 发送阿里云短信到 {Phone}, Template:{Template}", request.PhoneNumber, request.TemplateCode); + return Task.FromResult(new SmsSendResult { Success = true, Message = "Mocked" }); + } + + // 占位:保留待接入阿里云正式签名流程,当前返回未实现。 + logger.LogWarning("阿里云短信尚未启用,请配置腾讯云或开启 UseMock。"); + return Task.FromResult(new SmsSendResult { Success = false, Message = "Aliyun SMS not enabled" }); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs new file mode 100644 index 0000000..e47f6be --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Sms.Abstractions; +using TakeoutSaaS.Module.Sms.Options; + +namespace TakeoutSaaS.Module.Sms.Services; + +/// +/// 短信服务商解析器。 +/// +public sealed class SmsSenderResolver(IOptionsMonitor optionsMonitor, IEnumerable senders) : ISmsSenderResolver +{ + private readonly IReadOnlyDictionary _map = senders.ToDictionary(x => x.Provider); + + /// + public ISmsSender Resolve(SmsProviderKind? provider = null) + { + var key = provider ?? optionsMonitor.CurrentValue.Provider; + if (_map.TryGetValue(key, out var sender)) + { + return sender; + } + + throw new InvalidOperationException($"未注册短信服务商:{key}"); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs new file mode 100644 index 0000000..f7b9737 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs @@ -0,0 +1,136 @@ +using System.Globalization; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Sms.Abstractions; +using TakeoutSaaS.Module.Sms.Models; +using TakeoutSaaS.Module.Sms.Options; + +namespace TakeoutSaaS.Module.Sms.Services; + +/// +/// 腾讯云短信发送实现(TC3-HMAC 签名)。 +/// +public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor optionsMonitor, ILogger logger) + : ISmsSender +{ + private const string Service = "sms"; + private const string Action = "SendSms"; + private const string Version = "2021-01-11"; + + /// + public SmsProviderKind Provider => SmsProviderKind.Tencent; + + /// + public async Task SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default) + { + var options = optionsMonitor.CurrentValue; + if (options.UseMock) + { + logger.LogInformation("Mock 发送短信到 {Phone}, Template:{Template}, Vars:{Vars}", request.PhoneNumber, request.TemplateCode, JsonSerializer.Serialize(request.Variables)); + return new SmsSendResult { Success = true, Message = "Mocked" }; + } + + var tencent = options.Tencent; + var payload = BuildPayload(request, tencent); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var date = DateTimeOffset.FromUnixTimeSeconds(timestamp).UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + var host = new Uri(tencent.Endpoint).Host; + var canonicalRequest = BuildCanonicalRequest(payload, host, tencent.Endpoint.StartsWith("https", StringComparison.OrdinalIgnoreCase)); + var stringToSign = BuildStringToSign(canonicalRequest, timestamp, date); + var signature = Sign(stringToSign, tencent.SecretKey, date); + + using var httpClient = httpClientFactory.CreateClient(nameof(TencentSmsSender)); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, tencent.Endpoint) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + + httpRequest.Headers.Add("Host", host); + httpRequest.Headers.Add("X-TC-Action", Action); + httpRequest.Headers.Add("X-TC-Version", Version); + httpRequest.Headers.Add("X-TC-Timestamp", timestamp.ToString(CultureInfo.InvariantCulture)); + httpRequest.Headers.Add("X-TC-Region", tencent.Region); + httpRequest.Headers.Add("Authorization", + $"TC3-HMAC-SHA256 Credential={tencent.SecretId}/{date}/{Service}/tc3_request, SignedHeaders=content-type;host, Signature={signature}"); + + var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("腾讯云短信发送失败:{Status} {Content}", response.StatusCode, content); + return new SmsSendResult { Success = false, Message = content }; + } + + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement.GetProperty("Response"); + var status = root.GetProperty("SendStatusSet")[0]; + var code = status.GetProperty("Code").GetString(); + var message = status.GetProperty("Message").GetString(); + var requestId = root.GetProperty("RequestId").GetString(); + + var success = string.Equals(code, "Ok", StringComparison.OrdinalIgnoreCase); + return new SmsSendResult + { + Success = success, + RequestId = requestId, + Message = message + }; + } + + private static string BuildPayload(SmsSendRequest request, TencentSmsOptions options) + { + var payload = new + { + PhoneNumberSet = new[] { request.PhoneNumber }, + SmsSdkAppId = options.SdkAppId, + SignName = request.SignName ?? options.SignName, + TemplateId = request.TemplateCode, + TemplateParamSet = request.Variables.Values.ToArray() + }; + + return JsonSerializer.Serialize(payload); + } + + private static string BuildCanonicalRequest(string payload, string host, bool useHttps) + { + _ = useHttps; + var hashedPayload = HashSha256(payload); + var canonicalHeaders = $"content-type:application/json\nhost:{host}\n"; + return $"POST\n/\n\n{canonicalHeaders}\ncontent-type;host\n{hashedPayload}"; + } + + private static string BuildStringToSign(string canonicalRequest, long timestamp, string date) + { + var hashedRequest = HashSha256(canonicalRequest); + return $"TC3-HMAC-SHA256\n{timestamp}\n{date}/{Service}/tc3_request\n{hashedRequest}"; + } + + private static string Sign(string stringToSign, string secretKey, string date) + { + static byte[] HmacSha256(byte[] key, string msg) => new HMACSHA256(key).ComputeHash(Encoding.UTF8.GetBytes(msg)); + + var secretDate = HmacSha256(Encoding.UTF8.GetBytes($"TC3{secretKey}"), date); + var secretService = HmacSha256(secretDate, Service); + var secretSigning = HmacSha256(secretService, "tc3_request"); + var signatureBytes = new HMACSHA256(secretSigning).ComputeHash(Encoding.UTF8.GetBytes(stringToSign)); + return Convert.ToHexString(signatureBytes).ToLowerInvariant(); + } + + private static string HashSha256(string raw) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + var builder = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) + { + builder.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs b/src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs new file mode 100644 index 0000000..e374d12 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Module.Sms; + +/// +/// 短信服务商类型。 +/// +public enum SmsProviderKind +{ + /// + /// 腾讯云短信。 + /// + Tencent = 1, + + /// + /// 阿里云短信。 + /// + Aliyun = 2 +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj b/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj index b407eac..7ac9fcd 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj +++ b/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj @@ -4,8 +4,16 @@ enable enable + + + + + + + + + - diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs new file mode 100644 index 0000000..c53009c --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Module.Storage.Models; + +namespace TakeoutSaaS.Module.Storage.Abstractions; + +/// +/// 对象存储提供商统一抽象。 +/// +public interface IObjectStorageProvider +{ + /// + /// 当前提供商类型。 + /// + StorageProviderKind Kind { get; } + + /// + /// 上传文件到对象存储。 + /// + Task UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default); + + /// + /// 生成预签名直传参数(PUT 或表单直传)。 + /// + Task CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default); + + /// + /// 生成带过期时间的访问链接。 + /// + Task GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default); + + /// + /// 生成公共访问地址(可结合 CDN)。 + /// + string BuildPublicUrl(string objectKey); +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IStorageProviderResolver.cs b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IStorageProviderResolver.cs new file mode 100644 index 0000000..63ae4cb --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IStorageProviderResolver.cs @@ -0,0 +1,14 @@ +namespace TakeoutSaaS.Module.Storage.Abstractions; + +/// +/// 存储提供商解析器,用于按需选择具体实现。 +/// +public interface IStorageProviderResolver +{ + /// + /// 根据配置解析出可用的存储提供商。 + /// + /// 目标提供商类型,空则使用默认配置。 + /// 对应的存储提供商。 + IObjectStorageProvider Resolve(StorageProviderKind? provider = null); +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Extensions/StorageServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Extensions/StorageServiceCollectionExtensions.cs new file mode 100644 index 0000000..e1972f1 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Extensions/StorageServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Module.Storage.Abstractions; +using TakeoutSaaS.Module.Storage.Options; +using TakeoutSaaS.Module.Storage.Providers; +using TakeoutSaaS.Module.Storage.Services; + +namespace TakeoutSaaS.Module.Storage.Extensions; + +/// +/// 存储模块服务注册扩展。 +/// +public static class StorageServiceCollectionExtensions +{ + /// + /// 注册存储模块所需的提供商与配置。 + /// + /// 服务集合。 + /// 配置源。 + public static IServiceCollection AddStorageModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("Storage")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs new file mode 100644 index 0000000..2d8df5b --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs @@ -0,0 +1,42 @@ +namespace TakeoutSaaS.Module.Storage.Models; + +/// +/// 直传(预签名上传)请求参数。 +/// +public sealed class StorageDirectUploadRequest +{ + /// + /// 初始化请求。 + /// + /// 对象键。 + /// 内容类型。 + /// 内容长度。 + /// 签名有效期。 + public StorageDirectUploadRequest(string objectKey, string contentType, long contentLength, TimeSpan expires) + { + ObjectKey = objectKey; + ContentType = contentType; + ContentLength = contentLength; + Expires = expires; + } + + /// + /// 目标对象键。 + /// + public string ObjectKey { get; } + + /// + /// 内容类型。 + /// + public string ContentType { get; } + + /// + /// 内容长度。 + /// + public long ContentLength { get; } + + /// + /// 签名有效期。 + /// + public TimeSpan Expires { get; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs new file mode 100644 index 0000000..8bfade5 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace TakeoutSaaS.Module.Storage.Models; + +/// +/// 直传(预签名上传)结果。 +/// +public sealed class StorageDirectUploadResult +{ + /// + /// 预签名上传地址(PUT 上传或表单地址)。 + /// + public string UploadUrl { get; init; } = string.Empty; + + /// + /// 直传附加字段(如表单直传所需字段),PUT 方式为空。 + /// + public IReadOnlyDictionary FormFields { get; init; } = new Dictionary(); + + /// + /// 预签名过期时间。 + /// + public DateTimeOffset ExpiresAt { get; init; } + + /// + /// 关联的对象键。 + /// + public string ObjectKey { get; init; } = string.Empty; + + /// + /// 上传成功后可选的签名下载地址。 + /// + public string? SignedDownloadUrl { get; init; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs new file mode 100644 index 0000000..80a2644 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.IO; + +namespace TakeoutSaaS.Module.Storage.Models; + +/// +/// 对象存储上传请求参数。 +/// +public sealed class StorageUploadRequest +{ + /// + /// 初始化上传请求。 + /// + /// 对象键(含路径)。 + /// 文件流。 + /// 内容类型。 + /// 内容长度。 + /// 是否返回签名访问链接。 + /// 签名有效期。 + /// 附加元数据。 + public StorageUploadRequest( + string objectKey, + Stream content, + string contentType, + long contentLength, + bool generateSignedUrl, + TimeSpan signedUrlExpires, + IDictionary? metadata = null) + { + ObjectKey = objectKey; + Content = content; + ContentType = contentType; + ContentLength = contentLength; + GenerateSignedUrl = generateSignedUrl; + SignedUrlExpires = signedUrlExpires; + Metadata = metadata == null + ? new Dictionary() + : new Dictionary(metadata); + } + + /// + /// 对象键。 + /// + public string ObjectKey { get; } + + /// + /// 文件流。 + /// + public Stream Content { get; } + + /// + /// 内容类型。 + /// + public string ContentType { get; } + + /// + /// 内容长度。 + /// + public long ContentLength { get; } + + /// + /// 是否需要签名访问链接。 + /// + public bool GenerateSignedUrl { get; } + + /// + /// 签名有效期。 + /// + public TimeSpan SignedUrlExpires { get; } + + /// + /// 元数据集合。 + /// + public IReadOnlyDictionary Metadata { get; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadResult.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadResult.cs new file mode 100644 index 0000000..c3af710 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadResult.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Module.Storage.Models; + +/// +/// 上传结果信息。 +/// +public sealed class StorageUploadResult +{ + /// + /// 对象键。 + /// + public string ObjectKey { get; init; } = string.Empty; + + /// + /// 可访问的 URL(可能已包含签名)。 + /// + public string Url { get; init; } = string.Empty; + + /// + /// 带过期时间的签名 URL(若生成)。 + /// + public string? SignedUrl { get; init; } + + /// + /// 文件大小。 + /// + public long FileSize { get; init; } + + /// + /// 内容类型。 + /// + public string ContentType { get; init; } = string.Empty; +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Options/AliyunOssOptions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Options/AliyunOssOptions.cs new file mode 100644 index 0000000..d17f548 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Options/AliyunOssOptions.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Storage.Options; + +/// +/// 阿里云 OSS 访问配置。 +/// +public sealed class AliyunOssOptions +{ + /// + /// 访问密钥 ID。 + /// + [Required] + public string AccessKeyId { get; set; } = string.Empty; + + /// + /// 访问密钥 Secret。 + /// + [Required] + public string AccessKeySecret { get; set; } = string.Empty; + + /// + /// Endpoint,如 https://oss-cn-hangzhou.aliyuncs.com。 + /// + [Required] + [Url] + public string Endpoint { get; set; } = string.Empty; + + /// + /// 目标存储桶名称。 + /// + [Required] + public string Bucket { get; set; } = string.Empty; + + /// + /// CDN 加速域名(可选)。 + /// + [Url] + public string? CdnBaseUrl { get; set; } + + /// + /// 是否默认使用 HTTPS。 + /// + public bool UseHttps { get; set; } = true; +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Options/QiniuKodoOptions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Options/QiniuKodoOptions.cs new file mode 100644 index 0000000..7e6bf37 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Options/QiniuKodoOptions.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Storage.Options; + +/// +/// 七牛云 Kodo S3 兼容网关配置。 +/// +public sealed class QiniuKodoOptions +{ + /// + /// AccessKey。 + /// + [Required] + public string AccessKey { get; set; } = string.Empty; + + /// + /// SecretKey。 + /// + [Required] + public string SecretKey { get; set; } = string.Empty; + + /// + /// 绑定的空间名称。 + /// + [Required] + public string Bucket { get; set; } = string.Empty; + + /// + /// 下载域名(CDN 域名或测试域名),用于生成访问链接。 + /// + [Url] + public string? DownloadDomain { get; set; } + + /// + /// S3 兼容网关 Endpoint(如 https://s3-cn-south-1.qiniucs.com),为空则使用官方默认。 + /// + [Url] + public string? Endpoint { get; set; } + + /// + /// 是否使用 HTTPS。 + /// + public bool UseHttps { get; set; } = true; + + /// + /// 直传或下载时默认有效期(分钟),未设置时使用全局安全配置。 + /// + [Range(1, 24 * 60)] + public int? SignedUrlExpirationMinutes { get; set; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageOptions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageOptions.cs new file mode 100644 index 0000000..465b1b7 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageOptions.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Storage.Options; + +/// +/// 存储模块的统一配置项,决定默认提供商与全局安全策略。 +/// +public sealed class StorageOptions +{ + /// + /// 默认使用的存储提供商。 + /// + public StorageProviderKind Provider { get; set; } = StorageProviderKind.TencentCos; + + /// + /// CDN 访问域名(可选),若配置则优先使用 CDN 域名生成访问地址。 + /// + [Url] + public string? CdnBaseUrl { get; set; } + + /// + /// 腾讯云 COS 配置。 + /// + [Required] + public TencentCosOptions TencentCos { get; set; } = new(); + + /// + /// 七牛云 Kodo 配置。 + /// + [Required] + public QiniuKodoOptions QiniuKodo { get; set; } = new(); + + /// + /// 阿里云 OSS 配置。 + /// + [Required] + public AliyunOssOptions AliyunOss { get; set; } = new(); + + /// + /// 存储安全策略配置。 + /// + [Required] + public StorageSecurityOptions Security { get; set; } = new(); +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageSecurityOptions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageSecurityOptions.cs new file mode 100644 index 0000000..0619faf --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageSecurityOptions.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Storage.Options; + +/// +/// 文件安全与防盗链相关配置。 +/// +public sealed class StorageSecurityOptions +{ + /// + /// 单个文件最大尺寸(字节),默认 10MB。 + /// + [Range(1, long.MaxValue)] + public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; + + /// + /// 允许的图片后缀名白名单。 + /// + [MinLength(1)] + public string[] AllowedImageExtensions { get; set; } = { ".jpg", ".jpeg", ".png", ".webp", ".gif" }; + + /// + /// 允许的通用文件后缀名白名单。 + /// + [MinLength(1)] + public string[] AllowedFileExtensions { get; set; } = { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf" }; + + /// + /// 默认签名有效期(分钟),用于生成带过期时间的访问链接。 + /// + [Range(1, 24 * 60)] + public int DefaultUrlExpirationMinutes { get; set; } = 30; + + /// + /// 是否启用来源校验(防盗链),为空则不校验。 + /// + public bool EnableRefererValidation { get; set; } = true; + + /// + /// 允许的 Referer/Origin 前缀列表,用于限制上传接口调用来源。 + /// + public string[] AllowedReferers { get; set; } = Array.Empty(); + + /// + /// 针对 CDN 防盗链的额外签名密钥(可选),用于生成二次校验签名。 + /// + public string? AntiLeechTokenSecret { get; set; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Options/TencentCosOptions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Options/TencentCosOptions.cs new file mode 100644 index 0000000..0808821 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Options/TencentCosOptions.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Storage.Options; + +/// +/// 腾讯云 COS 访问配置。 +/// +public sealed class TencentCosOptions +{ + /// + /// SecretId。 + /// + [Required] + public string SecretId { get; set; } = string.Empty; + + /// + /// SecretKey。 + /// + [Required] + public string SecretKey { get; set; } = string.Empty; + + /// + /// 存储地域(如 ap-guangzhou)。 + /// + [Required] + public string Region { get; set; } = string.Empty; + + /// + /// 存储桶名称(含 AppId,如 takeout-bucket-123456)。 + /// + [Required] + public string Bucket { get; set; } = string.Empty; + + /// + /// COS 自定义域名或 API Endpoint(可选),未配置则根据 Region 生成默认域名。 + /// + public string? Endpoint { get; set; } + + /// + /// CDN 域名(可选),用于生成加速访问地址。 + /// + [Url] + public string? CdnBaseUrl { get; set; } + + /// + /// 是否使用 HTTPS。 + /// + public bool UseHttps { get; set; } = true; + + /// + /// 是否强制使用 PathStyle 访问,COS 默认可使用虚拟主机形式。 + /// + public bool ForcePathStyle { get; set; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs new file mode 100644 index 0000000..abb74ed --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Aliyun.OSS; +using Aliyun.OSS.Util; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Storage.Abstractions; +using TakeoutSaaS.Module.Storage.Models; +using TakeoutSaaS.Module.Storage.Options; + +namespace TakeoutSaaS.Module.Storage.Providers; + +/// +/// 阿里云 OSS 存储提供商实现。 +/// +public sealed class AliyunOssStorageProvider(IOptionsMonitor optionsMonitor) : IObjectStorageProvider, IDisposable +{ + private OssClient? _client; + private bool _disposed; + + private StorageOptions CurrentOptions => optionsMonitor.CurrentValue; + + /// + public StorageProviderKind Kind => StorageProviderKind.AliyunOss; + + /// + public async Task UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default) + { + var options = CurrentOptions; + var metadata = new ObjectMetadata + { + ContentLength = request.ContentLength, + ContentType = request.ContentType + }; + + foreach (var kv in request.Metadata) + { + metadata.UserMetadata[kv.Key] = kv.Value; + } + + // Aliyun OSS SDK 支持异步方法,如未支持将同步封装为任务。 + await PutObjectAsync(options.AliyunOss.Bucket, request.ObjectKey, request.Content, metadata, cancellationToken) + .ConfigureAwait(false); + + var signedUrl = request.GenerateSignedUrl + ? await GenerateDownloadUrlAsync(request.ObjectKey, request.SignedUrlExpires, cancellationToken).ConfigureAwait(false) + : null; + + return new StorageUploadResult + { + ObjectKey = request.ObjectKey, + Url = signedUrl ?? BuildPublicUrl(request.ObjectKey), + SignedUrl = signedUrl, + FileSize = request.ContentLength, + ContentType = request.ContentType + }; + } + + /// + public Task CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default) + { + var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires); + var uploadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Put, request.ContentType); + var downloadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Get, null); + + var result = new StorageDirectUploadResult + { + UploadUrl = uploadUrl, + FormFields = new Dictionary(), + ExpiresAt = expiresAt, + ObjectKey = request.ObjectKey, + SignedDownloadUrl = downloadUrl + }; + + return Task.FromResult(result); + } + + /// + public Task GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default) + { + var url = GeneratePresignedUrl(objectKey, expires, SignHttpMethod.Get, null); + return Task.FromResult(url); + } + + /// + public string BuildPublicUrl(string objectKey) + { + var cdn = CurrentOptions.AliyunOss.CdnBaseUrl ?? CurrentOptions.CdnBaseUrl; + if (!string.IsNullOrWhiteSpace(cdn)) + { + return $"{cdn!.TrimEnd('/')}/{objectKey}"; + } + + var endpoint = CurrentOptions.AliyunOss.Endpoint.TrimEnd('/'); + var scheme = CurrentOptions.AliyunOss.UseHttps ? "https" : "http"; + // Endpoint 可能已包含协议,若没有则补充。 + if (!endpoint.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + endpoint = $"{scheme}://{endpoint}"; + } + + return $"{endpoint}/{CurrentOptions.AliyunOss.Bucket}/{objectKey}"; + } + + /// + /// 上传对象到 OSS。 + /// + private async Task PutObjectAsync(string bucket, string key, Stream content, ObjectMetadata metadata, CancellationToken cancellationToken) + { + var client = EnsureClient(); + await Task.Run(() => client.PutObject(bucket, key, content, metadata), cancellationToken).ConfigureAwait(false); + } + + /// + /// 生成预签名 URL。 + /// + private string GeneratePresignedUrl(string objectKey, TimeSpan expires, SignHttpMethod method, string? contentType) + { + var request = new GeneratePresignedUriRequest(CurrentOptions.AliyunOss.Bucket, objectKey, method) + { + Expiration = DateTime.Now.Add(expires) + }; + + if (!string.IsNullOrWhiteSpace(contentType)) + { + request.ContentType = contentType; + } + + var uri = EnsureClient().GeneratePresignedUri(request); + return uri.ToString(); + } + + /// + /// 构建或复用 OSS 客户端。 + /// + private OssClient EnsureClient() + { + if (_client != null) + { + return _client; + } + + var options = CurrentOptions.AliyunOss; + _client = new OssClient(options.Endpoint, options.AccessKeyId, options.AccessKeySecret); + return _client; + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs new file mode 100644 index 0000000..df6b05d --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Storage.Options; + +namespace TakeoutSaaS.Module.Storage.Providers; + +/// +/// 七牛云 Kodo(S3 兼容网关)存储提供商。 +/// +public sealed class QiniuKodoStorageProvider(IOptionsMonitor optionsMonitor) + : S3StorageProviderBase +{ + private StorageOptions CurrentOptions => optionsMonitor.CurrentValue; + + /// + public override StorageProviderKind Kind => StorageProviderKind.QiniuKodo; + + /// + protected override string Bucket => CurrentOptions.QiniuKodo.Bucket; + + /// + protected override string ServiceUrl => string.IsNullOrWhiteSpace(CurrentOptions.QiniuKodo.Endpoint) + ? $"{(CurrentOptions.QiniuKodo.UseHttps ? "https" : "http")}://s3.qiniucs.com" + : CurrentOptions.QiniuKodo.Endpoint!; + + /// + protected override string AccessKey => CurrentOptions.QiniuKodo.AccessKey; + + /// + protected override string SecretKey => CurrentOptions.QiniuKodo.SecretKey; + + /// + protected override bool UseHttps => CurrentOptions.QiniuKodo.UseHttps; + + /// + protected override bool ForcePathStyle => true; + + /// + protected override string? CdnBaseUrl => !string.IsNullOrWhiteSpace(CurrentOptions.QiniuKodo.DownloadDomain) + ? CurrentOptions.QiniuKodo.DownloadDomain + : CurrentOptions.CdnBaseUrl; + + /// + protected override TimeSpan SignedUrlExpiry + { + get + { + var minutes = CurrentOptions.QiniuKodo.SignedUrlExpirationMinutes + ?? CurrentOptions.Security.DefaultUrlExpirationMinutes; + return TimeSpan.FromMinutes(Math.Max(1, minutes)); + } + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs new file mode 100644 index 0000000..6cc7773 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using TakeoutSaaS.Module.Storage.Abstractions; +using TakeoutSaaS.Module.Storage.Models; + +namespace TakeoutSaaS.Module.Storage.Providers; + +/// +/// 基于 AWS S3 SDK 的通用存储提供商基类,可复用到 COS 与 Kodo 等兼容实现。 +/// +public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposable +{ + private IAmazonS3? _client; + private bool _disposed; + + /// + public abstract StorageProviderKind Kind { get; } + + /// + /// 目标桶名称。 + /// + protected abstract string Bucket { get; } + + /// + /// S3 服务端点,需包含协议。 + /// + protected abstract string ServiceUrl { get; } + + /// + /// 访问凭证 ID。 + /// + protected abstract string AccessKey { get; } + + /// + /// 访问凭证密钥。 + /// + protected abstract string SecretKey { get; } + + /// + /// 是否使用 HTTPS。 + /// + protected abstract bool UseHttps { get; } + + /// + /// 是否强制 PathStyle 访问。 + /// + protected abstract bool ForcePathStyle { get; } + + /// + /// CDN 域名(可选)。 + /// + protected abstract string? CdnBaseUrl { get; } + + /// + /// 默认签名有效期。 + /// + protected abstract TimeSpan SignedUrlExpiry { get; } + + /// + public virtual async Task UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default) + { + var putRequest = new PutObjectRequest + { + BucketName = Bucket, + Key = request.ObjectKey, + InputStream = request.Content, + AutoCloseStream = false, + ContentType = request.ContentType + }; + + foreach (var kv in request.Metadata) + { + putRequest.Metadata[kv.Key] = kv.Value; + } + + await Client.PutObjectAsync(putRequest, cancellationToken).ConfigureAwait(false); + + var signedUrl = request.GenerateSignedUrl + ? GenerateSignedUrl(request.ObjectKey, request.SignedUrlExpires) + : null; + + return new StorageUploadResult + { + ObjectKey = request.ObjectKey, + Url = signedUrl ?? BuildPublicUrl(request.ObjectKey), + SignedUrl = signedUrl, + FileSize = request.ContentLength, + ContentType = request.ContentType + }; + } + + /// + public virtual Task CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default) + { + var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires); + var uploadUrl = GenerateSignedUrl(request.ObjectKey, request.Expires, HttpVerb.PUT, request.ContentType); + var signedDownload = GenerateSignedUrl(request.ObjectKey, request.Expires); + + var result = new StorageDirectUploadResult + { + UploadUrl = uploadUrl, + FormFields = new Dictionary(), + ExpiresAt = expiresAt, + ObjectKey = request.ObjectKey, + SignedDownloadUrl = signedDownload + }; + + return Task.FromResult(result); + } + + /// + public virtual Task GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default) + { + var url = GenerateSignedUrl(objectKey, expires); + return Task.FromResult(url); + } + + /// + public virtual string BuildPublicUrl(string objectKey) + { + if (!string.IsNullOrWhiteSpace(CdnBaseUrl)) + { + return $"{CdnBaseUrl!.TrimEnd('/')}/{objectKey}"; + } + + var endpoint = new Uri(ServiceUrl); + var scheme = UseHttps ? "https" : "http"; + return $"{scheme}://{Bucket}.{endpoint.Host}/{objectKey}"; + } + + /// + /// 生成预签名 URL。 + /// + /// 对象键。 + /// 过期时间。 + /// HTTP 动作。 + /// 可选的内容类型约束。 + protected virtual string GenerateSignedUrl(string objectKey, TimeSpan expires, HttpVerb verb = HttpVerb.GET, string? contentType = null) + { + var request = new GetPreSignedUrlRequest + { + BucketName = Bucket, + Key = objectKey, + Verb = verb, + Expires = DateTime.UtcNow.Add(expires), + Protocol = UseHttps ? Protocol.HTTPS : Protocol.HTTP + }; + + if (!string.IsNullOrWhiteSpace(contentType)) + { + request.Headers["Content-Type"] = contentType; + } + + return Client.GetPreSignedURL(request); + } + + /// + /// 创建 S3 客户端。 + /// + protected virtual IAmazonS3 CreateClient() + { + var config = new AmazonS3Config + { + ServiceURL = ServiceUrl, + ForcePathStyle = ForcePathStyle, + UseHttp = !UseHttps, + SignatureVersion = "4" + }; + + var credentials = new BasicAWSCredentials(AccessKey, SecretKey); + return new AmazonS3Client(credentials, config); + } + + private IAmazonS3 Client => _client ??= CreateClient(); + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _client?.Dispose(); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs new file mode 100644 index 0000000..fdd7795 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Storage.Options; + +namespace TakeoutSaaS.Module.Storage.Providers; + +/// +/// 腾讯云 COS 存储提供商实现。 +/// +public sealed class TencentCosStorageProvider(IOptionsMonitor optionsMonitor) + : S3StorageProviderBase +{ + private StorageOptions CurrentOptions => optionsMonitor.CurrentValue; + + /// + public override StorageProviderKind Kind => StorageProviderKind.TencentCos; + + /// + protected override string Bucket => CurrentOptions.TencentCos.Bucket; + + /// + protected override string ServiceUrl => string.IsNullOrWhiteSpace(CurrentOptions.TencentCos.Endpoint) + ? $"{(CurrentOptions.TencentCos.UseHttps ? "https" : "http")}://cos.{CurrentOptions.TencentCos.Region}.myqcloud.com" + : CurrentOptions.TencentCos.Endpoint!; + + /// + protected override string AccessKey => CurrentOptions.TencentCos.SecretId; + + /// + protected override string SecretKey => CurrentOptions.TencentCos.SecretKey; + + /// + protected override bool UseHttps => CurrentOptions.TencentCos.UseHttps; + + /// + protected override bool ForcePathStyle => CurrentOptions.TencentCos.ForcePathStyle; + + /// + protected override string? CdnBaseUrl => !string.IsNullOrWhiteSpace(CurrentOptions.TencentCos.CdnBaseUrl) + ? CurrentOptions.TencentCos.CdnBaseUrl + : CurrentOptions.CdnBaseUrl; + + /// + protected override TimeSpan SignedUrlExpiry => + TimeSpan.FromMinutes(Math.Max(1, CurrentOptions.Security.DefaultUrlExpirationMinutes)); +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs b/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs new file mode 100644 index 0000000..598d1ee --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Storage.Abstractions; +using TakeoutSaaS.Module.Storage.Options; + +namespace TakeoutSaaS.Module.Storage.Services; + +/// +/// 存储提供商解析器,实现基于配置的提供商选择。 +/// +public sealed class StorageProviderResolver(IOptionsMonitor optionsMonitor, IEnumerable providers) + : IStorageProviderResolver +{ + private readonly IDictionary _providerMap = + providers.ToDictionary(x => x.Kind, x => x); + + /// + public IObjectStorageProvider Resolve(StorageProviderKind? provider = null) + { + var target = provider ?? optionsMonitor.CurrentValue.Provider; + if (_providerMap.TryGetValue(target, out var instance)) + { + return instance; + } + + throw new InvalidOperationException($"未注册存储提供商:{target}"); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/StorageProviderKind.cs b/src/Modules/TakeoutSaaS.Module.Storage/StorageProviderKind.cs new file mode 100644 index 0000000..589b9f8 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/StorageProviderKind.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Module.Storage; + +/// +/// 存储提供商类型枚举,便于通过配置选择具体的对象存储实现。 +/// +public enum StorageProviderKind +{ + /// + /// 腾讯云 COS 对象存储。 + /// + TencentCos = 1, + + /// + /// 七牛云 Kodo 存储。 + /// + QiniuKodo = 2, + + /// + /// 阿里云 OSS 存储。 + /// + AliyunOss = 3 +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj b/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj index b407eac..fbeba2b 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj +++ b/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj @@ -4,8 +4,16 @@ enable enable + + + + + + + + + -