chore: 提交当前变更
This commit is contained in:
@@ -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 基础上下文、实体基类、审计字段
|
||||
|
||||
126
src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs
Normal file
126
src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/dictionaries")]
|
||||
public sealed class DictionaryController : BaseApiController
|
||||
{
|
||||
private readonly IDictionaryAppService _dictionaryAppService;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化字典控制器。
|
||||
/// </summary>
|
||||
/// <param name="dictionaryAppService">字典服务</param>
|
||||
public DictionaryController(IDictionaryAppService dictionaryAppService)
|
||||
{
|
||||
_dictionaryAppService = dictionaryAppService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询字典分组。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("dictionary:group:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<DictionaryGroupDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<DictionaryGroupDto>>> GetGroups([FromQuery] DictionaryGroupQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var groups = await _dictionaryAppService.SearchGroupsAsync(query, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<DictionaryGroupDto>>.Ok(groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典分组。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("dictionary:group:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryGroupDto>> CreateGroup([FromBody] CreateDictionaryGroupRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await _dictionaryAppService.CreateGroupAsync(request, cancellationToken);
|
||||
return ApiResponse<DictionaryGroupDto>.Ok(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典分组。
|
||||
/// </summary>
|
||||
[HttpPut("{groupId:guid}")]
|
||||
[PermissionAuthorize("dictionary:group:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryGroupDto>> UpdateGroup(Guid groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await _dictionaryAppService.UpdateGroupAsync(groupId, request, cancellationToken);
|
||||
return ApiResponse<DictionaryGroupDto>.Ok(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典分组。
|
||||
/// </summary>
|
||||
[HttpDelete("{groupId:guid}")]
|
||||
[PermissionAuthorize("dictionary:group:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteGroup(Guid groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
await _dictionaryAppService.DeleteGroupAsync(groupId, cancellationToken);
|
||||
return ApiResponse.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典项。
|
||||
/// </summary>
|
||||
[HttpPost("{groupId:guid}/items")]
|
||||
[PermissionAuthorize("dictionary:item:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryItemDto>> CreateItem(Guid groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
request.GroupId = groupId;
|
||||
var item = await _dictionaryAppService.CreateItemAsync(request, cancellationToken);
|
||||
return ApiResponse<DictionaryItemDto>.Ok(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典项。
|
||||
/// </summary>
|
||||
[HttpPut("items/{itemId:guid}")]
|
||||
[PermissionAuthorize("dictionary:item:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryItemDto>> UpdateItem(Guid itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await _dictionaryAppService.UpdateItemAsync(itemId, request, cancellationToken);
|
||||
return ApiResponse<DictionaryItemDto>.Ok(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典项。
|
||||
/// </summary>
|
||||
[HttpDelete("items/{itemId:guid}")]
|
||||
[PermissionAuthorize("dictionary:item:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteItem(Guid itemId, CancellationToken cancellationToken)
|
||||
{
|
||||
await _dictionaryAppService.DeleteItemAsync(itemId, cancellationToken);
|
||||
return ApiResponse.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取字典项(命中缓存)。
|
||||
/// </summary>
|
||||
[HttpPost("batch")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>> BatchGet([FromBody] DictionaryBatchQueryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var dictionaries = await _dictionaryAppService.GetCachedItemsAsync(request, cancellationToken);
|
||||
return ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>.Ok(dictionaries);
|
||||
}
|
||||
}
|
||||
@@ -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<ITenantProvider, TenantProvider>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors("AdminApiCors");
|
||||
app.UseTenantResolution();
|
||||
app.UseSharedWebCore();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
||||
<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.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<ITenantProvider, TenantProvider>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors("MiniApiCors");
|
||||
app.UseTenantResolution();
|
||||
app.UseSharedWebCore();
|
||||
app.UseSharedSwagger();
|
||||
|
||||
|
||||
@@ -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<ITenantProvider, TenantProvider>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors("UserApiCors");
|
||||
app.UseTenantResolution();
|
||||
app.UseSharedWebCore();
|
||||
app.UseSharedSwagger();
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典应用服务接口。
|
||||
/// </summary>
|
||||
public interface IDictionaryAppService
|
||||
{
|
||||
Task<DictionaryGroupDto> CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<DictionaryGroupDto> UpdateGroupAsync(Guid groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteGroupAsync(Guid groupId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<DictionaryGroupDto>> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<DictionaryItemDto> CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<DictionaryItemDto> UpdateItemAsync(Guid itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteItemAsync(Guid itemId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>> GetCachedItemsAsync(DictionaryBatchQueryRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存读写接口。
|
||||
/// </summary>
|
||||
public interface IDictionaryCache
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取缓存。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DictionaryItemDto>?> GetAsync(Guid tenantId, string code, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存。
|
||||
/// </summary>
|
||||
Task SetAsync(Guid tenantId, string code, IReadOnlyList<DictionaryItemDto> items, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 移除缓存。
|
||||
/// </summary>
|
||||
Task RemoveAsync(Guid tenantId, string code, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典分组请求。
|
||||
/// </summary>
|
||||
public sealed class CreateDictionaryGroupRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组编码。
|
||||
/// </summary>
|
||||
[Required, MaxLength(64)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
/// </summary>
|
||||
[Required, MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 作用域:系统/业务。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DictionaryScope Scope { get; set; } = DictionaryScope.Business;
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典项请求。
|
||||
/// </summary>
|
||||
public sealed class CreateDictionaryItemRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属分组 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典项键。
|
||||
/// </summary>
|
||||
[Required, MaxLength(64)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项值。
|
||||
/// </summary>
|
||||
[Required, MaxLength(256)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认项。
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 批量查询字典项请求。
|
||||
/// </summary>
|
||||
public sealed class DictionaryBatchQueryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组编码集合。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public IReadOnlyCollection<string> Codes { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组查询参数。
|
||||
/// </summary>
|
||||
public sealed class DictionaryGroupQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// 作用域过滤。
|
||||
/// </summary>
|
||||
public DictionaryScope? Scope { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含字典项。
|
||||
/// </summary>
|
||||
public bool IncludeItems { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典分组请求。
|
||||
/// </summary>
|
||||
public sealed class UpdateDictionaryGroupRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
/// </summary>
|
||||
[Required, MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典项请求。
|
||||
/// </summary>
|
||||
public sealed class UpdateDictionaryItemRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 字典项值。
|
||||
/// </summary>
|
||||
[Required, MaxLength(256)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认项。
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 字典应用服务注册扩展。
|
||||
/// </summary>
|
||||
public static class DictionaryServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册字典模块应用层组件。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDictionaryApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IDictionaryAppService, DictionaryAppService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组 DTO。
|
||||
/// </summary>
|
||||
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<DictionaryItemDto> Items { get; init; } = Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项 DTO。
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典应用服务实现。
|
||||
/// </summary>
|
||||
public sealed class DictionaryAppService : IDictionaryAppService
|
||||
{
|
||||
private readonly IDictionaryRepository _repository;
|
||||
private readonly IDictionaryCache _cache;
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
private readonly ILogger<DictionaryAppService> _logger;
|
||||
|
||||
public DictionaryAppService(
|
||||
IDictionaryRepository repository,
|
||||
IDictionaryCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<DictionaryAppService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_cache = cache;
|
||||
_tenantProvider = tenantProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DictionaryGroupDto> 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<DictionaryGroupDto> 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<IReadOnlyList<DictionaryGroupDto>> 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<DictionaryGroupDto>(groups.Count);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
IReadOnlyList<DictionaryItemDto> items = Array.Empty<DictionaryItemDto>();
|
||||
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<DictionaryItemDto> 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<DictionaryItemDto> 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<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>> 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<string, IReadOnlyList<DictionaryItemDto>>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var result = new Dictionary<string, IReadOnlyList<DictionaryItemDto>>(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<DictionaryGroup> 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<DictionaryItem> 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<IReadOnlyList<DictionaryItemDto>> 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<DictionaryItemDto> MergeItems(IReadOnlyList<DictionaryItemDto> systemItems, IReadOnlyList<DictionaryItemDto> 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<DictionaryItemDto>? 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<DictionaryItemDto>()
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 多租户实体约定:所有持久化实体须包含租户标识字段。
|
||||
/// </summary>
|
||||
public interface IMultiTenantEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属租户 ID。
|
||||
/// </summary>
|
||||
Guid TenantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 租户上下文访问器:用于在请求生命周期内读写当前租户上下文。
|
||||
/// </summary>
|
||||
public interface ITenantContextAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置当前租户上下文。
|
||||
/// </summary>
|
||||
TenantContext? Current { get; set; }
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 租户提供者接口:用于获取当前请求的租户标识。
|
||||
/// 租户提供者:用于在各层读取当前请求绑定的租户 ID。
|
||||
/// </summary>
|
||||
public interface ITenantProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前请求的租户 ID。
|
||||
/// 获取当前租户 ID,未解析时返回 Guid.Empty。
|
||||
/// </summary>
|
||||
/// <returns>租户 ID,如果未设置则返回 Guid.Empty</returns>
|
||||
Guid GetCurrentTenantId();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 多租户相关通用常量。
|
||||
/// </summary>
|
||||
public static class TenantConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// HttpContext.Items 中租户上下文的键名。
|
||||
/// </summary>
|
||||
public const string HttpContextItemKey = "__tenant_context";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 租户上下文:封装当前请求解析得到的租户标识、编号及解析来源。
|
||||
/// </summary>
|
||||
public sealed class TenantContext
|
||||
{
|
||||
/// <summary>
|
||||
/// 未解析到租户时的默认上下文。
|
||||
/// </summary>
|
||||
public static TenantContext Empty { get; } = new(Guid.Empty, null, "unresolved");
|
||||
|
||||
/// <summary>
|
||||
/// 初始化租户上下文。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID</param>
|
||||
/// <param name="tenantCode">租户编码(可选)</param>
|
||||
/// <param name="source">解析来源</param>
|
||||
public TenantContext(Guid tenantId, string? tenantCode, string source)
|
||||
{
|
||||
TenantId = tenantId;
|
||||
TenantCode = tenantCode;
|
||||
Source = source;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前租户 ID,未解析时为 Guid.Empty。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前租户编码(例如子域名或业务编码),可为空。
|
||||
/// </summary>
|
||||
public string? TenantCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户解析来源(Header、Host、Token 等)。
|
||||
/// </summary>
|
||||
public string Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已成功解析到租户。
|
||||
/// </summary>
|
||||
public bool IsResolved => TenantId != Guid.Empty;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Shared.Web.Security;
|
||||
|
||||
/// <summary>
|
||||
/// HttpContext 租户扩展方法。
|
||||
/// </summary>
|
||||
public static class TenantHttpContextExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取 HttpContext.Items 中缓存的租户上下文。
|
||||
/// </summary>
|
||||
/// <param name="context">当前 HttpContext</param>
|
||||
/// <returns>租户上下文,若不存在则返回 null</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典分组(系统参数/业务参数)。
|
||||
/// </summary>
|
||||
public sealed class DictionaryGroup : IMultiTenantEntity, IAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组 ID。
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户(系统参数为 Guid.Empty)。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组编码(唯一)。
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分组作用域:系统/业务。
|
||||
/// </summary>
|
||||
public DictionaryScope Scope { get; set; } = DictionaryScope.Business;
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </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>
|
||||
public ICollection<DictionaryItem> Items { get; set; } = new List<DictionaryItem>();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典项。
|
||||
/// </summary>
|
||||
public sealed class DictionaryItem : IMultiTenantEntity, IAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 字典项 ID。
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联分组 ID。
|
||||
/// </summary>
|
||||
public Guid GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典项键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项值。
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认项。
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值,越小越靠前。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性:所属分组。
|
||||
/// </summary>
|
||||
public DictionaryGroup? Group { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典作用域。
|
||||
/// </summary>
|
||||
public enum DictionaryScope
|
||||
{
|
||||
/// <summary>
|
||||
/// 系统级参数,所有租户共享。
|
||||
/// </summary>
|
||||
System = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 业务级参数,仅当前租户可见。
|
||||
/// </summary>
|
||||
Business = 2
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典仓储契约。
|
||||
/// </summary>
|
||||
public interface IDictionaryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 依据 ID 获取分组。
|
||||
/// </summary>
|
||||
Task<DictionaryGroup?> FindGroupByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 依据编码获取分组。
|
||||
/// </summary>
|
||||
Task<DictionaryGroup?> FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 搜索分组,可按作用域过滤。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DictionaryGroup>> SearchGroupsAsync(DictionaryScope? scope, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增分组。
|
||||
/// </summary>
|
||||
Task AddGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除分组。
|
||||
/// </summary>
|
||||
Task RemoveGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 依据 ID 获取字典项。
|
||||
/// </summary>
|
||||
Task<DictionaryItem?> FindItemByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取某分组下的所有字典项。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DictionaryItem>> GetItemsByGroupIdAsync(Guid groupId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按分组编码集合获取字典项(可包含系统参数)。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DictionaryItem>> GetItemsByCodesAsync(IEnumerable<string> codes, Guid tenantId, bool includeSystem, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增字典项。
|
||||
/// </summary>
|
||||
Task AddItemAsync(DictionaryItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典项。
|
||||
/// </summary>
|
||||
Task RemoveItemAsync(DictionaryItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 后台账号实体(平台/商户/员工)。
|
||||
/// 管理后台账户实体(平台、租户或商户员工)。
|
||||
/// </summary>
|
||||
public sealed class IdentityUser
|
||||
public sealed class IdentityUser : IMultiTenantEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序用户。
|
||||
/// 小程序用户实体。
|
||||
/// </summary>
|
||||
public sealed class MiniUser
|
||||
public sealed class MiniUser : IMultiTenantEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
@@ -18,7 +18,7 @@ public sealed class MiniUser
|
||||
public string OpenId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 微信 UnionId,可为空。
|
||||
/// 微信 UnionId,可能为空。
|
||||
/// </summary>
|
||||
public string? UnionId { get; set; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 多租户感知 DbContext:自动应用租户过滤并填充租户字段。
|
||||
/// </summary>
|
||||
public abstract class TenantAwareDbContext : DbContext
|
||||
{
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
|
||||
protected TenantAwareDbContext(DbContextOptions options, ITenantProvider tenantProvider) : base(options)
|
||||
{
|
||||
_tenantProvider = tenantProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前请求租户 ID。
|
||||
/// </summary>
|
||||
protected Guid CurrentTenantId => _tenantProvider.GetCurrentTenantId();
|
||||
|
||||
/// <summary>
|
||||
/// 应用租户过滤器至所有实现 <see cref="IMultiTenantEntity"/> 的实体。
|
||||
/// </summary>
|
||||
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<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);
|
||||
}
|
||||
|
||||
private void ApplyTenantMetadata()
|
||||
{
|
||||
var tenantId = CurrentTenantId;
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<IMultiTenantEntity>())
|
||||
{
|
||||
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<IAuditableEntity>())
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
entry.Entity.CreatedAt = utcNow;
|
||||
entry.Entity.UpdatedAt = null;
|
||||
}
|
||||
else if (entry.State == EntityState.Modified)
|
||||
{
|
||||
entry.Entity.UpdatedAt = utcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 字典基础设施注册扩展。
|
||||
/// </summary>
|
||||
public static class DictionaryServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册字典模块基础设施。
|
||||
/// </summary>
|
||||
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.AddScoped<IDictionaryRepository, EfDictionaryRepository>();
|
||||
services.AddScoped<IDictionaryCache, DistributedDictionaryCache>();
|
||||
|
||||
services.AddOptions<DictionaryCacheOptions>()
|
||||
.Bind(configuration.GetSection("Dictionary:Cache"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存配置。
|
||||
/// </summary>
|
||||
public sealed class DictionaryCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 缓存滑动过期时间。
|
||||
/// </summary>
|
||||
public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(30);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典 DbContext。
|
||||
/// </summary>
|
||||
public sealed class DictionaryDbContext : TenantAwareDbContext
|
||||
{
|
||||
public DictionaryDbContext(DbContextOptions<DictionaryDbContext> options, ITenantProvider tenantProvider)
|
||||
: base(options, tenantProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<DictionaryGroup> DictionaryGroups => Set<DictionaryGroup>();
|
||||
public DbSet<DictionaryItem> DictionaryItems => Set<DictionaryItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>());
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>());
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> 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<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);
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 字典仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfDictionaryRepository : IDictionaryRepository
|
||||
{
|
||||
private readonly DictionaryDbContext _context;
|
||||
|
||||
public EfDictionaryRepository(DictionaryDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<DictionaryGroup?> FindGroupByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _context.DictionaryGroups.FirstOrDefaultAsync(group => group.Id == id, cancellationToken);
|
||||
|
||||
public Task<DictionaryGroup?> FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> _context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == code, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<DictionaryGroup>> 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<DictionaryItem?> FindItemByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _context.DictionaryItems.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<DictionaryItem>> 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<IReadOnlyList<DictionaryItem>> GetItemsByCodesAsync(IEnumerable<string> 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<DictionaryItem>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 IDistributedCache 的字典缓存实现。
|
||||
/// </summary>
|
||||
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<DictionaryCacheOptions> options)
|
||||
{
|
||||
_cache = cache;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>?> 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<List<DictionaryItemDto>>(payload, _serializerOptions);
|
||||
}
|
||||
|
||||
public Task SetAsync(Guid tenantId, string code, IReadOnlyList<DictionaryItemDto> 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()}";
|
||||
}
|
||||
@@ -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<IdentityDbContext>();
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<AdminSeedOptions>>().Value;
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证 DbContext。
|
||||
/// 身份认证 DbContext,带多租户过滤。
|
||||
/// </summary>
|
||||
public sealed class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : DbContext(options)
|
||||
public sealed class IdentityDbContext : TenantAwareDbContext
|
||||
{
|
||||
public IdentityDbContext(DbContextOptions<IdentityDbContext> options, ITenantProvider tenantProvider)
|
||||
: base(options, tenantProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
|
||||
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
|
||||
@@ -20,6 +26,7 @@ public sealed class IdentityDbContext(DbContextOptions<IdentityDbContext> option
|
||||
{
|
||||
ConfigureIdentityUser(modelBuilder.Entity<IdentityUser>());
|
||||
ConfigureMiniUser(modelBuilder.Entity<MiniUser>());
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureIdentityUser(EntityTypeBuilder<IdentityUser> builder)
|
||||
|
||||
@@ -69,7 +69,7 @@ public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptio
|
||||
/// </summary>
|
||||
/// <param name="profile">用户档案</param>
|
||||
/// <returns>Claims 集合</returns>
|
||||
private static IEnumerable<Claim> BuildClaims(CurrentUserProfile profile)
|
||||
private static List<Claim> BuildClaims(CurrentUserProfile profile)
|
||||
{
|
||||
var userId = profile.UserId.ToString();
|
||||
var claims = new List<Claim>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 字典模块服务扩展。
|
||||
/// </summary>
|
||||
public static class DictionaryModuleExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册字典模块应用层与基础设施。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDictionaryModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDictionaryApplication();
|
||||
services.AddDictionaryInfrastructure(configuration);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 多租户服务注册及中间件扩展。
|
||||
/// </summary>
|
||||
public static class TenantServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册租户上下文、解析中间件及默认租户提供者。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddTenantResolution(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
|
||||
services.TryAddScoped<ITenantProvider, TenantProvider>();
|
||||
|
||||
services.AddOptions<TenantResolutionOptions>()
|
||||
.Bind(configuration.GetSection("Tenancy"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用多租户解析中间件。
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app)
|
||||
=> app.UseMiddleware<TenantResolutionMiddleware>();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Threading;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Module.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 <see cref="AsyncLocal{T}"/> 的租户上下文访问器,实现请求级别隔离。
|
||||
/// </summary>
|
||||
public sealed class TenantContextAccessor : ITenantContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<TenantContextHolder?> Holder = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,24 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Module.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 默认租户提供者:优先从Header: X-Tenant-Id,其次从Token Claim: tenant_id
|
||||
/// 默认租户提供者:基于租户上下文访问器暴露当前租户 ID。
|
||||
/// </summary>
|
||||
public sealed class TenantProvider : ITenantProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ITenantContextAccessor _tenantContextAccessor;
|
||||
|
||||
public TenantProvider(IHttpContextAccessor httpContextAccessor)
|
||||
/// <summary>
|
||||
/// 初始化租户提供者。
|
||||
/// </summary>
|
||||
/// <param name="tenantContextAccessor">租户上下文访问器</param>
|
||||
public TenantProvider(ITenantContextAccessor tenantContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_tenantContextAccessor = tenantContextAccessor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 多租户解析中间件:支持 Header、域名与 Token Claim 的优先级解析。
|
||||
/// </summary>
|
||||
public sealed class TenantResolutionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<TenantResolutionMiddleware> _logger;
|
||||
private readonly ITenantContextAccessor _tenantContextAccessor;
|
||||
private readonly IOptionsMonitor<TenantResolutionOptions> _optionsMonitor;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化中间件。
|
||||
/// </summary>
|
||||
public TenantResolutionMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<TenantResolutionMiddleware> logger,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IOptionsMonitor<TenantResolutionOptions> optionsMonitor)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
_tenantContextAccessor = tenantContextAccessor;
|
||||
_optionsMonitor = optionsMonitor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析租户并将上下文注入请求。
|
||||
/// </summary>
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace TakeoutSaaS.Module.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 多租户解析配置项。
|
||||
/// </summary>
|
||||
public sealed class TenantResolutionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 通过 Header 解析租户 ID 时使用的头名称,默认 X-Tenant-Id。
|
||||
/// </summary>
|
||||
public string TenantIdHeaderName { get; set; } = "X-Tenant-Id";
|
||||
|
||||
/// <summary>
|
||||
/// 通过 Header 解析租户编码时使用的头名称,默认 X-Tenant-Code。
|
||||
/// </summary>
|
||||
public string TenantCodeHeaderName { get; set; } = "X-Tenant-Code";
|
||||
|
||||
/// <summary>
|
||||
/// 明确指定 host 与租户 ID 对应关系的映射表(精确匹配)。
|
||||
/// </summary>
|
||||
public IDictionary<string, Guid> DomainTenantMap { get; set; }
|
||||
= new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码到租户 ID 的映射表,用于 header 或子域名解析。
|
||||
/// </summary>
|
||||
public IDictionary<string, Guid> CodeTenantMap { get; set; }
|
||||
= new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 根域(不含子域),用于形如 {tenant}.rootDomain 的场景,例如 admin.takeoutsaas.com。
|
||||
/// </summary>
|
||||
public string? RootDomain { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 需要跳过租户解析的路径集合(如健康检查),默认仅包含 /health。
|
||||
/// </summary>
|
||||
public ISet<string> IgnoredPaths { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "/health" };
|
||||
|
||||
/// <summary>
|
||||
/// 若为 true,当无法解析租户时立即返回 400;否则交由上层自行判定。
|
||||
/// </summary>
|
||||
public bool ThrowIfUnresolved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对外只读视图,便于审计日志输出。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, Guid> DomainMappings => new ReadOnlyDictionary<string, Guid>(DomainTenantMap);
|
||||
|
||||
/// <summary>
|
||||
/// 对外只读的编码映射。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, Guid> CodeMappings => new ReadOnlyDictionary<string, Guid>(CodeTenantMap);
|
||||
}
|
||||
Reference in New Issue
Block a user