diff --git a/Document/10_TODO.md b/Document/10_TODO.md index 9190463..ddb4c41 100644 --- a/Document/10_TODO.md +++ b/Document/10_TODO.md @@ -17,9 +17,9 @@ - [x] 登录防刷限流(MiniApi) ## C. 多租户与参数字典 -- [ ] 多租户中间件:从 Header/域名解析租户(Shared.Web + Tenancy) -- [ ] EF Core 全局查询过滤(tenant_id) -- [ ] 参数字典模块(系统参数/业务参数)CRUD 与缓存(Dictionary 模块) +- [x] 多租户中间件:从 Header/域名解析租户(Shared.Web + Tenancy) +- [x] EF Core 全局查询过滤(tenant_id) +- [x] 参数字典模块(系统参数/业务参数)CRUD 与缓存(Dictionary 模块) ## D. 数据访问与多数据源 - [ ] EF Core 10 基础上下文、实体基类、审计字段 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs new file mode 100644 index 0000000..7232ed2 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs @@ -0,0 +1,126 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.Dictionary.Abstractions; +using TakeoutSaaS.Application.Dictionary.Contracts; +using TakeoutSaaS.Application.Dictionary.Models; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 参数字典管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/dictionaries")] +public sealed class DictionaryController : BaseApiController +{ + private readonly IDictionaryAppService _dictionaryAppService; + + /// + /// 初始化字典控制器。 + /// + /// 字典服务 + public DictionaryController(IDictionaryAppService dictionaryAppService) + { + _dictionaryAppService = dictionaryAppService; + } + + /// + /// 查询字典分组。 + /// + [HttpGet] + [PermissionAuthorize("dictionary:group:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetGroups([FromQuery] DictionaryGroupQuery query, CancellationToken cancellationToken) + { + var groups = await _dictionaryAppService.SearchGroupsAsync(query, cancellationToken); + return ApiResponse>.Ok(groups); + } + + /// + /// 创建字典分组。 + /// + [HttpPost] + [PermissionAuthorize("dictionary:group:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateGroup([FromBody] CreateDictionaryGroupRequest request, CancellationToken cancellationToken) + { + var group = await _dictionaryAppService.CreateGroupAsync(request, cancellationToken); + return ApiResponse.Ok(group); + } + + /// + /// 更新字典分组。 + /// + [HttpPut("{groupId:guid}")] + [PermissionAuthorize("dictionary:group:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpdateGroup(Guid groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken) + { + var group = await _dictionaryAppService.UpdateGroupAsync(groupId, request, cancellationToken); + return ApiResponse.Ok(group); + } + + /// + /// 删除字典分组。 + /// + [HttpDelete("{groupId:guid}")] + [PermissionAuthorize("dictionary:group:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteGroup(Guid groupId, CancellationToken cancellationToken) + { + await _dictionaryAppService.DeleteGroupAsync(groupId, cancellationToken); + return ApiResponse.Success(); + } + + /// + /// 创建字典项。 + /// + [HttpPost("{groupId:guid}/items")] + [PermissionAuthorize("dictionary:item:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateItem(Guid groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken) + { + request.GroupId = groupId; + var item = await _dictionaryAppService.CreateItemAsync(request, cancellationToken); + return ApiResponse.Ok(item); + } + + /// + /// 更新字典项。 + /// + [HttpPut("items/{itemId:guid}")] + [PermissionAuthorize("dictionary:item:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpdateItem(Guid itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken) + { + var item = await _dictionaryAppService.UpdateItemAsync(itemId, request, cancellationToken); + return ApiResponse.Ok(item); + } + + /// + /// 删除字典项。 + /// + [HttpDelete("items/{itemId:guid}")] + [PermissionAuthorize("dictionary:item:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteItem(Guid itemId, CancellationToken cancellationToken) + { + await _dictionaryAppService.DeleteItemAsync(itemId, cancellationToken); + return ApiResponse.Success(); + } + + /// + /// 批量获取字典项(命中缓存)。 + /// + [HttpPost("batch")] + [ProducesResponseType(typeof(ApiResponse>>), StatusCodes.Status200OK)] + public async Task>>> BatchGet([FromBody] DictionaryBatchQueryRequest request, CancellationToken cancellationToken) + { + var dictionaries = await _dictionaryAppService.GetCachedItemsAsync(request, cancellationToken); + return ApiResponse>>.Ok(dictionaries); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs index 5359f9f..c506c0c 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Program.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs @@ -9,8 +9,8 @@ using Serilog; using TakeoutSaaS.Application.Identity.Extensions; using TakeoutSaaS.Infrastructure.Identity.Extensions; using TakeoutSaaS.Module.Authorization.Extensions; -using TakeoutSaaS.Module.Tenancy; -using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Module.Dictionary.Extensions; +using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; @@ -36,6 +36,8 @@ builder.Services.AddIdentityInfrastructure(builder.Configuration, enableAdminSee builder.Services.AddJwtAuthentication(builder.Configuration); builder.Services.AddAuthorization(); builder.Services.AddPermissionAuthorization(); +builder.Services.AddTenantResolution(builder.Configuration); +builder.Services.AddDictionaryModule(builder.Configuration); var adminOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Admin"); builder.Services.AddCors(options => @@ -46,11 +48,10 @@ builder.Services.AddCors(options => }); }); -builder.Services.AddScoped(); - var app = builder.Build(); app.UseCors("AdminApiCors"); +app.UseTenantResolution(); app.UseSharedWebCore(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj b/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj index d4c9b45..c1f701a 100644 --- a/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj +++ b/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs index fac60c8..fd0ebd4 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Program.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Cors.Infrastructure; using Serilog; -using TakeoutSaaS.Module.Tenancy; -using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; @@ -22,6 +21,7 @@ builder.Services.AddSharedSwagger(options => options.Description = "小程序 API 文档"; options.EnableAuthorization = true; }); +builder.Services.AddTenantResolution(builder.Configuration); var miniOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Mini"); builder.Services.AddCors(options => @@ -32,11 +32,10 @@ builder.Services.AddCors(options => }); }); -builder.Services.AddScoped(); - var app = builder.Build(); app.UseCors("MiniApiCors"); +app.UseTenantResolution(); app.UseSharedWebCore(); app.UseSharedSwagger(); diff --git a/src/Api/TakeoutSaaS.UserApi/Program.cs b/src/Api/TakeoutSaaS.UserApi/Program.cs index d43a488..ddfa3af 100644 --- a/src/Api/TakeoutSaaS.UserApi/Program.cs +++ b/src/Api/TakeoutSaaS.UserApi/Program.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Cors.Infrastructure; using Serilog; -using TakeoutSaaS.Module.Tenancy; -using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; @@ -22,6 +21,7 @@ builder.Services.AddSharedSwagger(options => options.Description = "C 端用户 API 文档"; options.EnableAuthorization = true; }); +builder.Services.AddTenantResolution(builder.Configuration); var userOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:User"); builder.Services.AddCors(options => @@ -32,11 +32,10 @@ builder.Services.AddCors(options => }); }); -builder.Services.AddScoped(); - var app = builder.Build(); app.UseCors("UserApiCors"); +app.UseTenantResolution(); app.UseSharedWebCore(); app.UseSharedSwagger(); diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryAppService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryAppService.cs new file mode 100644 index 0000000..dc51fe3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryAppService.cs @@ -0,0 +1,26 @@ +using TakeoutSaaS.Application.Dictionary.Contracts; +using TakeoutSaaS.Application.Dictionary.Models; + +namespace TakeoutSaaS.Application.Dictionary.Abstractions; + +/// +/// 参数字典应用服务接口。 +/// +public interface IDictionaryAppService +{ + Task CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default); + + Task UpdateGroupAsync(Guid groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default); + + Task DeleteGroupAsync(Guid groupId, CancellationToken cancellationToken = default); + + Task> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default); + + Task CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default); + + Task UpdateItemAsync(Guid itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default); + + Task DeleteItemAsync(Guid itemId, CancellationToken cancellationToken = default); + + Task>> GetCachedItemsAsync(DictionaryBatchQueryRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs new file mode 100644 index 0000000..4bbf169 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Dictionary.Models; + +namespace TakeoutSaaS.Application.Dictionary.Abstractions; + +/// +/// 字典缓存读写接口。 +/// +public interface IDictionaryCache +{ + /// + /// 获取缓存。 + /// + Task?> GetAsync(Guid tenantId, string code, CancellationToken cancellationToken = default); + + /// + /// 写入缓存。 + /// + Task SetAsync(Guid tenantId, string code, IReadOnlyList items, CancellationToken cancellationToken = default); + + /// + /// 移除缓存。 + /// + Task RemoveAsync(Guid tenantId, string code, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryGroupRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryGroupRequest.cs new file mode 100644 index 0000000..10454ff --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryGroupRequest.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 创建字典分组请求。 +/// +public sealed class CreateDictionaryGroupRequest +{ + /// + /// 分组编码。 + /// + [Required, MaxLength(64)] + public string Code { get; set; } = string.Empty; + + /// + /// 分组名称。 + /// + [Required, MaxLength(128)] + public string Name { get; set; } = string.Empty; + + /// + /// 作用域:系统/业务。 + /// + [Required] + public DictionaryScope Scope { get; set; } = DictionaryScope.Business; + + /// + /// 描述信息。 + /// + [MaxLength(512)] + public string? Description { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs new file mode 100644 index 0000000..553401a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 创建字典项请求。 +/// +public sealed class CreateDictionaryItemRequest +{ + /// + /// 所属分组 ID。 + /// + [Required] + public Guid GroupId { get; set; } + + /// + /// 字典项键。 + /// + [Required, MaxLength(64)] + public string Key { get; set; } = string.Empty; + + /// + /// 字典项值。 + /// + [Required, MaxLength(256)] + public string Value { get; set; } = string.Empty; + + /// + /// 是否默认项。 + /// + public bool IsDefault { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; + + /// + /// 描述信息。 + /// + [MaxLength(512)] + public string? Description { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryBatchQueryRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryBatchQueryRequest.cs new file mode 100644 index 0000000..cc5e2c6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryBatchQueryRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 批量查询字典项请求。 +/// +public sealed class DictionaryBatchQueryRequest +{ + /// + /// 分组编码集合。 + /// + [Required] + public IReadOnlyCollection Codes { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryGroupQuery.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryGroupQuery.cs new file mode 100644 index 0000000..861b082 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryGroupQuery.cs @@ -0,0 +1,19 @@ +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 字典分组查询参数。 +/// +public sealed class DictionaryGroupQuery +{ + /// + /// 作用域过滤。 + /// + public DictionaryScope? Scope { get; set; } + + /// + /// 是否包含字典项。 + /// + public bool IncludeItems { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs new file mode 100644 index 0000000..4ed0fdc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 更新字典分组请求。 +/// +public sealed class UpdateDictionaryGroupRequest +{ + /// + /// 分组名称。 + /// + [Required, MaxLength(128)] + public string Name { get; set; } = string.Empty; + + /// + /// 描述信息。 + /// + [MaxLength(512)] + public string? Description { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs new file mode 100644 index 0000000..f2c9871 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 更新字典项请求。 +/// +public sealed class UpdateDictionaryItemRequest +{ + /// + /// 字典项值。 + /// + [Required, MaxLength(256)] + public string Value { get; set; } = string.Empty; + + /// + /// 是否默认项。 + /// + public bool IsDefault { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; + + /// + /// 描述信息。 + /// + [MaxLength(512)] + public string? Description { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs new file mode 100644 index 0000000..5b03aa0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Dictionary.Abstractions; +using TakeoutSaaS.Application.Dictionary.Services; + +namespace TakeoutSaaS.Application.Dictionary.Extensions; + +/// +/// 字典应用服务注册扩展。 +/// +public static class DictionaryServiceCollectionExtensions +{ + /// + /// 注册字典模块应用层组件。 + /// + public static IServiceCollection AddDictionaryApplication(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs new file mode 100644 index 0000000..528167f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs @@ -0,0 +1,23 @@ +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Application.Dictionary.Models; + +/// +/// 字典分组 DTO。 +/// +public sealed class DictionaryGroupDto +{ + public Guid Id { get; init; } + + public string Code { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public DictionaryScope Scope { get; init; } + + public string? Description { get; init; } + + public bool IsEnabled { get; init; } + + public IReadOnlyList Items { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs new file mode 100644 index 0000000..89faaf7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs @@ -0,0 +1,23 @@ +namespace TakeoutSaaS.Application.Dictionary.Models; + +/// +/// 字典项 DTO。 +/// +public sealed class DictionaryItemDto +{ + public Guid Id { get; init; } + + public Guid GroupId { get; init; } + + public string Key { get; init; } = string.Empty; + + public string Value { get; init; } = string.Empty; + + public bool IsDefault { get; init; } + + public bool IsEnabled { get; init; } + + public int SortOrder { get; init; } + + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs new file mode 100644 index 0000000..93d92c2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs @@ -0,0 +1,344 @@ +using System.Linq; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.Dictionary.Abstractions; +using TakeoutSaaS.Application.Dictionary.Contracts; +using TakeoutSaaS.Application.Dictionary.Models; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Dictionary.Services; + +/// +/// 参数字典应用服务实现。 +/// +public sealed class DictionaryAppService : IDictionaryAppService +{ + private readonly IDictionaryRepository _repository; + private readonly IDictionaryCache _cache; + private readonly ITenantProvider _tenantProvider; + private readonly ILogger _logger; + + public DictionaryAppService( + IDictionaryRepository repository, + IDictionaryCache cache, + ITenantProvider tenantProvider, + ILogger logger) + { + _repository = repository; + _cache = cache; + _tenantProvider = tenantProvider; + _logger = logger; + } + + public async Task CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default) + { + var normalizedCode = NormalizeCode(request.Code); + var targetTenant = ResolveTargetTenant(request.Scope); + + var existing = await _repository.FindGroupByCodeAsync(normalizedCode, cancellationToken); + if (existing != null) + { + throw new BusinessException(ErrorCodes.Conflict, $"字典分组编码 {normalizedCode} 已存在"); + } + + var group = new DictionaryGroup + { + Id = Guid.NewGuid(), + TenantId = targetTenant, + Code = normalizedCode, + Name = request.Name.Trim(), + Scope = request.Scope, + Description = request.Description?.Trim(), + IsEnabled = true + }; + + await _repository.AddGroupAsync(group, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建字典分组:{Code}({Scope})", group.Code, group.Scope); + return MapGroup(group, includeItems: false); + } + + public async Task UpdateGroupAsync(Guid groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default) + { + var group = await RequireGroupAsync(groupId, cancellationToken); + EnsureScopePermission(group.Scope); + + group.Name = request.Name.Trim(); + group.Description = request.Description?.Trim(); + group.IsEnabled = request.IsEnabled; + + await _repository.SaveChangesAsync(cancellationToken); + await InvalidateCacheAsync(group, cancellationToken); + _logger.LogInformation("更新字典分组:{GroupId}", group.Id); + return MapGroup(group, includeItems: false); + } + + public async Task DeleteGroupAsync(Guid groupId, CancellationToken cancellationToken = default) + { + var group = await RequireGroupAsync(groupId, cancellationToken); + EnsureScopePermission(group.Scope); + + await _repository.RemoveGroupAsync(group, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + await InvalidateCacheAsync(group, cancellationToken); + _logger.LogInformation("删除字典分组:{GroupId}", group.Id); + } + + public async Task> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var scope = ResolveScopeForQuery(request.Scope, tenantId); + EnsureScopePermission(scope); + + var groups = await _repository.SearchGroupsAsync(scope, cancellationToken); + var includeItems = request.IncludeItems; + var result = new List(groups.Count); + + foreach (var group in groups) + { + IReadOnlyList items = Array.Empty(); + if (includeItems) + { + var itemEntities = await _repository.GetItemsByGroupIdAsync(group.Id, cancellationToken); + items = itemEntities.Select(MapItem).ToList(); + } + + result.Add(MapGroup(group, includeItems, items)); + } + + return result; + } + + public async Task CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default) + { + var group = await RequireGroupAsync(request.GroupId, cancellationToken); + EnsureScopePermission(group.Scope); + + var item = new DictionaryItem + { + Id = Guid.NewGuid(), + TenantId = group.TenantId, + GroupId = group.Id, + Key = request.Key.Trim(), + Value = request.Value.Trim(), + Description = request.Description?.Trim(), + SortOrder = request.SortOrder, + IsDefault = request.IsDefault, + IsEnabled = request.IsEnabled + }; + + await _repository.AddItemAsync(item, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + await InvalidateCacheAsync(group, cancellationToken); + _logger.LogInformation("新增字典项:{ItemId}", item.Id); + return MapItem(item); + } + + public async Task UpdateItemAsync(Guid itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default) + { + var item = await RequireItemAsync(itemId, cancellationToken); + var group = await RequireGroupAsync(item.GroupId, cancellationToken); + EnsureScopePermission(group.Scope); + + item.Value = request.Value.Trim(); + item.Description = request.Description?.Trim(); + item.SortOrder = request.SortOrder; + item.IsDefault = request.IsDefault; + item.IsEnabled = request.IsEnabled; + + await _repository.SaveChangesAsync(cancellationToken); + await InvalidateCacheAsync(group, cancellationToken); + _logger.LogInformation("更新字典项:{ItemId}", item.Id); + return MapItem(item); + } + + public async Task DeleteItemAsync(Guid itemId, CancellationToken cancellationToken = default) + { + var item = await RequireItemAsync(itemId, cancellationToken); + var group = await RequireGroupAsync(item.GroupId, cancellationToken); + EnsureScopePermission(group.Scope); + + await _repository.RemoveItemAsync(item, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + await InvalidateCacheAsync(group, cancellationToken); + _logger.LogInformation("删除字典项:{ItemId}", item.Id); + } + + public async Task>> GetCachedItemsAsync(DictionaryBatchQueryRequest request, CancellationToken cancellationToken = default) + { + var normalizedCodes = request.Codes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(NormalizeCode) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (normalizedCodes.Length == 0) + { + return new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + + var tenantId = _tenantProvider.GetCurrentTenantId(); + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var code in normalizedCodes) + { + var systemItems = await GetOrLoadCacheAsync(Guid.Empty, code, cancellationToken); + if (tenantId == Guid.Empty) + { + result[code] = systemItems; + continue; + } + + var tenantItems = await GetOrLoadCacheAsync(tenantId, code, cancellationToken); + result[code] = MergeItems(systemItems, tenantItems); + } + + return result; + } + + private async Task RequireGroupAsync(Guid groupId, CancellationToken cancellationToken) + { + var group = await _repository.FindGroupByIdAsync(groupId, cancellationToken); + if (group == null) + { + throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在"); + } + + return group; + } + + private async Task RequireItemAsync(Guid itemId, CancellationToken cancellationToken) + { + var item = await _repository.FindItemByIdAsync(itemId, cancellationToken); + if (item == null) + { + throw new BusinessException(ErrorCodes.NotFound, "字典项不存在"); + } + + return item; + } + + private Guid ResolveTargetTenant(DictionaryScope scope) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + if (scope == DictionaryScope.System) + { + EnsurePlatformTenant(tenantId); + return Guid.Empty; + } + + if (tenantId == Guid.Empty) + { + throw new BusinessException(ErrorCodes.BadRequest, "业务参数需指定租户"); + } + + return tenantId; + } + + private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant(); + + private static DictionaryScope ResolveScopeForQuery(DictionaryScope? requestedScope, Guid tenantId) + { + if (requestedScope.HasValue) + { + return requestedScope.Value; + } + + return tenantId == Guid.Empty ? DictionaryScope.System : DictionaryScope.Business; + } + + private void EnsureScopePermission(DictionaryScope scope) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + if (scope == DictionaryScope.System && tenantId != Guid.Empty) + { + throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典"); + } + } + + private void EnsurePlatformTenant(Guid tenantId) + { + if (tenantId != Guid.Empty) + { + throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典"); + } + } + + private async Task InvalidateCacheAsync(DictionaryGroup group, CancellationToken cancellationToken) + { + await _cache.RemoveAsync(group.TenantId, group.Code, cancellationToken); + if (group.Scope == DictionaryScope.Business) + { + return; + } + + // 系统参数更新需要逐租户重新合并,由调用方在下一次请求时重新加载 + } + + private async Task> GetOrLoadCacheAsync(Guid tenantId, string code, CancellationToken cancellationToken) + { + var cached = await _cache.GetAsync(tenantId, code, cancellationToken); + if (cached != null) + { + return cached; + } + + var entities = await _repository.GetItemsByCodesAsync(new[] { code }, tenantId, includeSystem: false, cancellationToken); + var items = entities + .Where(item => item.IsEnabled && (item.Group?.IsEnabled ?? true)) + .Select(MapItem) + .OrderBy(item => item.SortOrder) + .ToList(); + + await _cache.SetAsync(tenantId, code, items, cancellationToken); + return items; + } + + private static IReadOnlyList MergeItems(IReadOnlyList systemItems, IReadOnlyList tenantItems) + { + if (tenantItems.Count == 0) + { + return systemItems; + } + + if (systemItems.Count == 0) + { + return tenantItems; + } + + return systemItems.Concat(tenantItems) + .OrderBy(item => item.SortOrder) + .ToList(); + } + + private static DictionaryGroupDto MapGroup(DictionaryGroup group, bool includeItems, IReadOnlyList? items = null) + { + return new DictionaryGroupDto + { + Id = group.Id, + Code = group.Code, + Name = group.Name, + Scope = group.Scope, + Description = group.Description, + IsEnabled = group.IsEnabled, + Items = includeItems ? items ?? group.Items.Select(MapItem).ToList() : Array.Empty() + }; + } + + private static DictionaryItemDto MapItem(DictionaryItem item) + => new() + { + Id = item.Id, + GroupId = item.GroupId, + Key = item.Key, + Value = item.Value, + IsDefault = item.IsDefault, + IsEnabled = item.IsEnabled, + SortOrder = item.SortOrder, + Description = item.Description + }; +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IMultiTenantEntity.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IMultiTenantEntity.cs new file mode 100644 index 0000000..5ea8f7d --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IMultiTenantEntity.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 多租户实体约定:所有持久化实体须包含租户标识字段。 +/// +public interface IMultiTenantEntity +{ + /// + /// 所属租户 ID。 + /// + Guid TenantId { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantContextAccessor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantContextAccessor.cs new file mode 100644 index 0000000..c05f027 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantContextAccessor.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 租户上下文访问器:用于在请求生命周期内读写当前租户上下文。 +/// +public interface ITenantContextAccessor +{ + /// + /// 获取或设置当前租户上下文。 + /// + TenantContext? Current { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs index 8782625..41b999f 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs @@ -1,14 +1,12 @@ namespace TakeoutSaaS.Shared.Abstractions.Tenancy; /// -/// 租户提供者接口:用于获取当前请求的租户标识。 +/// 租户提供者:用于在各层读取当前请求绑定的租户 ID。 /// public interface ITenantProvider { /// - /// 获取当前请求的租户 ID。 + /// 获取当前租户 ID,未解析时返回 Guid.Empty。 /// - /// 租户 ID,如果未设置则返回 Guid.Empty Guid GetCurrentTenantId(); } - diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantConstants.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantConstants.cs new file mode 100644 index 0000000..d5638fa --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantConstants.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 多租户相关通用常量。 +/// +public static class TenantConstants +{ + /// + /// HttpContext.Items 中租户上下文的键名。 + /// + public const string HttpContextItemKey = "__tenant_context"; +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantContext.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantContext.cs new file mode 100644 index 0000000..a4686a4 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantContext.cs @@ -0,0 +1,45 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 租户上下文:封装当前请求解析得到的租户标识、编号及解析来源。 +/// +public sealed class TenantContext +{ + /// + /// 未解析到租户时的默认上下文。 + /// + public static TenantContext Empty { get; } = new(Guid.Empty, null, "unresolved"); + + /// + /// 初始化租户上下文。 + /// + /// 租户 ID + /// 租户编码(可选) + /// 解析来源 + public TenantContext(Guid tenantId, string? tenantCode, string source) + { + TenantId = tenantId; + TenantCode = tenantCode; + Source = source; + } + + /// + /// 当前租户 ID,未解析时为 Guid.Empty。 + /// + public Guid TenantId { get; } + + /// + /// 当前租户编码(例如子域名或业务编码),可为空。 + /// + public string? TenantCode { get; } + + /// + /// 租户解析来源(Header、Host、Token 等)。 + /// + public string Source { get; } + + /// + /// 是否已成功解析到租户。 + /// + public bool IsResolved => TenantId != Guid.Empty; +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/TenantHttpContextExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/TenantHttpContextExtensions.cs new file mode 100644 index 0000000..12832c9 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/TenantHttpContextExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Http; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Shared.Web.Security; + +/// +/// HttpContext 租户扩展方法。 +/// +public static class TenantHttpContextExtensions +{ + /// + /// 获取 HttpContext.Items 中缓存的租户上下文。 + /// + /// 当前 HttpContext + /// 租户上下文,若不存在则返回 null + public static TenantContext? GetTenantContext(this HttpContext? context) + { + if (context == null) + { + return null; + } + + if (context.Items.TryGetValue(TenantConstants.HttpContextItemKey, out var value) && + value is TenantContext tenantContext) + { + return tenantContext; + } + + return null; + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs new file mode 100644 index 0000000..4bba213 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Entities; + +/// +/// 参数字典分组(系统参数/业务参数)。 +/// +public sealed class DictionaryGroup : IMultiTenantEntity, IAuditableEntity +{ + /// + /// 分组 ID。 + /// + public Guid Id { get; set; } + + /// + /// 所属租户(系统参数为 Guid.Empty)。 + /// + public Guid TenantId { get; set; } + + /// + /// 分组编码(唯一)。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 分组名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 分组作用域:系统/业务。 + /// + public DictionaryScope Scope { get; set; } = DictionaryScope.Business; + + /// + /// 描述信息。 + /// + public string? Description { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; set; } + + /// + /// 更新时间(UTC)。 + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// 字典项集合。 + /// + public ICollection Items { get; set; } = new List(); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs new file mode 100644 index 0000000..0058e23 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs @@ -0,0 +1,69 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Entities; + +/// +/// 参数字典项。 +/// +public sealed class DictionaryItem : IMultiTenantEntity, IAuditableEntity +{ + /// + /// 字典项 ID。 + /// + public Guid Id { get; set; } + + /// + /// 所属租户。 + /// + public Guid TenantId { get; set; } + + /// + /// 关联分组 ID。 + /// + public Guid GroupId { get; set; } + + /// + /// 字典项键。 + /// + public string Key { get; set; } = string.Empty; + + /// + /// 字典项值。 + /// + public string Value { get; set; } = string.Empty; + + /// + /// 是否默认项。 + /// + public bool IsDefault { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 排序值,越小越靠前。 + /// + public int SortOrder { get; set; } + + /// + /// 描述信息。 + /// + public string? Description { get; set; } + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; set; } + + /// + /// 更新时间(UTC)。 + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// 导航属性:所属分组。 + /// + public DictionaryGroup? Group { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/DictionaryScope.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/DictionaryScope.cs new file mode 100644 index 0000000..5e27e54 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/DictionaryScope.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Dictionary.Enums; + +/// +/// 参数字典作用域。 +/// +public enum DictionaryScope +{ + /// + /// 系统级参数,所有租户共享。 + /// + System = 1, + + /// + /// 业务级参数,仅当前租户可见。 + /// + Business = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs new file mode 100644 index 0000000..ee338f2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Domain.Dictionary.Repositories; + +/// +/// 参数字典仓储契约。 +/// +public interface IDictionaryRepository +{ + /// + /// 依据 ID 获取分组。 + /// + Task FindGroupByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// 依据编码获取分组。 + /// + Task FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default); + + /// + /// 搜索分组,可按作用域过滤。 + /// + Task> SearchGroupsAsync(DictionaryScope? scope, CancellationToken cancellationToken = default); + + /// + /// 新增分组。 + /// + Task AddGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default); + + /// + /// 删除分组。 + /// + Task RemoveGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default); + + /// + /// 依据 ID 获取字典项。 + /// + Task FindItemByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// 获取某分组下的所有字典项。 + /// + Task> GetItemsByGroupIdAsync(Guid groupId, CancellationToken cancellationToken = default); + + /// + /// 按分组编码集合获取字典项(可包含系统参数)。 + /// + Task> GetItemsByCodesAsync(IEnumerable codes, Guid tenantId, bool includeSystem, CancellationToken cancellationToken = default); + + /// + /// 新增字典项。 + /// + Task AddItemAsync(DictionaryItem item, CancellationToken cancellationToken = default); + + /// + /// 删除字典项。 + /// + Task RemoveItemAsync(DictionaryItem item, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs index 123208a..b47df34 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs @@ -1,11 +1,11 @@ -using System; +using TakeoutSaaS.Shared.Abstractions.Entities; namespace TakeoutSaaS.Domain.Identity.Entities; /// -/// 后台账号实体(平台/商户/员工)。 +/// 管理后台账户实体(平台、租户或商户员工)。 /// -public sealed class IdentityUser +public sealed class IdentityUser : IMultiTenantEntity { /// /// 用户 ID。 diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs index 3c4fdf8..953e979 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs @@ -1,11 +1,11 @@ -using System; +using TakeoutSaaS.Shared.Abstractions.Entities; namespace TakeoutSaaS.Domain.Identity.Entities; /// -/// 小程序用户。 +/// 小程序用户实体。 /// -public sealed class MiniUser +public sealed class MiniUser : IMultiTenantEntity { /// /// 用户 ID。 @@ -18,7 +18,7 @@ public sealed class MiniUser public string OpenId { get; set; } = string.Empty; /// - /// 微信 UnionId,可为空。 + /// 微信 UnionId,可能为空。 /// public string? UnionId { get; set; } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs new file mode 100644 index 0000000..10bd6d5 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs @@ -0,0 +1,89 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Shared.Abstractions.Entities; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 多租户感知 DbContext:自动应用租户过滤并填充租户字段。 +/// +public abstract class TenantAwareDbContext : DbContext +{ + private readonly ITenantProvider _tenantProvider; + + protected TenantAwareDbContext(DbContextOptions options, ITenantProvider tenantProvider) : base(options) + { + _tenantProvider = tenantProvider; + } + + /// + /// 当前请求租户 ID。 + /// + protected Guid CurrentTenantId => _tenantProvider.GetCurrentTenantId(); + + /// + /// 应用租户过滤器至所有实现 的实体。 + /// + protected void ApplyTenantQueryFilters(ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (!typeof(IMultiTenantEntity).IsAssignableFrom(entityType.ClrType)) + { + continue; + } + + var methodInfo = typeof(TenantAwareDbContext) + .GetMethod(nameof(SetTenantFilter), BindingFlags.Instance | BindingFlags.NonPublic)! + .MakeGenericMethod(entityType.ClrType); + + methodInfo.Invoke(this, new object[] { modelBuilder }); + } + } + + private void SetTenantFilter(ModelBuilder modelBuilder) + where TEntity : class, IMultiTenantEntity + { + modelBuilder.Entity().HasQueryFilter(entity => entity.TenantId == CurrentTenantId); + } + + public override int SaveChanges() + { + ApplyTenantMetadata(); + return base.SaveChanges(); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + ApplyTenantMetadata(); + return base.SaveChangesAsync(cancellationToken); + } + + private void ApplyTenantMetadata() + { + var tenantId = CurrentTenantId; + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added && entry.Entity.TenantId == Guid.Empty && tenantId != Guid.Empty) + { + entry.Entity.TenantId = tenantId; + } + } + + var utcNow = DateTime.UtcNow; + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedAt = utcNow; + entry.Entity.UpdatedAt = null; + } + else if (entry.State == EntityState.Modified) + { + entry.Entity.UpdatedAt = utcNow; + } + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs new file mode 100644 index 0000000..ad33dab --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +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.Dictionary.Options; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; +using TakeoutSaaS.Infrastructure.Dictionary.Repositories; +using TakeoutSaaS.Infrastructure.Dictionary.Services; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions; + +/// +/// 字典基础设施注册扩展。 +/// +public static class DictionaryServiceCollectionExtensions +{ + /// + /// 注册字典模块基础设施。 + /// + public static IServiceCollection AddDictionaryInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("AppDatabase"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException("缺少 AppDatabase 连接字符串配置"); + } + + services.AddDbContext(options => + { + options.UseNpgsql(connectionString); + }); + + services.AddScoped(); + services.AddScoped(); + + services.AddOptions() + .Bind(configuration.GetSection("Dictionary:Cache")) + .ValidateDataAnnotations(); + + return services; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheOptions.cs new file mode 100644 index 0000000..c5df7a0 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheOptions.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Infrastructure.Dictionary.Options; + +/// +/// 字典缓存配置。 +/// +public sealed class DictionaryCacheOptions +{ + /// + /// 缓存滑动过期时间。 + /// + public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(30); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs new file mode 100644 index 0000000..90351a8 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Infrastructure.Common.Persistence; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +/// +/// 参数字典 DbContext。 +/// +public sealed class DictionaryDbContext : TenantAwareDbContext +{ + public DictionaryDbContext(DbContextOptions options, ITenantProvider tenantProvider) + : base(options, tenantProvider) + { + } + + public DbSet DictionaryGroups => Set(); + public DbSet DictionaryItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureGroup(modelBuilder.Entity()); + ConfigureItem(modelBuilder.Entity()); + ApplyTenantQueryFilters(modelBuilder); + } + + private static void ConfigureGroup(EntityTypeBuilder builder) + { + builder.ToTable("dictionary_groups"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Code).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Scope).HasConversion().IsRequired(); + builder.Property(x => x.Description).HasMaxLength(512); + builder.Property(x => x.IsEnabled).HasDefaultValue(true); + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.UpdatedAt); + + builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + } + + private static void ConfigureItem(EntityTypeBuilder builder) + { + builder.ToTable("dictionary_items"); + builder.HasKey(x => x.Id); + builder.Property(x => x.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); + + builder.HasOne(x => x.Group) + .WithMany(g => g.Items) + .HasForeignKey(x => x.GroupId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => new { x.GroupId, x.Key }).IsUnique(); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs new file mode 100644 index 0000000..3bb86c1 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs @@ -0,0 +1,105 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Domain.Dictionary.Repositories; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories; + +/// +/// EF Core 字典仓储实现。 +/// +public sealed class EfDictionaryRepository : IDictionaryRepository +{ + private readonly DictionaryDbContext _context; + + public EfDictionaryRepository(DictionaryDbContext context) + { + _context = context; + } + + public Task FindGroupByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _context.DictionaryGroups.FirstOrDefaultAsync(group => group.Id == id, cancellationToken); + + public Task FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default) + => _context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == code, cancellationToken); + + public async Task> SearchGroupsAsync(DictionaryScope? scope, CancellationToken cancellationToken = default) + { + var query = _context.DictionaryGroups.AsNoTracking(); + if (scope.HasValue) + { + query = query.Where(group => group.Scope == scope.Value); + } + + return await query + .OrderBy(group => group.Code) + .ToListAsync(cancellationToken); + } + + public Task AddGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default) + { + _context.DictionaryGroups.Add(group); + return Task.CompletedTask; + } + + public Task RemoveGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default) + { + _context.DictionaryGroups.Remove(group); + return Task.CompletedTask; + } + + public Task FindItemByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _context.DictionaryItems.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + + public async Task> GetItemsByGroupIdAsync(Guid groupId, CancellationToken cancellationToken = default) + { + return await _context.DictionaryItems + .AsNoTracking() + .Where(item => item.GroupId == groupId) + .OrderBy(item => item.SortOrder) + .ToListAsync(cancellationToken); + } + + public Task AddItemAsync(DictionaryItem item, CancellationToken cancellationToken = default) + { + _context.DictionaryItems.Add(item); + return Task.CompletedTask; + } + + public Task RemoveItemAsync(DictionaryItem item, CancellationToken cancellationToken = default) + { + _context.DictionaryItems.Remove(item); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _context.SaveChangesAsync(cancellationToken); + + public async Task> GetItemsByCodesAsync(IEnumerable codes, Guid tenantId, bool includeSystem, CancellationToken cancellationToken = default) + { + var normalizedCodes = codes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim().ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (normalizedCodes.Length == 0) + { + return Array.Empty(); + } + + var query = _context.DictionaryItems + .AsNoTracking() + .IgnoreQueryFilters() + .Include(item => item.Group) + .Where(item => normalizedCodes.Contains(item.Group!.Code)); + + query = query.Where(item => item.TenantId == tenantId || (includeSystem && item.TenantId == Guid.Empty)); + + return await query + .OrderBy(item => item.SortOrder) + .ToListAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs new file mode 100644 index 0000000..9024674 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Application.Dictionary.Abstractions; +using TakeoutSaaS.Application.Dictionary.Models; +using TakeoutSaaS.Infrastructure.Dictionary.Options; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Services; + +/// +/// 基于 IDistributedCache 的字典缓存实现。 +/// +public sealed class DistributedDictionaryCache : IDictionaryCache +{ + private readonly IDistributedCache _cache; + private readonly DictionaryCacheOptions _options; + private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + + public DistributedDictionaryCache(IDistributedCache cache, IOptions options) + { + _cache = cache; + _options = options.Value; + } + + public async Task?> GetAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) + { + var cacheKey = BuildKey(tenantId, code); + var payload = await _cache.GetAsync(cacheKey, cancellationToken); + if (payload == null || payload.Length == 0) + { + return null; + } + + return JsonSerializer.Deserialize>(payload, _serializerOptions); + } + + public Task SetAsync(Guid tenantId, string code, IReadOnlyList items, CancellationToken cancellationToken = default) + { + var cacheKey = BuildKey(tenantId, code); + var payload = JsonSerializer.SerializeToUtf8Bytes(items, _serializerOptions); + var options = new DistributedCacheEntryOptions + { + SlidingExpiration = _options.SlidingExpiration + }; + return _cache.SetAsync(cacheKey, payload, options, cancellationToken); + } + + public Task RemoveAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) + { + var cacheKey = BuildKey(tenantId, code); + return _cache.RemoveAsync(cacheKey, cancellationToken); + } + + private static string BuildKey(Guid tenantId, string code) + => $"dictionary:{tenantId.ToString().ToLowerInvariant()}:{code.ToLowerInvariant()}"; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs index 57d88b6..f640da0 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using TakeoutSaaS.Infrastructure.Identity.Options; +using TakeoutSaaS.Shared.Abstractions.Tenancy; using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser; namespace TakeoutSaaS.Infrastructure.Identity.Persistence; @@ -20,6 +21,7 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger var context = scope.ServiceProvider.GetRequiredService(); var options = scope.ServiceProvider.GetRequiredService>().Value; var passwordHasher = scope.ServiceProvider.GetRequiredService>(); + var tenantContextAccessor = scope.ServiceProvider.GetRequiredService(); await context.Database.MigrateAsync(cancellationToken); @@ -31,6 +33,7 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger foreach (var userOptions in options.Users) { + using var tenantScope = EnterTenantScope(tenantContextAccessor, userOptions.TenantId); var user = await context.IdentityUsers.FirstOrDefaultAsync(x => x.Account == userOptions.Account, cancellationToken); var roles = NormalizeValues(userOptions.Roles); var permissions = NormalizeValues(userOptions.Permissions); @@ -76,4 +79,17 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger .Where(v => !string.IsNullOrWhiteSpace(v)) .Select(v => v.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase)]; + + private static IDisposable EnterTenantScope(ITenantContextAccessor accessor, Guid tenantId) + { + var previous = accessor.Current; + accessor.Current = new TenantContext(tenantId, null, "admin-seed"); + return new Scope(() => accessor.Current = previous); + } + + private sealed class Scope(Action disposeAction) : IDisposable + { + private readonly Action _disposeAction = disposeAction; + public void Dispose() => _disposeAction(); + } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index 9887201..d9b5b52 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -4,14 +4,20 @@ using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Infrastructure.Common.Persistence; +using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Infrastructure.Identity.Persistence; /// -/// 身份认证 DbContext。 +/// 身份认证 DbContext,带多租户过滤。 /// -public sealed class IdentityDbContext(DbContextOptions options) : DbContext(options) +public sealed class IdentityDbContext : TenantAwareDbContext { + public IdentityDbContext(DbContextOptions options, ITenantProvider tenantProvider) + : base(options, tenantProvider) + { + } public DbSet IdentityUsers => Set(); public DbSet MiniUsers => Set(); @@ -20,6 +26,7 @@ public sealed class IdentityDbContext(DbContextOptions option { ConfigureIdentityUser(modelBuilder.Entity()); ConfigureMiniUser(modelBuilder.Entity()); + ApplyTenantQueryFilters(modelBuilder); } private static void ConfigureIdentityUser(EntityTypeBuilder builder) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs index cf9c8e9..78d3f0e 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs @@ -69,7 +69,7 @@ public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptio /// /// 用户档案 /// Claims 集合 - private static IEnumerable BuildClaims(CurrentUserProfile profile) + private static List BuildClaims(CurrentUserProfile profile) { var userId = profile.UserId.ToString(); var claims = new List @@ -86,15 +86,9 @@ public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptio claims.Add(new Claim("merchant_id", profile.MerchantId.Value.ToString())); } - foreach (var role in profile.Roles) - { - claims.Add(new Claim(ClaimTypes.Role, role)); - } + claims.AddRange(profile.Roles.Select(role => new Claim(ClaimTypes.Role, role))); - foreach (var permission in profile.Permissions) - { - claims.Add(new Claim("permission", permission)); - } + claims.AddRange(profile.Permissions.Select(permission => new Claim("permission", permission))); return claims; } diff --git a/src/Modules/TakeoutSaaS.Module.Dictionary/Extensions/DictionaryModuleExtensions.cs b/src/Modules/TakeoutSaaS.Module.Dictionary/Extensions/DictionaryModuleExtensions.cs new file mode 100644 index 0000000..97334aa --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Dictionary/Extensions/DictionaryModuleExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Dictionary.Extensions; +using TakeoutSaaS.Infrastructure.Dictionary.Extensions; + +namespace TakeoutSaaS.Module.Dictionary.Extensions; + +/// +/// 字典模块服务扩展。 +/// +public static class DictionaryModuleExtensions +{ + /// + /// 注册字典模块应用层与基础设施。 + /// + public static IServiceCollection AddDictionaryModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddDictionaryApplication(); + services.AddDictionaryInfrastructure(configuration); + return services; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Dictionary/TakeoutSaaS.Module.Dictionary.csproj b/src/Modules/TakeoutSaaS.Module.Dictionary/TakeoutSaaS.Module.Dictionary.csproj index b407eac..b03dcec 100644 --- a/src/Modules/TakeoutSaaS.Module.Dictionary/TakeoutSaaS.Module.Dictionary.csproj +++ b/src/Modules/TakeoutSaaS.Module.Dictionary/TakeoutSaaS.Module.Dictionary.csproj @@ -6,6 +6,7 @@ + + - diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs new file mode 100644 index 0000000..72afde3 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy.Extensions; + +/// +/// 多租户服务注册及中间件扩展。 +/// +public static class TenantServiceCollectionExtensions +{ + /// + /// 注册租户上下文、解析中间件及默认租户提供者。 + /// + public static IServiceCollection AddTenantResolution(this IServiceCollection services, IConfiguration configuration) + { + services.TryAddSingleton(); + services.TryAddScoped(); + + services.AddOptions() + .Bind(configuration.GetSection("Tenancy")) + .ValidateDataAnnotations(); + + return services; + } + + /// + /// 使用多租户解析中间件。 + /// + public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app) + => app.UseMiddleware(); +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs new file mode 100644 index 0000000..2a7f7ab --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs @@ -0,0 +1,34 @@ +using System.Threading; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 基于 的租户上下文访问器,实现请求级别隔离。 +/// +public sealed class TenantContextAccessor : ITenantContextAccessor +{ + private static readonly AsyncLocal Holder = new(); + + /// + public TenantContext? Current + { + get => Holder.Value?.Context; + set + { + if (Holder.Value != null) + { + Holder.Value.Context = value; + } + else if (value != null) + { + Holder.Value = new TenantContextHolder { Context = value }; + } + } + } + + private sealed class TenantContextHolder + { + public TenantContext? Context { get; set; } + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs index 41e74d1..1adf1d1 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs @@ -1,39 +1,24 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Http; using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Module.Tenancy; /// -/// 默认租户提供者:优先从Header: X-Tenant-Id,其次从Token Claim: tenant_id +/// 默认租户提供者:基于租户上下文访问器暴露当前租户 ID。 /// public sealed class TenantProvider : ITenantProvider { - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ITenantContextAccessor _tenantContextAccessor; - public TenantProvider(IHttpContextAccessor httpContextAccessor) + /// + /// 初始化租户提供者。 + /// + /// 租户上下文访问器 + public TenantProvider(ITenantContextAccessor tenantContextAccessor) { - _httpContextAccessor = httpContextAccessor; + _tenantContextAccessor = tenantContextAccessor; } + /// public Guid GetCurrentTenantId() - { - var httpContext = _httpContextAccessor.HttpContext; - if (httpContext == null) return Guid.Empty; - - // 1. Header 优先 - if (httpContext.Request.Headers.TryGetValue("X-Tenant-Id", out var values)) - { - if (Guid.TryParse(values.FirstOrDefault(), out var headerTenant)) - return headerTenant; - } - - // 2. Token Claim - var claim = httpContext.User?.FindFirst("tenant_id"); - if (claim != null && Guid.TryParse(claim.Value, out var claimTenant)) - return claimTenant; - - return Guid.Empty; // 未识别到则返回空(上层可按需处理) - } + => _tenantContextAccessor.Current?.TenantId ?? Guid.Empty; } - diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs new file mode 100644 index 0000000..e7a2c2f --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs @@ -0,0 +1,191 @@ +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 多租户解析中间件:支持 Header、域名与 Token Claim 的优先级解析。 +/// +public sealed class TenantResolutionMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly ITenantContextAccessor _tenantContextAccessor; + private readonly IOptionsMonitor _optionsMonitor; + + /// + /// 初始化中间件。 + /// + public TenantResolutionMiddleware( + RequestDelegate next, + ILogger logger, + ITenantContextAccessor tenantContextAccessor, + IOptionsMonitor optionsMonitor) + { + _next = next; + _logger = logger; + _tenantContextAccessor = tenantContextAccessor; + _optionsMonitor = optionsMonitor; + } + + /// + /// 解析租户并将上下文注入请求。 + /// + public async Task InvokeAsync(HttpContext context) + { + var options = _optionsMonitor.CurrentValue ?? new TenantResolutionOptions(); + if (ShouldSkip(context.Request.Path, options)) + { + await _next(context); + return; + } + + var tenantContext = ResolveTenant(context, options); + _tenantContextAccessor.Current = tenantContext; + context.Items[TenantConstants.HttpContextItemKey] = tenantContext; + + if (!tenantContext.IsResolved) + { + _logger.LogDebug("未能解析租户:{Path}", context.Request.Path); + + if (options.ThrowIfUnresolved) + { + var response = ApiResponse.Error(ErrorCodes.BadRequest, "缺少租户标识"); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(response, cancellationToken: context.RequestAborted); + _tenantContextAccessor.Current = null; + context.Items.Remove(TenantConstants.HttpContextItemKey); + return; + } + } + + try + { + await _next(context); + } + finally + { + _tenantContextAccessor.Current = null; + context.Items.Remove(TenantConstants.HttpContextItemKey); + } + } + + private static bool ShouldSkip(PathString path, TenantResolutionOptions options) + { + if (!path.HasValue) + { + return false; + } + + var value = path.Value ?? string.Empty; + if (options.IgnoredPaths.Contains(value)) + { + return true; + } + + return options.IgnoredPaths.Any(ignore => + { + if (string.IsNullOrWhiteSpace(ignore)) + { + return false; + } + + var ignorePath = new PathString(ignore); + return path.StartsWithSegments(ignorePath); + }); + } + + private TenantContext ResolveTenant(HttpContext context, TenantResolutionOptions options) + { + var request = context.Request; + + // 1. Header 中的租户 ID + if (!string.IsNullOrWhiteSpace(options.TenantIdHeaderName) && + request.Headers.TryGetValue(options.TenantIdHeaderName, out var tenantHeader) && + Guid.TryParse(tenantHeader.FirstOrDefault(), out var headerTenantId)) + { + return new TenantContext(headerTenantId, null, $"header:{options.TenantIdHeaderName}"); + } + + // 2. Header 中的租户编码 + if (!string.IsNullOrWhiteSpace(options.TenantCodeHeaderName) && + request.Headers.TryGetValue(options.TenantCodeHeaderName, out var codeHeader)) + { + var code = codeHeader.FirstOrDefault(); + if (TryResolveByCode(code, options, out var tenantFromCode)) + { + return new TenantContext(tenantFromCode, code, $"header:{options.TenantCodeHeaderName}"); + } + } + + // 3. Host 映射/子域名解析 + var host = request.Host.Host; + if (!string.IsNullOrWhiteSpace(host)) + { + if (options.DomainTenantMap.TryGetValue(host, out var tenantFromHost)) + { + return new TenantContext(tenantFromHost, null, $"host:{host}"); + } + + var codeFromHost = ResolveCodeFromHost(host, options.RootDomain); + if (TryResolveByCode(codeFromHost, options, out var tenantFromSubdomain)) + { + return new TenantContext(tenantFromSubdomain, codeFromHost, $"host:{host}"); + } + } + + // 4. Token Claim + var claim = context.User?.FindFirst("tenant_id"); + if (claim != null && Guid.TryParse(claim.Value, out var claimTenant)) + { + return new TenantContext(claimTenant, null, "claim:tenant_id"); + } + + return TenantContext.Empty; + } + + private static bool TryResolveByCode(string? code, TenantResolutionOptions options, out Guid tenantId) + { + tenantId = Guid.Empty; + if (string.IsNullOrWhiteSpace(code)) + { + return false; + } + + return options.CodeTenantMap.TryGetValue(code, out tenantId); + } + + private static string? ResolveCodeFromHost(string host, string? rootDomain) + { + if (string.IsNullOrWhiteSpace(rootDomain)) + { + return null; + } + + var normalizedRoot = rootDomain.TrimStart('.'); + if (!host.EndsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var suffixLength = normalizedRoot.Length; + if (host.Length <= suffixLength) + { + return null; + } + + var withoutRoot = host[..(host.Length - suffixLength)]; + if (withoutRoot.EndsWith('.')) + { + withoutRoot = withoutRoot[..^1]; + } + + var segments = withoutRoot.Split('.', StringSplitOptions.RemoveEmptyEntries); + return segments.Length == 0 ? null : segments[0]; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs new file mode 100644 index 0000000..8e0c5ff --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs @@ -0,0 +1,56 @@ +using System.Collections.ObjectModel; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 多租户解析配置项。 +/// +public sealed class TenantResolutionOptions +{ + /// + /// 通过 Header 解析租户 ID 时使用的头名称,默认 X-Tenant-Id。 + /// + public string TenantIdHeaderName { get; set; } = "X-Tenant-Id"; + + /// + /// 通过 Header 解析租户编码时使用的头名称,默认 X-Tenant-Code。 + /// + public string TenantCodeHeaderName { get; set; } = "X-Tenant-Code"; + + /// + /// 明确指定 host 与租户 ID 对应关系的映射表(精确匹配)。 + /// + public IDictionary DomainTenantMap { get; set; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// 租户编码到租户 ID 的映射表,用于 header 或子域名解析。 + /// + public IDictionary CodeTenantMap { get; set; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// 根域(不含子域),用于形如 {tenant}.rootDomain 的场景,例如 admin.takeoutsaas.com。 + /// + public string? RootDomain { get; set; } + + /// + /// 需要跳过租户解析的路径集合(如健康检查),默认仅包含 /health。 + /// + public ISet IgnoredPaths { get; } = new HashSet(StringComparer.OrdinalIgnoreCase) { "/health" }; + + /// + /// 若为 true,当无法解析租户时立即返回 400;否则交由上层自行判定。 + /// + public bool ThrowIfUnresolved { get; set; } + + /// + /// 对外只读视图,便于审计日志输出。 + /// + public IReadOnlyDictionary DomainMappings => new ReadOnlyDictionary(DomainTenantMap); + + /// + /// 对外只读的编码映射。 + /// + public IReadOnlyDictionary CodeMappings => new ReadOnlyDictionary(CodeTenantMap); +}