feat: finalize core modules and gateway
This commit is contained in:
52
src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs
Normal file
52
src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台文件上传。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/files")]
|
||||
public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController
|
||||
{
|
||||
private readonly IFileStorageService _fileStorageService = fileStorageService;
|
||||
|
||||
/// <summary>
|
||||
/// 上传图片或文件。
|
||||
/// </summary>
|
||||
[HttpPost("upload")]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ApiResponse<FileUploadResponse>> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "文件不能为空");
|
||||
}
|
||||
|
||||
if (!UploadFileTypeParser.TryParse(type, out var uploadType))
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.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<FileUploadResponse>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Dictionary\TakeoutSaaS.Module.Dictionary.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Messaging\TakeoutSaaS.Module.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Scheduler\TakeoutSaaS.Module.Scheduler.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Storage\TakeoutSaaS.Module.Storage.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
142
src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json
Normal file
142
src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
52
src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs
Normal file
52
src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序文件上传。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/files")]
|
||||
public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController
|
||||
{
|
||||
private readonly IFileStorageService _fileStorageService = fileStorageService;
|
||||
|
||||
/// <summary>
|
||||
/// 上传图片或文件。
|
||||
/// </summary>
|
||||
[HttpPost("upload")]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ApiResponse<FileUploadResponse>> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "文件不能为空");
|
||||
}
|
||||
|
||||
if (!UploadFileTypeParser.TryParse(type, out var uploadType))
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.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<FileUploadResponse>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Web\TakeoutSaaS.Shared.Web.csproj" />
|
||||
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Messaging\TakeoutSaaS.Module.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Storage\TakeoutSaaS.Module.Storage.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
|
||||
|
||||
|
||||
133
src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json
Normal file
133
src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
52
src/Api/TakeoutSaaS.UserApi/appsettings.Development.json
Normal file
52
src/Api/TakeoutSaaS.UserApi/appsettings.Development.json
Normal file
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TakeoutSaaS.Application.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 领域事件发布抽象。
|
||||
/// </summary>
|
||||
public interface IEventPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// 发布领域事件。
|
||||
/// </summary>
|
||||
Task PublishAsync<TEvent>(string routingKey, TEvent @event, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 事件路由键常量。
|
||||
/// </summary>
|
||||
public static class EventRoutingKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单创建事件路由键。
|
||||
/// </summary>
|
||||
public const string OrderCreated = "orders.created";
|
||||
|
||||
/// <summary>
|
||||
/// 支付成功事件路由键。
|
||||
/// </summary>
|
||||
public const string PaymentSucceeded = "payments.succeeded";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.Messaging.Events;
|
||||
|
||||
/// <summary>
|
||||
/// 订单创建事件。
|
||||
/// </summary>
|
||||
public sealed class OrderCreatedEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单标识。
|
||||
/// </summary>
|
||||
public Guid OrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单编号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 实付金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.Messaging.Events;
|
||||
|
||||
/// <summary>
|
||||
/// 支付成功事件。
|
||||
/// </summary>
|
||||
public sealed class PaymentSucceededEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单标识。
|
||||
/// </summary>
|
||||
public Guid OrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付流水号。
|
||||
/// </summary>
|
||||
public string PaymentNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PaidAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Messaging.Abstractions;
|
||||
using TakeoutSaaS.Application.Messaging.Services;
|
||||
|
||||
namespace TakeoutSaaS.Application.Messaging.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 消息模块应用层注册。
|
||||
/// </summary>
|
||||
public static class MessagingServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册事件发布器。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddMessagingApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IEventPublisher, EventPublisher>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 事件发布适配器,封装应用层到 MQ 的发布。
|
||||
/// </summary>
|
||||
public sealed class EventPublisher(IMessagePublisher messagePublisher) : IEventPublisher
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task PublishAsync<TEvent>(string routingKey, TEvent @event, CancellationToken cancellationToken = default)
|
||||
=> messagePublisher.PublishAsync(routingKey, @event, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Sms.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Sms.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 短信验证码服务抽象。
|
||||
/// </summary>
|
||||
public interface IVerificationCodeService
|
||||
{
|
||||
/// <summary>
|
||||
/// 发送验证码。
|
||||
/// </summary>
|
||||
Task<SendVerificationCodeResponse> SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 校验验证码。
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using TakeoutSaaS.Module.Sms;
|
||||
|
||||
namespace TakeoutSaaS.Application.Sms.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 发送验证码请求。
|
||||
/// </summary>
|
||||
public sealed class SendVerificationCodeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建发送请求。
|
||||
/// </summary>
|
||||
public SendVerificationCodeRequest(string phoneNumber, string scene, SmsProviderKind? provider = null)
|
||||
{
|
||||
PhoneNumber = phoneNumber;
|
||||
Scene = scene;
|
||||
Provider = provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(支持 +86 前缀或纯 11 位)。
|
||||
/// </summary>
|
||||
public string PhoneNumber { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务场景(如 login/register/reset)。
|
||||
/// </summary>
|
||||
public string Scene { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 指定服务商,未指定则使用默认配置。
|
||||
/// </summary>
|
||||
public SmsProviderKind? Provider { get; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace TakeoutSaaS.Application.Sms.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 发送验证码响应。
|
||||
/// </summary>
|
||||
public sealed class SendVerificationCodeResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 过期时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 请求标识。
|
||||
/// </summary>
|
||||
public string? RequestId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.Sms.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 校验验证码请求。
|
||||
/// </summary>
|
||||
public sealed class VerifyVerificationCodeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建校验请求。
|
||||
/// </summary>
|
||||
public VerifyVerificationCodeRequest(string phoneNumber, string scene, string code)
|
||||
{
|
||||
PhoneNumber = phoneNumber;
|
||||
Scene = scene;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
public string PhoneNumber { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务场景。
|
||||
/// </summary>
|
||||
public string Scene { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 填写的验证码。
|
||||
/// </summary>
|
||||
public string Code { get; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 短信应用服务注册扩展。
|
||||
/// </summary>
|
||||
public static class SmsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册短信验证码应用服务。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSmsApplication(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<VerificationCodeOptions>()
|
||||
.Bind(configuration.GetSection("Sms:VerificationCode"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddScoped<IVerificationCodeService, VerificationCodeService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Sms.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 验证码发送配置。
|
||||
/// </summary>
|
||||
public sealed class VerificationCodeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证码位数,默认 6。
|
||||
/// </summary>
|
||||
[Range(4, 10)]
|
||||
public int CodeLength { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(分钟)。
|
||||
/// </summary>
|
||||
[Range(1, 60)]
|
||||
public int ExpireMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// 发送冷却时间(秒),用于防止频繁请求。
|
||||
/// </summary>
|
||||
[Range(10, 300)]
|
||||
public int CooldownSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存前缀。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string CachePrefix { get; set; } = "sms:code";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 短信验证码服务实现。
|
||||
/// </summary>
|
||||
public sealed class VerificationCodeService(
|
||||
ISmsSenderResolver senderResolver,
|
||||
IOptionsMonitor<SmsOptions> smsOptionsMonitor,
|
||||
IOptionsMonitor<VerificationCodeOptions> codeOptionsMonitor,
|
||||
ITenantProvider tenantProvider,
|
||||
IDistributedCache cache,
|
||||
ILogger<VerificationCodeService> logger) : IVerificationCodeService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SendVerificationCodeResponse> 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<string, string> { { "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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> 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, "请求过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Storage.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 文件存储应用服务抽象。
|
||||
/// </summary>
|
||||
public interface IFileStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// 通过服务端中转上传文件。
|
||||
/// </summary>
|
||||
Task<FileUploadResponse> UploadAsync(UploadFileRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 生成前端直传凭证(预签名上传)。
|
||||
/// </summary>
|
||||
Task<DirectUploadResponse> CreateDirectUploadAsync(DirectUploadRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using TakeoutSaaS.Application.Storage.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 直传凭证请求模型。
|
||||
/// </summary>
|
||||
public sealed class DirectUploadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建直传请求。
|
||||
/// </summary>
|
||||
public DirectUploadRequest(UploadFileType fileType, string fileName, string contentType, long contentLength, string? requestOrigin)
|
||||
{
|
||||
FileType = fileType;
|
||||
FileName = fileName;
|
||||
ContentType = contentType;
|
||||
ContentLength = contentLength;
|
||||
RequestOrigin = requestOrigin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 文件类型。
|
||||
/// </summary>
|
||||
public UploadFileType FileType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件长度。
|
||||
/// </summary>
|
||||
public long ContentLength { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 请求来源(Origin/Referer)。
|
||||
/// </summary>
|
||||
public string? RequestOrigin { get; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 直传凭证响应模型。
|
||||
/// </summary>
|
||||
public sealed class DirectUploadResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 预签名上传地址。
|
||||
/// </summary>
|
||||
public string UploadUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 表单直传所需字段(PUT 直传为空)。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> FormFields { get; set; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// 预签名过期时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对象键。
|
||||
/// </summary>
|
||||
public string ObjectKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 直传完成后的访问链接(包含签名)。
|
||||
/// </summary>
|
||||
public string? DownloadUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.Storage.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 上传完成后的返回模型。
|
||||
/// </summary>
|
||||
public sealed class FileUploadResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 访问 URL(已包含签名)。
|
||||
/// </summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小。
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.IO;
|
||||
using TakeoutSaaS.Application.Storage.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 上传文件请求模型。
|
||||
/// </summary>
|
||||
public sealed class UploadFileRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建上传文件请求。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 文件分类。
|
||||
/// </summary>
|
||||
public UploadFileType FileType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件流。
|
||||
/// </summary>
|
||||
public Stream Content { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小。
|
||||
/// </summary>
|
||||
public long ContentLength { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 请求来源(Origin/Referer)。
|
||||
/// </summary>
|
||||
public string? RequestOrigin { get; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.Storage.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 上传文件类型,映射业务场景。
|
||||
/// </summary>
|
||||
public enum UploadFileType
|
||||
{
|
||||
/// <summary>
|
||||
/// 菜品图片。
|
||||
/// </summary>
|
||||
DishImage = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 商户 Logo。
|
||||
/// </summary>
|
||||
MerchantLogo = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 用户头像。
|
||||
/// </summary>
|
||||
UserAvatar = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 评价图片。
|
||||
/// </summary>
|
||||
ReviewImage = 4,
|
||||
|
||||
/// <summary>
|
||||
/// 其他通用文件。
|
||||
/// </summary>
|
||||
Other = 9
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Storage.Abstractions;
|
||||
using TakeoutSaaS.Application.Storage.Services;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 存储应用服务注册扩展。
|
||||
/// </summary>
|
||||
public static class StorageServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册文件存储应用服务。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddStorageApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IFileStorageService, FileStorageService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using TakeoutSaaS.Application.Storage.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Storage.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 上传类型解析与辅助方法。
|
||||
/// </summary>
|
||||
public static class UploadFileTypeParser
|
||||
{
|
||||
/// <summary>
|
||||
/// 将字符串解析为上传类型。
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将上传类型转换为路径片段。
|
||||
/// </summary>
|
||||
public static string ToFolderName(this UploadFileType type) => type switch
|
||||
{
|
||||
UploadFileType.DishImage => "dishes",
|
||||
UploadFileType.MerchantLogo => "merchants",
|
||||
UploadFileType.UserAvatar => "users",
|
||||
UploadFileType.ReviewImage => "reviews",
|
||||
_ => "files"
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 文件存储应用服务,实现上传与直传凭证生成。
|
||||
/// </summary>
|
||||
public sealed class FileStorageService(
|
||||
IStorageProviderResolver providerResolver,
|
||||
IOptionsMonitor<StorageOptions> optionsMonitor,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<FileStorageService> logger) : IFileStorageService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FileUploadResponse> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DirectUploadResponse> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验文件大小。
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验文件后缀是否符合配置。
|
||||
/// </summary>
|
||||
private static void ValidateExtension(UploadFileType type, string extension, StorageSecurityOptions security)
|
||||
{
|
||||
var allowedImages = security.AllowedImageExtensions ?? Array.Empty<string>();
|
||||
var allowedFiles = security.AllowedFileExtensions ?? Array.Empty<string>();
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一化文件后缀(小写,默认 .bin)。
|
||||
/// </summary>
|
||||
private static string NormalizeExtension(string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName);
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
{
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
return extension.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据内容类型或后缀推断 Content-Type。
|
||||
/// </summary>
|
||||
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"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验请求来源是否在白名单内。
|
||||
/// </summary>
|
||||
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, "访问来源未在白名单中");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成对象存储的键路径。
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 组装对象元数据,便于追踪租户与用户。
|
||||
/// </summary>
|
||||
private IDictionary<string, string> BuildMetadata(UploadFileType type)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置文件流的读取位置。
|
||||
/// </summary>
|
||||
private static void ResetStream(Stream stream)
|
||||
{
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为访问链接追加防盗链签名(可配合 CDN Token 验证)。
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,12 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Messaging\TakeoutSaaS.Module.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Storage\TakeoutSaaS.Module.Storage.csproj" />
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// 数据源名称常量,统一配置键与使用。
|
||||
/// </summary>
|
||||
public static class DatabaseConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// 默认业务库(AppDatabase)。
|
||||
/// </summary>
|
||||
public const string AppDataSource = "AppDatabase";
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证库(IdentityDatabase)。
|
||||
/// </summary>
|
||||
public const string IdentityDataSource = "IdentityDatabase";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库连接角色,用于区分主写与从读连接。
|
||||
/// </summary>
|
||||
public enum DatabaseConnectionRole
|
||||
{
|
||||
/// <summary>
|
||||
/// 主写连接,用于写入或强一致读。
|
||||
/// </summary>
|
||||
Write = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 从读连接,用于只读查询或报表。
|
||||
/// </summary>
|
||||
Read = 2
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Data;
|
||||
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper 查询/命令执行器抽象,封装连接获取与读写路由。
|
||||
/// </summary>
|
||||
public interface IDapperExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用指定数据源与读写角色执行异步查询,并返回结果。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">查询结果类型。</typeparam>
|
||||
/// <param name="dataSourceName">逻辑数据源名称。</param>
|
||||
/// <param name="role">连接角色(读/写)。</param>
|
||||
/// <param name="query">查询委托,提供已打开的连接和取消标记。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>查询结果。</returns>
|
||||
Task<TResult> QueryAsync<TResult>(
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role,
|
||||
Func<IDbConnection, CancellationToken, Task<TResult>> query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定数据源与读写角色执行异步命令。
|
||||
/// </summary>
|
||||
/// <param name="dataSourceName">逻辑数据源名称。</param>
|
||||
/// <param name="role">连接角色(读/写)。</param>
|
||||
/// <param name="command">命令委托,提供已打开的连接和取消标记。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task ExecuteAsync(
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role,
|
||||
Func<IDbConnection, CancellationToken, Task> command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定数据源及角色的默认命令超时时间(秒)。
|
||||
/// </summary>
|
||||
/// <param name="dataSourceName">逻辑数据源名称。</param>
|
||||
/// <param name="role">连接角色,默认读取从库。</param>
|
||||
/// <returns>命令超时时间(秒)。</returns>
|
||||
int GetDefaultCommandTimeoutSeconds(
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role = DatabaseConnectionRole.Read);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 审计实体基类:提供创建、更新时间以及软删除时间。
|
||||
/// </summary>
|
||||
public abstract class AuditableEntityBase : EntityBase, IAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近一次更新时间(UTC),从未更新时为 null。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 软删除时间(UTC),未删除时为 null。
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建人用户标识,匿名或系统操作时为 null。
|
||||
/// </summary>
|
||||
public Guid? CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后更新人用户标识,匿名或系统操作时为 null。
|
||||
/// </summary>
|
||||
public Guid? UpdatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 删除人用户标识(软删除),未删除时为 null。
|
||||
/// </summary>
|
||||
public Guid? DeletedBy { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 实体基类,统一提供主键标识。
|
||||
/// </summary>
|
||||
public abstract class EntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 实体唯一标识。
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 审计字段接口:提供创建时间和更新时间字段。
|
||||
/// 审计字段接口:提供创建、更新、删除时间与操作者标识。
|
||||
/// </summary>
|
||||
public interface IAuditableEntity
|
||||
public interface IAuditableEntity : ISoftDeleteEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
@@ -14,5 +14,24 @@ public interface IAuditableEntity
|
||||
/// 更新时间(UTC),未更新时为 null。
|
||||
/// </summary>
|
||||
DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除时间(UTC),未删除时为 null。
|
||||
/// </summary>
|
||||
new DateTime? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建人用户标识,匿名或系统操作时为 null。
|
||||
/// </summary>
|
||||
Guid? CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后更新人用户标识,匿名或系统操作时为 null。
|
||||
/// </summary>
|
||||
Guid? UpdatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 删除人用户标识(软删除),未删除时为 null。
|
||||
/// </summary>
|
||||
Guid? DeletedBy { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 软删除实体约定:提供可空的删除时间戳以支持全局过滤。
|
||||
/// </summary>
|
||||
public interface ISoftDeleteEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 删除时间(UTC),未删除时为 null。
|
||||
/// </summary>
|
||||
DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 多租户审计实体基类:提供租户标识、审计字段与软删除标记。
|
||||
/// </summary>
|
||||
public abstract class MultiTenantEntityBase : AuditableEntityBase, IMultiTenantEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属租户 ID。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 当前用户访问器:提供与当前请求相关的用户标识信息。
|
||||
/// </summary>
|
||||
public interface ICurrentUserAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前用户 ID,未登录时为 Guid.Empty。
|
||||
/// </summary>
|
||||
Guid UserId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已登录。
|
||||
/// </summary>
|
||||
bool IsAuthenticated { get; }
|
||||
}
|
||||
@@ -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<ICurrentUserAccessor, HttpContextCurrentUserAccessor>();
|
||||
|
||||
services
|
||||
.AddControllers(options =>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Shared.Web.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 HttpContext 的当前用户访问器。
|
||||
/// </summary>
|
||||
public sealed class HttpContextCurrentUserAccessor : ICurrentUserAccessor
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化访问器。
|
||||
/// </summary>
|
||||
public HttpContextCurrentUserAccessor(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||
}
|
||||
@@ -5,20 +5,10 @@ using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典分组(系统参数/业务参数)。
|
||||
/// 参数字典分组(系统参数、业务参数)。
|
||||
/// </summary>
|
||||
public sealed class DictionaryGroup : IMultiTenantEntity, IAuditableEntity
|
||||
public sealed class DictionaryGroup : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组 ID。
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户(系统参数为 Guid.Empty)。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组编码(唯一)。
|
||||
/// </summary>
|
||||
@@ -44,16 +34,6 @@ public sealed class DictionaryGroup : IMultiTenantEntity, IAuditableEntity
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典项集合。
|
||||
/// </summary>
|
||||
|
||||
@@ -5,18 +5,8 @@ namespace TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
/// <summary>
|
||||
/// 参数字典项。
|
||||
/// </summary>
|
||||
public sealed class DictionaryItem : IMultiTenantEntity, IAuditableEntity
|
||||
public sealed class DictionaryItem : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 字典项 ID。
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联分组 ID。
|
||||
/// </summary>
|
||||
@@ -52,16 +42,6 @@ public sealed class DictionaryItem : IMultiTenantEntity, IAuditableEntity
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性:所属分组。
|
||||
/// </summary>
|
||||
|
||||
@@ -3,15 +3,10 @@ using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
namespace TakeoutSaaS.Domain.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台账户实体(平台、租户或商户员工)。
|
||||
/// 管理后台账户实体(平台管理员、租户管理员或商户员工)。
|
||||
/// </summary>
|
||||
public sealed class IdentityUser : IMultiTenantEntity
|
||||
public sealed class IdentityUser : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录账号。
|
||||
/// </summary>
|
||||
@@ -27,11 +22,6 @@ public sealed class IdentityUser : IMultiTenantEntity
|
||||
/// </summary>
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属商户(平台管理员为空)。
|
||||
/// </summary>
|
||||
|
||||
@@ -5,13 +5,8 @@ namespace TakeoutSaaS.Domain.Identity.Entities;
|
||||
/// <summary>
|
||||
/// 小程序用户实体。
|
||||
/// </summary>
|
||||
public sealed class MiniUser : IMultiTenantEntity
|
||||
public sealed class MiniUser : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信 OpenId。
|
||||
/// </summary>
|
||||
@@ -31,9 +26,4 @@ public sealed class MiniUser : IMultiTenantEntity
|
||||
/// 头像地址。
|
||||
/// </summary>
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
|
||||
@@ -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<string, DestinationConfig>
|
||||
{
|
||||
["d1"] = new() { Address = "http://localhost:5001/" }
|
||||
}
|
||||
},
|
||||
new ClusterConfig
|
||||
{
|
||||
ClusterId = "mini",
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
["d1"] = new() { Address = "http://localhost:5002/" }
|
||||
}
|
||||
},
|
||||
new ClusterConfig
|
||||
{
|
||||
ClusterId = "user",
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
["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, string>(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<IExceptionHandlerFeature>();
|
||||
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<ILoggerFactory>().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<ILoggerFactory>().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();
|
||||
|
||||
|
||||
@@ -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/" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 数据访问与多数据源相关的服务注册扩展。
|
||||
/// </summary>
|
||||
public static class DatabaseServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册数据库基础设施(多数据源配置、读写分离、Dapper 执行器)。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
public static IServiceCollection AddDatabaseInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<DatabaseOptions>()
|
||||
.Bind(configuration.GetSection(DatabaseOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IDatabaseConnectionFactory, DatabaseConnectionFactory>();
|
||||
services.AddScoped<IDapperExecutor, DapperExecutor>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为指定 DbContext 注册读写分离的 PostgreSQL 配置,同时提供读上下文工厂。
|
||||
/// </summary>
|
||||
/// <typeparam name="TContext">上下文类型。</typeparam>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="dataSourceName">逻辑数据源名称。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
public static IServiceCollection AddPostgresDbContext<TContext>(
|
||||
this IServiceCollection services,
|
||||
string dataSourceName)
|
||||
where TContext : DbContext
|
||||
{
|
||||
services.AddDbContext<TContext>((sp, options) =>
|
||||
{
|
||||
ConfigureDbContextOptions(sp, options, dataSourceName, DatabaseConnectionRole.Write);
|
||||
});
|
||||
|
||||
services.AddDbContextFactory<TContext>((sp, options) =>
|
||||
{
|
||||
ConfigureDbContextOptions(sp, options, dataSourceName, DatabaseConnectionRole.Read);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置 DbContextOptions,应用连接串、命令超时与重试策略。
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">服务提供程序。</param>
|
||||
/// <param name="optionsBuilder">上下文配置器。</param>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <param name="role">连接角色。</param>
|
||||
private static void ConfigureDbContextOptions(
|
||||
IServiceProvider serviceProvider,
|
||||
DbContextOptionsBuilder optionsBuilder,
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role)
|
||||
{
|
||||
var connection = serviceProvider
|
||||
.GetRequiredService<IDatabaseConnectionFactory>()
|
||||
.GetConnection(dataSourceName, role);
|
||||
|
||||
optionsBuilder.UseNpgsql(
|
||||
connection.ConnectionString,
|
||||
npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.CommandTimeout(connection.CommandTimeoutSeconds);
|
||||
npgsqlOptions.EnableRetryOnFailure(
|
||||
connection.MaxRetryCount,
|
||||
TimeSpan.FromSeconds(connection.MaxRetryDelaySeconds),
|
||||
null);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 单个数据源的连接配置,支持主写与多个从读。
|
||||
/// </summary>
|
||||
public sealed class DatabaseDataSourceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 主写连接串,读写分离缺省回退到此连接。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string? Write { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 从读连接串集合,可为空。
|
||||
/// </summary>
|
||||
public IList<string> Reads { get; init; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 默认命令超时(秒),未设置时使用框架默认值。
|
||||
/// </summary>
|
||||
[Range(1, 600)]
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库重试次数。
|
||||
/// </summary>
|
||||
[Range(0, 10)]
|
||||
public int MaxRetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库重试最大延迟(秒)。
|
||||
/// </summary>
|
||||
[Range(1, 60)]
|
||||
public int MaxRetryDelaySeconds { get; set; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 数据源配置集合,键为逻辑数据源名称。
|
||||
/// </summary>
|
||||
public sealed class DatabaseOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置节名称。
|
||||
/// </summary>
|
||||
public const string SectionName = "Database";
|
||||
|
||||
/// <summary>
|
||||
/// 数据源配置字典,键为数据源名称。
|
||||
/// </summary>
|
||||
public IDictionary<string, DatabaseDataSourceOptions> DataSources { get; init; } =
|
||||
new Dictionary<string, DatabaseDataSourceOptions>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定名称的数据源配置,不存在时返回 null。
|
||||
/// </summary>
|
||||
/// <param name="name">逻辑数据源名称。</param>
|
||||
/// <returns>数据源配置或 null。</returns>
|
||||
public DatabaseDataSourceOptions? Find(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DataSources.TryGetValue(name, out var options) ? options : null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 应用基础 DbContext,统一处理审计字段、软删除与全局查询过滤。
|
||||
/// </summary>
|
||||
public abstract class AppDbContext(DbContextOptions options, ICurrentUserAccessor? currentUserAccessor = null) : DbContext(options)
|
||||
{
|
||||
private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// 构建模型时应用软删除过滤器。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ApplySoftDeleteQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存更改前应用元数据填充。
|
||||
/// </summary>
|
||||
/// <returns>受影响行数。</returns>
|
||||
public override int SaveChanges()
|
||||
{
|
||||
OnBeforeSaving();
|
||||
return base.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步保存更改前应用元数据填充。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>受影响行数。</returns>
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
OnBeforeSaving();
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存前处理审计、软删除等元数据,可在子类中扩展。
|
||||
/// </summary>
|
||||
protected virtual void OnBeforeSaving()
|
||||
{
|
||||
ApplySoftDeleteMetadata();
|
||||
ApplyAuditMetadata();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将软删除实体的删除操作转换为设置 DeletedAt。
|
||||
/// </summary>
|
||||
private void ApplySoftDeleteMetadata()
|
||||
{
|
||||
var utcNow = DateTime.UtcNow;
|
||||
var actor = GetCurrentUserIdOrNull();
|
||||
foreach (var entry in ChangeTracker.Entries<ISoftDeleteEntity>())
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对审计实体填充创建与更新时间。
|
||||
/// </summary>
|
||||
private void ApplyAuditMetadata()
|
||||
{
|
||||
var utcNow = DateTime.UtcNow;
|
||||
var actor = GetCurrentUserIdOrNull();
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用软删除查询过滤器,自动排除 DeletedAt 不为 null 的记录。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置软删除查询过滤器。
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">实体类型。</typeparam>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
private void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
||||
where TEntity : class, ISoftDeleteEntity
|
||||
{
|
||||
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.DeletedAt == null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置审计字段的通用约束。
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">实体类型。</typeparam>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
protected static void ConfigureAuditableEntity<TEntity>(EntityTypeBuilder<TEntity> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置软删除字段的通用约束。
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">实体类型。</typeparam>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
protected static void ConfigureSoftDeleteEntity<TEntity>(EntityTypeBuilder<TEntity> builder)
|
||||
where TEntity : class, ISoftDeleteEntity
|
||||
{
|
||||
builder.Property(x => x.DeletedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 Dapper 的执行器实现,封装连接创建与读写分离。
|
||||
/// </summary>
|
||||
public sealed class DapperExecutor(
|
||||
IDatabaseConnectionFactory connectionFactory,
|
||||
ILogger<DapperExecutor> logger) : IDapperExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用指定数据源与读写角色执行异步查询。
|
||||
/// </summary>
|
||||
public async Task<TResult> QueryAsync<TResult>(
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role,
|
||||
Func<IDbConnection, CancellationToken, Task<TResult>> query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ExecuteAsync(
|
||||
dataSourceName,
|
||||
role,
|
||||
async (connection, token) => await query(connection, token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定数据源与读写角色执行异步命令。
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync(
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role,
|
||||
Func<IDbConnection, CancellationToken, Task> command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
dataSourceName,
|
||||
role,
|
||||
async (connection, token) =>
|
||||
{
|
||||
await command(connection, token);
|
||||
return true;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取默认命令超时时间(秒)。
|
||||
/// </summary>
|
||||
public int GetDefaultCommandTimeoutSeconds(string dataSourceName, DatabaseConnectionRole role = DatabaseConnectionRole.Read)
|
||||
{
|
||||
var details = connectionFactory.GetConnection(dataSourceName, role);
|
||||
return details.CommandTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核心执行逻辑:创建连接、打开并执行委托。
|
||||
/// </summary>
|
||||
private async Task<TResult> ExecuteAsync<TResult>(
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role,
|
||||
Func<IDbConnection, CancellationToken, Task<TResult>> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库连接信息(连接串与超时/重试设置)。
|
||||
/// </summary>
|
||||
public sealed record DatabaseConnectionDetails(
|
||||
string ConnectionString,
|
||||
int CommandTimeoutSeconds,
|
||||
int MaxRetryCount,
|
||||
int MaxRetryDelaySeconds);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库连接工厂,支持读写分离及连接配置校验。
|
||||
/// </summary>
|
||||
public sealed class DatabaseConnectionFactory(
|
||||
IOptionsMonitor<DatabaseOptions> optionsMonitor,
|
||||
IConfiguration configuration,
|
||||
ILogger<DatabaseConnectionFactory> logger) : IDatabaseConnectionFactory
|
||||
{
|
||||
private const int DefaultCommandTimeoutSeconds = 30;
|
||||
private const int DefaultMaxRetryCount = 3;
|
||||
private const int DefaultMaxRetryDelaySeconds = 5;
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定数据源与读写角色的连接信息。
|
||||
/// </summary>
|
||||
/// <param name="dataSourceName">逻辑数据源名称。</param>
|
||||
/// <param name="role">连接角色。</param>
|
||||
/// <returns>连接串与超时/重试配置。</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验数据源配置完整性。
|
||||
/// </summary>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <param name="options">数据源配置。</param>
|
||||
/// <exception cref="InvalidOperationException">配置不合法时抛出。</exception>
|
||||
private bool ValidateOptions(string dataSourceName, DatabaseDataSourceOptions options)
|
||||
{
|
||||
var results = new List<ValidationResult>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据读写角色选择连接串,从读连接随机分配。
|
||||
/// </summary>
|
||||
/// <param name="options">数据源配置。</param>
|
||||
/// <param name="role">连接角色。</param>
|
||||
/// <returns>可用连接串。</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库连接工厂,负责按读写角色选择对应连接串及配置。
|
||||
/// </summary>
|
||||
public interface IDatabaseConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定数据源与读写角色的连接信息。
|
||||
/// </summary>
|
||||
/// <param name="dataSourceName">逻辑数据源名称。</param>
|
||||
/// <param name="role">连接角色(读/写)。</param>
|
||||
/// <returns>连接串与相关配置。</returns>
|
||||
DatabaseConnectionDetails GetConnection(string dataSourceName, DatabaseConnectionRole role);
|
||||
}
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// 多租户感知 DbContext:自动应用租户过滤并填充租户字段。
|
||||
/// </summary>
|
||||
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));
|
||||
|
||||
/// <summary>
|
||||
/// 当前请求租户 ID。
|
||||
@@ -23,8 +22,18 @@ public abstract class TenantAwareDbContext : DbContext
|
||||
protected Guid CurrentTenantId => _tenantProvider.GetCurrentTenantId();
|
||||
|
||||
/// <summary>
|
||||
/// 应用租户过滤器至所有实现 <see cref="IMultiTenantEntity"/> 的实体。
|
||||
/// 保存前填充租户元数据并执行基础处理。
|
||||
/// </summary>
|
||||
protected override void OnBeforeSaving()
|
||||
{
|
||||
ApplyTenantMetadata();
|
||||
base.OnBeforeSaving();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用租户过滤器到所有实现 <see cref="IMultiTenantEntity"/> 的实体。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected void ApplyTenantQueryFilters(ModelBuilder modelBuilder)
|
||||
{
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
@@ -42,24 +51,20 @@ public abstract class TenantAwareDbContext : DbContext
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为具体实体设置租户过滤器。
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">实体类型。</typeparam>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
private void SetTenantFilter<TEntity>(ModelBuilder modelBuilder)
|
||||
where TEntity : class, IMultiTenantEntity
|
||||
{
|
||||
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.TenantId == CurrentTenantId);
|
||||
}
|
||||
|
||||
public override int SaveChanges()
|
||||
{
|
||||
ApplyTenantMetadata();
|
||||
return base.SaveChanges();
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ApplyTenantMetadata();
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为新增实体填充租户 ID。
|
||||
/// </summary>
|
||||
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<IAuditableEntity>())
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
entry.Entity.CreatedAt = utcNow;
|
||||
entry.Entity.UpdatedAt = null;
|
||||
}
|
||||
else if (entry.State == EntityState.Modified)
|
||||
{
|
||||
entry.Entity.UpdatedAt = utcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// <summary>
|
||||
/// 注册字典模块基础设施。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
/// <exception cref="InvalidOperationException">缺少数据库配置时抛出。</exception>
|
||||
public static IServiceCollection AddDictionaryInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString("AppDatabase");
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new InvalidOperationException("缺少 AppDatabase 连接字符串配置");
|
||||
}
|
||||
|
||||
services.AddDbContext<DictionaryDbContext>(options =>
|
||||
{
|
||||
options.UseNpgsql(connectionString);
|
||||
});
|
||||
services.AddDatabaseInfrastructure(configuration);
|
||||
services.AddPostgresDbContext<DictionaryDbContext>(DatabaseConstants.AppDataSource);
|
||||
|
||||
services.AddScoped<IDictionaryRepository, EfDictionaryRepository>();
|
||||
services.AddScoped<IDictionaryCache, DistributedDictionaryCache>();
|
||||
@@ -41,4 +39,15 @@ public static class DictionaryServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保数据库连接已配置(Database 节或 ConnectionStrings)。
|
||||
/// </summary>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <exception cref="InvalidOperationException">未配置时抛出。</exception>
|
||||
private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName)
|
||||
{
|
||||
// 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// 参数字典 DbContext。
|
||||
/// </summary>
|
||||
public sealed class DictionaryDbContext : TenantAwareDbContext
|
||||
public sealed class DictionaryDbContext(
|
||||
DbContextOptions<DictionaryDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor)
|
||||
{
|
||||
public DictionaryDbContext(DbContextOptions<DictionaryDbContext> options, ITenantProvider tenantProvider)
|
||||
: base(options, tenantProvider)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组集。
|
||||
/// </summary>
|
||||
public DbSet<DictionaryGroup> DictionaryGroups => Set<DictionaryGroup>();
|
||||
|
||||
/// <summary>
|
||||
/// 字典项集。
|
||||
/// </summary>
|
||||
public DbSet<DictionaryItem> DictionaryItems => Set<DictionaryItem>();
|
||||
|
||||
/// <summary>
|
||||
/// 配置实体模型。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>());
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>());
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置字典分组。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> 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<int>().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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置字典项。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证基础设施注入
|
||||
/// 身份认证基础设施注入。
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册身份认证基础设施(数据库、Redis、JWT、限流等)
|
||||
/// 注册身份认证基础设施(数据库、Redis、JWT、限流等)。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="configuration">配置源</param>
|
||||
/// <param name="enableMiniFeatures">是否启用小程序相关依赖(如微信登录)</param>
|
||||
/// <param name="enableAdminSeed">是否启用后台账号初始化</param>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <param name="enableMiniFeatures">是否启用小程序相关依赖(如微信登录)。</param>
|
||||
/// <param name="enableAdminSeed">是否启用后台账号初始化。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
/// <exception cref="InvalidOperationException">配置缺失时抛出。</exception>
|
||||
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<IdentityDbContext>(options => options.UseNpgsql(dbConnection));
|
||||
services.AddDatabaseInfrastructure(configuration);
|
||||
services.AddPostgresDbContext<IdentityDbContext>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保数据库连接已配置(Database 节或 ConnectionStrings)。
|
||||
/// </summary>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <exception cref="InvalidOperationException">未配置时抛出。</exception>
|
||||
private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName)
|
||||
{
|
||||
// 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证 DbContext,带多租户过滤。
|
||||
/// 身份认证 DbContext,带多租户过滤与审计字段处理。
|
||||
/// </summary>
|
||||
public sealed class IdentityDbContext : TenantAwareDbContext
|
||||
public sealed class IdentityDbContext(
|
||||
DbContextOptions<IdentityDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor)
|
||||
{
|
||||
public IdentityDbContext(DbContextOptions<IdentityDbContext> options, ITenantProvider tenantProvider)
|
||||
: base(options, tenantProvider)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台用户集合。
|
||||
/// </summary>
|
||||
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
|
||||
|
||||
/// <summary>
|
||||
/// 小程序用户集合。
|
||||
/// </summary>
|
||||
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
|
||||
|
||||
/// <summary>
|
||||
/// 配置实体模型。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ConfigureIdentityUser(modelBuilder.Entity<IdentityUser>());
|
||||
ConfigureMiniUser(modelBuilder.Entity<MiniUser>());
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置管理后台用户实体。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureIdentityUser(EntityTypeBuilder<IdentityUser> 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<string[], string>(
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置小程序用户实体。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureMiniUser(EntityTypeBuilder<MiniUser> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-rc.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.1" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 消息发布抽象。
|
||||
/// </summary>
|
||||
public interface IMessagePublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// 发布消息到指定路由键。
|
||||
/// </summary>
|
||||
Task PublishAsync<T>(string routingKey, T message, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 消息订阅抽象。
|
||||
/// </summary>
|
||||
public interface IMessageSubscriber : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅指定队列与路由键,处理后返回是否消费成功。
|
||||
/// </summary>
|
||||
Task SubscribeAsync<T>(string queue, string routingKey, Func<T, CancellationToken, Task<bool>> handler, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 消息队列模块注册扩展。
|
||||
/// </summary>
|
||||
public static class MessagingServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册 RabbitMQ 发布/订阅能力。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddMessagingModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<RabbitMqOptions>()
|
||||
.Bind(configuration.GetSection("RabbitMQ"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<JsonMessageSerializer>();
|
||||
services.AddSingleton<RabbitMqConnectionFactory>();
|
||||
services.AddSingleton<IMessagePublisher, RabbitMqMessagePublisher>();
|
||||
services.AddSingleton<IMessageSubscriber, RabbitMqMessageSubscriber>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Options;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ 连接与交换机配置。
|
||||
/// </summary>
|
||||
public sealed class RabbitMqOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 主机名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Host { get; set; } = "localhost";
|
||||
|
||||
/// <summary>
|
||||
/// 端口。
|
||||
/// </summary>
|
||||
[Range(1, 65535)]
|
||||
public int Port { get; set; } = 5672;
|
||||
|
||||
/// <summary>
|
||||
/// 用户名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Username { get; set; } = "guest";
|
||||
|
||||
/// <summary>
|
||||
/// 密码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Password { get; set; } = "guest";
|
||||
|
||||
/// <summary>
|
||||
/// 虚拟主机。
|
||||
/// </summary>
|
||||
public string VirtualHost { get; set; } = "/";
|
||||
|
||||
/// <summary>
|
||||
/// 默认交换机名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Exchange { get; set; } = "takeout.events";
|
||||
|
||||
/// <summary>
|
||||
/// 交换机类型,默认 topic。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "topic";
|
||||
|
||||
/// <summary>
|
||||
/// 消费预取数量。
|
||||
/// </summary>
|
||||
[Range(1, 1000)]
|
||||
public ushort PrefetchCount { get; set; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 消息 JSON 序列化器。
|
||||
/// </summary>
|
||||
public sealed class JsonMessageSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions DefaultOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 序列化消息。
|
||||
/// </summary>
|
||||
public byte[] Serialize<T>(T message) => Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message, DefaultOptions));
|
||||
|
||||
/// <summary>
|
||||
/// 反序列化消息。
|
||||
/// </summary>
|
||||
public T? Deserialize<T>(byte[] body) => JsonSerializer.Deserialize<T>(body, DefaultOptions);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using TakeoutSaaS.Module.Messaging.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Messaging.Services;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ 连接工厂封装。
|
||||
/// </summary>
|
||||
public sealed class RabbitMqConnectionFactory(IOptionsMonitor<RabbitMqOptions> optionsMonitor)
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建连接。
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ 消息发布实现。
|
||||
/// </summary>
|
||||
public sealed class RabbitMqMessagePublisher(RabbitMqConnectionFactory connectionFactory, IOptionsMonitor<RabbitMqOptions> optionsMonitor, JsonMessageSerializer serializer, ILogger<RabbitMqMessagePublisher> logger)
|
||||
: IMessagePublisher, IAsyncDisposable
|
||||
{
|
||||
private IConnection? _connection;
|
||||
private IModel? _channel;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PublishAsync<T>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放 RabbitMQ 资源。
|
||||
/// </summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_channel?.Dispose();
|
||||
_connection?.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ 消费者实现。
|
||||
/// </summary>
|
||||
public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connectionFactory, IOptionsMonitor<RabbitMqOptions> optionsMonitor, JsonMessageSerializer serializer, ILogger<RabbitMqMessageSubscriber> logger)
|
||||
: IMessageSubscriber
|
||||
{
|
||||
private IConnection? _connection;
|
||||
private IModel? _channel;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SubscribeAsync<T>(string queue, string routingKey, Func<T, CancellationToken, Task<bool>> 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<T>(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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
await Task.Run(() =>
|
||||
{
|
||||
_channel?.Dispose();
|
||||
_connection?.Dispose();
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,15 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="6.6.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 周期性任务注册抽象。
|
||||
/// </summary>
|
||||
public interface IRecurringJobRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册所有预设的周期性任务。
|
||||
/// </summary>
|
||||
Task RegisterAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 调度模块注册扩展(默认 Hangfire)。
|
||||
/// </summary>
|
||||
public static class SchedulerServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册调度模块。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSchedulerModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<SchedulerOptions>()
|
||||
.Bind(configuration.GetSection("Scheduler"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHangfire((serviceProvider, config) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerOptions>>().CurrentValue;
|
||||
config
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UsePostgreSqlStorage(options.ConnectionString);
|
||||
});
|
||||
|
||||
services.AddHangfireServer((serviceProvider, options) =>
|
||||
{
|
||||
var scheduler = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerOptions>>().CurrentValue;
|
||||
options.WorkerCount = scheduler.WorkerCount ?? options.WorkerCount;
|
||||
});
|
||||
|
||||
services.AddSingleton<IRecurringJobRegistrar, RecurringJobRegistrar>();
|
||||
services.AddHostedService<RecurringJobHostedService>();
|
||||
|
||||
services.AddScoped<OrderTimeoutJob>();
|
||||
services.AddScoped<CouponExpireJob>();
|
||||
services.AddScoped<LogCleanupJob>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用 Hangfire Dashboard(默认关闭,可通过配置开启)。
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseSchedulerDashboard(this IApplicationBuilder app, IConfiguration configuration)
|
||||
{
|
||||
var options = configuration.GetSection("Scheduler").Get<SchedulerOptions>();
|
||||
if (options is { DashboardEnabled: true })
|
||||
{
|
||||
app.UseHangfireDashboard(options.DashboardPath);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Module.Scheduler.Abstractions;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.HostedServices;
|
||||
|
||||
/// <summary>
|
||||
/// 启动时注册周期性任务的宿主服务。
|
||||
/// </summary>
|
||||
public sealed class RecurringJobHostedService(IRecurringJobRegistrar registrar, ILogger<RecurringJobHostedService> logger) : IHostedService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await registrar.RegisterAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("调度任务已注册");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券过期处理任务(占位实现)。
|
||||
/// </summary>
|
||||
public sealed class CouponExpireJob(ILogger<CouponExpireJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行优惠券过期清理。
|
||||
/// </summary>
|
||||
public Task ExecuteAsync()
|
||||
{
|
||||
logger.LogInformation("定时任务:处理已过期优惠券(占位实现)");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 日志清理任务(占位实现)。
|
||||
/// </summary>
|
||||
public sealed class LogCleanupJob(ILogger<LogCleanupJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行日志清理。
|
||||
/// </summary>
|
||||
public Task ExecuteAsync()
|
||||
{
|
||||
logger.LogInformation("定时任务:清理历史日志(占位实现)");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 订单超时取消任务(占位,后续接入订单服务)。
|
||||
/// </summary>
|
||||
public sealed class OrderTimeoutJob(ILogger<OrderTimeoutJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行超时订单检查。
|
||||
/// </summary>
|
||||
public Task ExecuteAsync()
|
||||
{
|
||||
logger.LogInformation("定时任务:检查超时未支付订单并取消(占位实现)");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 调度模块配置。
|
||||
/// </summary>
|
||||
public sealed class SchedulerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Hangfire 存储使用的连接字符串。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 工作线程数,默认根据 CPU 计算。
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int? WorkerCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用 Dashboard(默认 false,待 AdminUI 接入)。
|
||||
/// </summary>
|
||||
public bool DashboardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard 路径。
|
||||
/// </summary>
|
||||
public string DashboardPath { get; set; } = "/hangfire";
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Hangfire;
|
||||
using TakeoutSaaS.Module.Scheduler.Abstractions;
|
||||
using TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 周期性任务注册器。
|
||||
/// </summary>
|
||||
public sealed class RecurringJobRegistrar : IRecurringJobRegistrar
|
||||
{
|
||||
private readonly OrderTimeoutJob _orderTimeoutJob;
|
||||
private readonly CouponExpireJob _couponExpireJob;
|
||||
private readonly LogCleanupJob _logCleanupJob;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化注册器。
|
||||
/// </summary>
|
||||
public RecurringJobRegistrar(
|
||||
OrderTimeoutJob orderTimeoutJob,
|
||||
CouponExpireJob couponExpireJob,
|
||||
LogCleanupJob logCleanupJob)
|
||||
{
|
||||
_orderTimeoutJob = orderTimeoutJob;
|
||||
_couponExpireJob = couponExpireJob;
|
||||
_logCleanupJob = logCleanupJob;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,16 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
|
||||
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Module.Sms.Models;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 短信发送抽象。
|
||||
/// </summary>
|
||||
public interface ISmsSender
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务商类型。
|
||||
/// </summary>
|
||||
SmsProviderKind Provider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送短信。
|
||||
/// </summary>
|
||||
Task<SmsSendResult> SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Module.Sms.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 短信服务商解析器。
|
||||
/// </summary>
|
||||
public interface ISmsSenderResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定服务商的发送器。
|
||||
/// </summary>
|
||||
ISmsSender Resolve(SmsProviderKind? provider = null);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 短信模块 DI 注册扩展。
|
||||
/// </summary>
|
||||
public static class SmsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册短信模块(包含腾讯云、阿里云实现)。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSmsModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<SmsOptions>()
|
||||
.Bind(configuration.GetSection("Sms"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHttpClient(nameof(TencentSmsSender));
|
||||
services.AddHttpClient(nameof(AliyunSmsSender));
|
||||
|
||||
services.AddSingleton<ISmsSender, TencentSmsSender>();
|
||||
services.AddSingleton<ISmsSender, AliyunSmsSender>();
|
||||
services.AddSingleton<ISmsSenderResolver, SmsSenderResolver>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
44
src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs
Normal file
44
src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 短信发送请求。
|
||||
/// </summary>
|
||||
public sealed class SmsSendRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化短信发送请求。
|
||||
/// </summary>
|
||||
/// <param name="phoneNumber">目标手机号码(含国家码,如 +86xxxxxxxxxxx)。</param>
|
||||
/// <param name="templateCode">模版编号。</param>
|
||||
/// <param name="variables">模版变量。</param>
|
||||
/// <param name="signName">短信签名。</param>
|
||||
public SmsSendRequest(string phoneNumber, string templateCode, IDictionary<string, string> variables, string? signName = null)
|
||||
{
|
||||
PhoneNumber = phoneNumber;
|
||||
TemplateCode = templateCode;
|
||||
Variables = new Dictionary<string, string>(variables);
|
||||
SignName = signName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目标手机号。
|
||||
/// </summary>
|
||||
public string PhoneNumber { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 模版编号。
|
||||
/// </summary>
|
||||
public string TemplateCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 模版变量。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Variables { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选的签名。
|
||||
/// </summary>
|
||||
public string? SignName { get; }
|
||||
}
|
||||
22
src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs
Normal file
22
src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Module.Sms.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 短信发送结果。
|
||||
/// </summary>
|
||||
public sealed class SmsSendResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否发送成功。
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 平台返回的请求标识。
|
||||
/// </summary>
|
||||
public string? RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云短信配置。
|
||||
/// </summary>
|
||||
public sealed class AliyunSmsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// AccessKeyId。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AccessKeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// AccessKeySecret。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AccessKeySecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 短信服务域名。
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = "dysmsapi.aliyuncs.com";
|
||||
|
||||
/// <summary>
|
||||
/// 默认签名。
|
||||
/// </summary>
|
||||
public string? SignName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地域 ID。
|
||||
/// </summary>
|
||||
public string Region { get; set; } = "cn-hangzhou";
|
||||
}
|
||||
43
src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs
Normal file
43
src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Module.Sms;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 短信模块配置。
|
||||
/// </summary>
|
||||
public sealed class SmsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 默认服务商,默认为腾讯云。
|
||||
/// </summary>
|
||||
public SmsProviderKind Provider { get; set; } = SmsProviderKind.Tencent;
|
||||
|
||||
/// <summary>
|
||||
/// 默认签名。
|
||||
/// </summary>
|
||||
public string? DefaultSignName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用模拟发送(仅日志,不实际调用),方便开发环境。
|
||||
/// </summary>
|
||||
public bool UseMock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云短信配置。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public TencentSmsOptions Tencent { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云短信配置。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AliyunSmsOptions Aliyun { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 场景与模板映射(如 login: TEMPLATE_ID)。
|
||||
/// </summary>
|
||||
public Dictionary<string, string> SceneTemplates { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Module.Sms.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云短信配置。
|
||||
/// </summary>
|
||||
public sealed class TencentSmsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// SecretId。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SecretId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SecretKey。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 应用 SdkAppId。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SdkAppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 默认签名。
|
||||
/// </summary>
|
||||
public string? SignName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 默认地域。
|
||||
/// </summary>
|
||||
public string Region { get; set; } = "ap-guangzhou";
|
||||
|
||||
/// <summary>
|
||||
/// 接口域名。
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = "https://sms.tencentcloudapi.com";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云短信发送实现(简化版,占位可扩展正式签名流程)。
|
||||
/// </summary>
|
||||
public sealed class AliyunSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor<SmsOptions> optionsMonitor, ILogger<AliyunSmsSender> logger)
|
||||
: ISmsSender
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SmsProviderKind Provider => SmsProviderKind.Aliyun;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SmsSendResult> 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" });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 短信服务商解析器。
|
||||
/// </summary>
|
||||
public sealed class SmsSenderResolver(IOptionsMonitor<SmsOptions> optionsMonitor, IEnumerable<ISmsSender> senders) : ISmsSenderResolver
|
||||
{
|
||||
private readonly IReadOnlyDictionary<SmsProviderKind, ISmsSender> _map = senders.ToDictionary(x => x.Provider);
|
||||
|
||||
/// <inheritdoc />
|
||||
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}");
|
||||
}
|
||||
}
|
||||
136
src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs
Normal file
136
src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云短信发送实现(TC3-HMAC 签名)。
|
||||
/// </summary>
|
||||
public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor<SmsOptions> optionsMonitor, ILogger<TencentSmsSender> logger)
|
||||
: ISmsSender
|
||||
{
|
||||
private const string Service = "sms";
|
||||
private const string Action = "SendSms";
|
||||
private const string Version = "2021-01-11";
|
||||
|
||||
/// <inheritdoc />
|
||||
public SmsProviderKind Provider => SmsProviderKind.Tencent;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SmsSendResult> 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();
|
||||
}
|
||||
}
|
||||
17
src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs
Normal file
17
src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Module.Sms;
|
||||
|
||||
/// <summary>
|
||||
/// 短信服务商类型。
|
||||
/// </summary>
|
||||
public enum SmsProviderKind
|
||||
{
|
||||
/// <summary>
|
||||
/// 腾讯云短信。
|
||||
/// </summary>
|
||||
Tencent = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云短信。
|
||||
/// </summary>
|
||||
Aliyun = 2
|
||||
}
|
||||
@@ -4,8 +4,16 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 对象存储提供商统一抽象。
|
||||
/// </summary>
|
||||
public interface IObjectStorageProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前提供商类型。
|
||||
/// </summary>
|
||||
StorageProviderKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传文件到对象存储。
|
||||
/// </summary>
|
||||
Task<StorageUploadResult> UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 生成预签名直传参数(PUT 或表单直传)。
|
||||
/// </summary>
|
||||
Task<StorageDirectUploadResult> CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 生成带过期时间的访问链接。
|
||||
/// </summary>
|
||||
Task<string> GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 生成公共访问地址(可结合 CDN)。
|
||||
/// </summary>
|
||||
string BuildPublicUrl(string objectKey);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TakeoutSaaS.Module.Storage.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 存储提供商解析器,用于按需选择具体实现。
|
||||
/// </summary>
|
||||
public interface IStorageProviderResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据配置解析出可用的存储提供商。
|
||||
/// </summary>
|
||||
/// <param name="provider">目标提供商类型,空则使用默认配置。</param>
|
||||
/// <returns>对应的存储提供商。</returns>
|
||||
IObjectStorageProvider Resolve(StorageProviderKind? provider = null);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 存储模块服务注册扩展。
|
||||
/// </summary>
|
||||
public static class StorageServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册存储模块所需的提供商与配置。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
public static IServiceCollection AddStorageModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<StorageOptions>()
|
||||
.Bind(configuration.GetSection("Storage"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IObjectStorageProvider, TencentCosStorageProvider>();
|
||||
services.AddSingleton<IObjectStorageProvider, QiniuKodoStorageProvider>();
|
||||
services.AddSingleton<IObjectStorageProvider, AliyunOssStorageProvider>();
|
||||
services.AddSingleton<IStorageProviderResolver, StorageProviderResolver>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 直传(预签名上传)请求参数。
|
||||
/// </summary>
|
||||
public sealed class StorageDirectUploadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化请求。
|
||||
/// </summary>
|
||||
/// <param name="objectKey">对象键。</param>
|
||||
/// <param name="contentType">内容类型。</param>
|
||||
/// <param name="contentLength">内容长度。</param>
|
||||
/// <param name="expires">签名有效期。</param>
|
||||
public StorageDirectUploadRequest(string objectKey, string contentType, long contentLength, TimeSpan expires)
|
||||
{
|
||||
ObjectKey = objectKey;
|
||||
ContentType = contentType;
|
||||
ContentLength = contentLength;
|
||||
Expires = expires;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目标对象键。
|
||||
/// </summary>
|
||||
public string ObjectKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容长度。
|
||||
/// </summary>
|
||||
public long ContentLength { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 签名有效期。
|
||||
/// </summary>
|
||||
public TimeSpan Expires { get; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 直传(预签名上传)结果。
|
||||
/// </summary>
|
||||
public sealed class StorageDirectUploadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 预签名上传地址(PUT 上传或表单地址)。
|
||||
/// </summary>
|
||||
public string UploadUrl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 直传附加字段(如表单直传所需字段),PUT 方式为空。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> FormFields { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// 预签名过期时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联的对象键。
|
||||
/// </summary>
|
||||
public string ObjectKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 上传成功后可选的签名下载地址。
|
||||
/// </summary>
|
||||
public string? SignedDownloadUrl { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 对象存储上传请求参数。
|
||||
/// </summary>
|
||||
public sealed class StorageUploadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化上传请求。
|
||||
/// </summary>
|
||||
/// <param name="objectKey">对象键(含路径)。</param>
|
||||
/// <param name="content">文件流。</param>
|
||||
/// <param name="contentType">内容类型。</param>
|
||||
/// <param name="contentLength">内容长度。</param>
|
||||
/// <param name="generateSignedUrl">是否返回签名访问链接。</param>
|
||||
/// <param name="signedUrlExpires">签名有效期。</param>
|
||||
/// <param name="metadata">附加元数据。</param>
|
||||
public StorageUploadRequest(
|
||||
string objectKey,
|
||||
Stream content,
|
||||
string contentType,
|
||||
long contentLength,
|
||||
bool generateSignedUrl,
|
||||
TimeSpan signedUrlExpires,
|
||||
IDictionary<string, string>? metadata = null)
|
||||
{
|
||||
ObjectKey = objectKey;
|
||||
Content = content;
|
||||
ContentType = contentType;
|
||||
ContentLength = contentLength;
|
||||
GenerateSignedUrl = generateSignedUrl;
|
||||
SignedUrlExpires = signedUrlExpires;
|
||||
Metadata = metadata == null
|
||||
? new Dictionary<string, string>()
|
||||
: new Dictionary<string, string>(metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对象键。
|
||||
/// </summary>
|
||||
public string ObjectKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件流。
|
||||
/// </summary>
|
||||
public Stream Content { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容长度。
|
||||
/// </summary>
|
||||
public long ContentLength { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否需要签名访问链接。
|
||||
/// </summary>
|
||||
public bool GenerateSignedUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 签名有效期。
|
||||
/// </summary>
|
||||
public TimeSpan SignedUrlExpires { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 元数据集合。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user