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
+
+
+
+
+
+
+
+
+
-