feat: 实现字典管理后端
This commit is contained in:
39
README.md
39
README.md
@@ -6,14 +6,15 @@
|
||||
|
||||
### 核心特性
|
||||
|
||||
- 🏢 **多租户架构**:支持多租户数据隔离,SaaS模式运营
|
||||
- 🍔 **商家管理**:完善的商家入驻、门店管理、菜品管理功能
|
||||
- 📦 **订单管理**:订单全生命周期管理,实时状态跟踪
|
||||
🚚 配送管理:配送任务、路线规划、第三方配送对接
|
||||
- 💰 **支付集成**:支持微信、支付宝等多种支付方式
|
||||
- 🎁 **营销功能**:优惠券、满减活动、会员积分
|
||||
- 📊 **数据分析**:实时数据统计、经营报表、趋势分析
|
||||
- 🔒 **安全可靠**:JWT认证、权限控制、数据加密
|
||||
- **多租户架构**:支持多租户数据隔离,SaaS模式运营
|
||||
- **商家管理**:完善的商家入驻、门店管理、菜品管理功能
|
||||
- **订单管理**:订单全生命周期管理,实时状态跟踪
|
||||
- **配送管理**:配送任务、路线规划、第三方配送对接
|
||||
- **支付集成**:支持微信、支付宝等多种支付方式
|
||||
- **营销功能**:优惠券、满减活动、会员积分
|
||||
- **字典管理**:系统字典、租户覆盖、批量导入导出、缓存监控
|
||||
- **数据分析**:实时数据统计、经营报表、趋势分析
|
||||
- **安全可靠**:JWT认证、权限控制、数据加密
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -80,8 +81,26 @@ dotnet run
|
||||
```
|
||||
|
||||
访问 API 文档:
|
||||
- 管理后台 AdminApi Swagger:http://localhost:5001/swagger
|
||||
- 小程序/用户端 MiniApi Swagger:http://localhost:5002/swagger
|
||||
- 管理后台 AdminApi Swagger:http://localhost:5001/api/docs
|
||||
- 小程序/用户端 MiniApi Swagger:http://localhost:5002/api/docs
|
||||
|
||||
## 字典管理
|
||||
|
||||
> 最后更新日期:2025-12-30
|
||||
|
||||
### 功能概述
|
||||
|
||||
- 系统/业务字典分组与字典项管理
|
||||
- 租户覆盖:隐藏系统项、自定义字典项、拖拽排序
|
||||
- CSV/JSON 批量导入导出
|
||||
- 两级缓存(Memory + Redis)与缓存监控指标
|
||||
|
||||
### 配置要点
|
||||
|
||||
- `ConnectionStrings:Redis`:Redis 连接字符串
|
||||
- `Database:DataSources:DictionaryDatabase`:字典库读写连接
|
||||
- `Dictionary:Cache:SlidingExpiration`:字典缓存滑动过期
|
||||
- `CacheWarmup:DictionaryCodes`:缓存预热字典编码列表
|
||||
|
||||
## 公告管理
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.AdminApi.Contracts.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导出请求。
|
||||
/// </summary>
|
||||
public sealed record DictionaryExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 导出格式(csv/json)。
|
||||
/// </summary>
|
||||
public string? Format { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Contracts.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入表单请求。
|
||||
/// </summary>
|
||||
public sealed record DictionaryImportFormRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 导入文件。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required IFormFile File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 冲突解决模式(Skip/Overwrite/Append)。
|
||||
/// </summary>
|
||||
public string? ConflictMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件格式(csv/json)。
|
||||
/// </summary>
|
||||
public string? Format { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存监控指标接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize(Roles = "PlatformAdmin")]
|
||||
[Route("api/admin/v{version:apiVersion}/dictionary/metrics")]
|
||||
public sealed class CacheMetricsController(
|
||||
CacheMetricsCollector metricsCollector,
|
||||
ICacheInvalidationLogRepository invalidationLogRepository)
|
||||
: BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取缓存统计信息。
|
||||
/// </summary>
|
||||
[HttpGet("cache-stats")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CacheStatsSnapshot>), StatusCodes.Status200OK)]
|
||||
public ApiResponse<CacheStatsSnapshot> GetCacheStats([FromQuery] string? timeRange = "1h")
|
||||
{
|
||||
var window = timeRange?.ToLowerInvariant() switch
|
||||
{
|
||||
"24h" => TimeSpan.FromHours(24),
|
||||
"7d" => TimeSpan.FromDays(7),
|
||||
_ => TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
var snapshot = metricsCollector.GetSnapshot(window);
|
||||
return ApiResponse<CacheStatsSnapshot>.Ok(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取缓存失效事件列表。
|
||||
/// </summary>
|
||||
[HttpGet("invalidation-events")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<CacheInvalidationLog>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<CacheInvalidationLog>>> GetInvalidationEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] DateTime? startDate = null,
|
||||
[FromQuery] DateTime? endDate = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var safePage = page <= 0 ? 1 : page;
|
||||
var safePageSize = pageSize <= 0 ? 20 : pageSize;
|
||||
|
||||
var (items, total) = await invalidationLogRepository.GetPagedAsync(
|
||||
safePage,
|
||||
safePageSize,
|
||||
startDate,
|
||||
endDate,
|
||||
cancellationToken);
|
||||
|
||||
var result = new PagedResult<CacheInvalidationLog>(items, safePage, safePageSize, total);
|
||||
return ApiResponse<PagedResult<CacheInvalidationLog>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using System.Net.Mime;
|
||||
using TakeoutSaaS.AdminApi.Contracts.Requests;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/dictionary/groups")]
|
||||
public sealed class DictionaryGroupsController(
|
||||
DictionaryCommandService commandService,
|
||||
DictionaryQueryService queryService,
|
||||
DictionaryImportExportService importExportService)
|
||||
: BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询字典分组。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("dictionary:group:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<DictionaryGroupDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<DictionaryGroupDto>>> List([FromQuery] DictionaryGroupQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await queryService.GetGroupsAsync(query, cancellationToken);
|
||||
return ApiResponse<PagedResult<DictionaryGroupDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取字典分组详情。
|
||||
/// </summary>
|
||||
[HttpGet("{groupId:long}")]
|
||||
[PermissionAuthorize("dictionary:group:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<DictionaryGroupDto>> Detail(long groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await queryService.GetGroupByIdAsync(groupId, cancellationToken);
|
||||
return result == null
|
||||
? ApiResponse<DictionaryGroupDto>.Error(ErrorCodes.NotFound, "字典分组不存在")
|
||||
: ApiResponse<DictionaryGroupDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典分组。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("dictionary:group:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryGroupDto>> Create([FromBody] CreateDictionaryGroupRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await commandService.CreateGroupAsync(request, cancellationToken);
|
||||
return ApiResponse<DictionaryGroupDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典分组。
|
||||
/// </summary>
|
||||
[HttpPut("{groupId:long}")]
|
||||
[PermissionAuthorize("dictionary:group:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryGroupDto>> Update(long groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await commandService.UpdateGroupAsync(groupId, request, cancellationToken);
|
||||
return ApiResponse<DictionaryGroupDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典分组。
|
||||
/// </summary>
|
||||
[HttpDelete("{groupId:long}")]
|
||||
[PermissionAuthorize("dictionary:group:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await commandService.DeleteGroupAsync(groupId, cancellationToken);
|
||||
return success
|
||||
? ApiResponse.Success()
|
||||
: ApiResponse.Error(ErrorCodes.NotFound, "字典分组不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出字典分组数据。
|
||||
/// </summary>
|
||||
[HttpPost("{groupId:long}/export")]
|
||||
[PermissionAuthorize("dictionary:group:read")]
|
||||
[Produces("application/octet-stream")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Export(long groupId, [FromBody] DictionaryExportRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var format = NormalizeFormat(request.Format);
|
||||
await using var stream = new MemoryStream();
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
await importExportService.ExportToJsonAsync(groupId, stream, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await importExportService.ExportToCsvAsync(groupId, stream, cancellationToken);
|
||||
}
|
||||
|
||||
var extension = format == "json" ? "json" : "csv";
|
||||
var fileName = $"dictionary_{groupId}_{DateTime.UtcNow:yyyyMMdd_HHmmss}.{extension}";
|
||||
Response.Headers[HeaderNames.ContentDisposition] = new ContentDispositionHeaderValue("attachment")
|
||||
{
|
||||
FileName = fileName,
|
||||
FileNameStar = fileName
|
||||
}.ToString();
|
||||
|
||||
var contentType = format == "json" ? MediaTypeNames.Application.Json : "text/csv";
|
||||
return File(stream.ToArray(), contentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入字典分组数据。
|
||||
/// </summary>
|
||||
[HttpPost("{groupId:long}/import")]
|
||||
[PermissionAuthorize("dictionary:item:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryImportResultDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryImportResultDto>> Import(
|
||||
long groupId,
|
||||
[FromForm] DictionaryImportFormRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.File.Length > 10 * 1024 * 1024)
|
||||
{
|
||||
return ApiResponse<DictionaryImportResultDto>.Error(ErrorCodes.BadRequest, "导入文件不能超过 10MB");
|
||||
}
|
||||
|
||||
var format = NormalizeFormat(request.Format);
|
||||
var conflictMode = ParseConflictMode(request.ConflictMode);
|
||||
|
||||
await using var stream = request.File.OpenReadStream();
|
||||
var importRequest = new DictionaryImportRequest
|
||||
{
|
||||
GroupId = groupId,
|
||||
FileName = request.File.FileName,
|
||||
FileSize = request.File.Length,
|
||||
ConflictMode = conflictMode,
|
||||
FileStream = stream
|
||||
};
|
||||
|
||||
var result = format == "json"
|
||||
? await importExportService.ImportFromJsonAsync(importRequest, cancellationToken)
|
||||
: await importExportService.ImportFromCsvAsync(importRequest, cancellationToken);
|
||||
|
||||
return ApiResponse<DictionaryImportResultDto>.Ok(result);
|
||||
}
|
||||
|
||||
private static string NormalizeFormat(string? format)
|
||||
{
|
||||
if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "json";
|
||||
}
|
||||
|
||||
return "csv";
|
||||
}
|
||||
|
||||
private static ConflictResolutionMode ParseConflictMode(string? conflictMode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(conflictMode))
|
||||
{
|
||||
return ConflictResolutionMode.Skip;
|
||||
}
|
||||
|
||||
return Enum.TryParse<ConflictResolutionMode>(conflictMode, ignoreCase: true, out var mode)
|
||||
? mode
|
||||
: ConflictResolutionMode.Skip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/dictionary/groups/{groupId:long}/items")]
|
||||
public sealed class DictionaryItemsController(
|
||||
DictionaryCommandService commandService,
|
||||
DictionaryQueryService queryService)
|
||||
: BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询字典项列表。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("dictionary:group:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<DictionaryItemDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<DictionaryItemDto>>> List(long groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await queryService.GetItemsByGroupIdAsync(groupId, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<DictionaryItemDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典项。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("dictionary:item:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryItemDto>> Create(long groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
request.GroupId = groupId;
|
||||
var result = await commandService.CreateItemAsync(request, cancellationToken);
|
||||
return ApiResponse<DictionaryItemDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典项。
|
||||
/// </summary>
|
||||
[HttpPut("{itemId:long}")]
|
||||
[PermissionAuthorize("dictionary:item:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryItemDto>> Update(long groupId, long itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = groupId;
|
||||
var result = await commandService.UpdateItemAsync(itemId, request, cancellationToken);
|
||||
return ApiResponse<DictionaryItemDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典项。
|
||||
/// </summary>
|
||||
[HttpDelete("{itemId:long}")]
|
||||
[PermissionAuthorize("dictionary:item:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long groupId, long itemId, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = groupId;
|
||||
var success = await commandService.DeleteItemAsync(itemId, cancellationToken);
|
||||
return success
|
||||
? ApiResponse.Success()
|
||||
: ApiResponse.Error(ErrorCodes.NotFound, "字典项不存在");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖配置管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/dictionary/overrides")]
|
||||
public sealed class DictionaryOverridesController(
|
||||
DictionaryOverrideService overrideService,
|
||||
ITenantProvider tenantProvider)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string TenantIdHeaderName = "X-Tenant-Id";
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前租户的覆盖配置列表。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("dictionary:override:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<OverrideConfigDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<OverrideConfigDto>>> List(CancellationToken cancellationToken)
|
||||
{
|
||||
var headerError = EnsureTenantHeader<IReadOnlyList<OverrideConfigDto>>();
|
||||
if (headerError != null)
|
||||
{
|
||||
return headerError;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var result = await overrideService.GetOverridesAsync(tenantId, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<OverrideConfigDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定字典分组的覆盖配置。
|
||||
/// </summary>
|
||||
[HttpGet("{groupCode}")]
|
||||
[PermissionAuthorize("dictionary:override:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OverrideConfigDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<OverrideConfigDto>> Detail(string groupCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var headerError = EnsureTenantHeader<OverrideConfigDto>();
|
||||
if (headerError != null)
|
||||
{
|
||||
return headerError;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var result = await overrideService.GetOverrideAsync(tenantId, groupCode, cancellationToken);
|
||||
return result == null
|
||||
? ApiResponse<OverrideConfigDto>.Error(ErrorCodes.NotFound, "覆盖配置不存在")
|
||||
: ApiResponse<OverrideConfigDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用覆盖模式。
|
||||
/// </summary>
|
||||
[HttpPost("{groupCode}/enable")]
|
||||
[PermissionAuthorize("dictionary:override:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OverrideConfigDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OverrideConfigDto>> Enable(string groupCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var headerError = EnsureTenantHeader<OverrideConfigDto>();
|
||||
if (headerError != null)
|
||||
{
|
||||
return headerError;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var result = await overrideService.EnableOverrideAsync(tenantId, groupCode, cancellationToken);
|
||||
return ApiResponse<OverrideConfigDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 禁用覆盖模式。
|
||||
/// </summary>
|
||||
[HttpPost("{groupCode}/disable")]
|
||||
[PermissionAuthorize("dictionary:override:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Disable(string groupCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var headerError = EnsureTenantHeader<object>();
|
||||
if (headerError != null)
|
||||
{
|
||||
return headerError;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var success = await overrideService.DisableOverrideAsync(tenantId, groupCode, cancellationToken);
|
||||
return success
|
||||
? ApiResponse.Success()
|
||||
: ApiResponse.Error(ErrorCodes.NotFound, "覆盖配置不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新隐藏的系统字典项。
|
||||
/// </summary>
|
||||
[HttpPut("{groupCode}/hidden-items")]
|
||||
[PermissionAuthorize("dictionary:override:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OverrideConfigDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OverrideConfigDto>> UpdateHiddenItems(
|
||||
string groupCode,
|
||||
[FromBody] DictionaryOverrideHiddenItemsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var headerError = EnsureTenantHeader<OverrideConfigDto>();
|
||||
if (headerError != null)
|
||||
{
|
||||
return headerError;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var result = await overrideService.UpdateHiddenItemsAsync(tenantId, groupCode, request.HiddenItemIds, cancellationToken);
|
||||
return ApiResponse<OverrideConfigDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新自定义排序。
|
||||
/// </summary>
|
||||
[HttpPut("{groupCode}/sort-order")]
|
||||
[PermissionAuthorize("dictionary:override:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OverrideConfigDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OverrideConfigDto>> UpdateSortOrder(
|
||||
string groupCode,
|
||||
[FromBody] DictionaryOverrideSortOrderRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var headerError = EnsureTenantHeader<OverrideConfigDto>();
|
||||
if (headerError != null)
|
||||
{
|
||||
return headerError;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var result = await overrideService.UpdateCustomSortOrderAsync(tenantId, groupCode, request.SortOrder, cancellationToken);
|
||||
return ApiResponse<OverrideConfigDto>.Ok(result);
|
||||
}
|
||||
|
||||
private ApiResponse<T>? EnsureTenantHeader<T>()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(TenantIdHeaderName, out var tenantHeader) || string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
return ApiResponse<T>.Error(StatusCodes.Status400BadRequest, $"缺少租户标识,请在请求头 {TenantIdHeaderName} 指定租户");
|
||||
}
|
||||
|
||||
if (!long.TryParse(tenantHeader.FirstOrDefault(), out _))
|
||||
{
|
||||
return ApiResponse<T>.Error(StatusCodes.Status400BadRequest, $"租户标识无效,请在请求头 {TenantIdHeaderName} 指定正确的租户 ID");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,15 @@
|
||||
"SlidingExpiration": "00:30:00"
|
||||
}
|
||||
},
|
||||
"CacheWarmup": {
|
||||
"DictionaryCodes": [
|
||||
"order_status",
|
||||
"payment_method",
|
||||
"shipping_method",
|
||||
"product_category",
|
||||
"user_role"
|
||||
]
|
||||
},
|
||||
"Tenancy": {
|
||||
"TenantIdHeaderName": "X-Tenant-Id",
|
||||
"TenantCodeHeaderName": "X-Tenant-Code",
|
||||
|
||||
@@ -66,6 +66,15 @@
|
||||
"SlidingExpiration": "00:30:00"
|
||||
}
|
||||
},
|
||||
"CacheWarmup": {
|
||||
"DictionaryCodes": [
|
||||
"order_status",
|
||||
"payment_method",
|
||||
"shipping_method",
|
||||
"product_category",
|
||||
"user_role"
|
||||
]
|
||||
},
|
||||
"Tenancy": {
|
||||
"TenantIdHeaderName": "X-Tenant-Id",
|
||||
"TenantCodeHeaderName": "X-Tenant-Code",
|
||||
|
||||
@@ -156,6 +156,8 @@
|
||||
"dictionary:item:create",
|
||||
"dictionary:item:update",
|
||||
"dictionary:item:delete",
|
||||
"dictionary:override:read",
|
||||
"dictionary:override:update",
|
||||
"system-parameter:create",
|
||||
"system-parameter:read",
|
||||
"system-parameter:update",
|
||||
@@ -265,6 +267,8 @@
|
||||
"dictionary:item:create",
|
||||
"dictionary:item:update",
|
||||
"dictionary:item:delete",
|
||||
"dictionary:override:read",
|
||||
"dictionary:override:update",
|
||||
"system-parameter:read"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.UserApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 字典查询接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/user/v{version:apiVersion}/dictionary")]
|
||||
public sealed class DictionaryController(DictionaryQueryService queryService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定字典分组的合并结果。
|
||||
/// </summary>
|
||||
[HttpGet("{code}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<DictionaryItemDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<DictionaryItemDto>>> GetByCode(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
Response.Headers[HeaderNames.CacheControl] = "max-age=1800";
|
||||
var result = await queryService.GetMergedDictionaryAsync(code, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<DictionaryItemDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (request.Codes.Count > 20)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "最多支持 20 个字典编码");
|
||||
}
|
||||
|
||||
var result = await queryService.BatchGetDictionariesAsync(request.Codes, cancellationToken);
|
||||
return ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
using TakeoutSaaS.Module.Dictionary.Extensions;
|
||||
using TakeoutSaaS.Module.Tenancy.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Kernel.Ids;
|
||||
@@ -44,6 +46,9 @@ if (isDevelopment)
|
||||
|
||||
// 4. 注册多租户与健康检查
|
||||
builder.Services.AddTenantResolution(builder.Configuration);
|
||||
builder.Services.AddJwtAuthentication(builder.Configuration);
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddDictionaryModule(builder.Configuration);
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// 5. 配置 OpenTelemetry 采集
|
||||
@@ -114,6 +119,8 @@ var app = builder.Build();
|
||||
app.UseCors("UserApiCors");
|
||||
app.UseTenantResolution();
|
||||
app.UseSharedWebCore();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSharedSwagger();
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// CSV 字典导入解析器。
|
||||
/// </summary>
|
||||
public interface ICsvDictionaryParser
|
||||
{
|
||||
/// <summary>
|
||||
/// 解析 CSV 数据。
|
||||
/// </summary>
|
||||
/// <param name="stream">输入流。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>解析后的记录列表。</returns>
|
||||
Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 字典两级缓存访问接口。
|
||||
/// </summary>
|
||||
public interface IDictionaryHybridCache
|
||||
{
|
||||
/// <summary>
|
||||
/// 读取缓存,不存在时通过工厂生成并回填。
|
||||
/// </summary>
|
||||
Task<T?> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
TimeSpan ttl,
|
||||
Func<CancellationToken, Task<T?>> factory,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀失效缓存。
|
||||
/// </summary>
|
||||
Task InvalidateAsync(
|
||||
string prefix,
|
||||
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// JSON 字典导入解析器。
|
||||
/// </summary>
|
||||
public interface IJsonDictionaryParser
|
||||
{
|
||||
/// <summary>
|
||||
/// 解析 JSON 数据。
|
||||
/// </summary>
|
||||
/// <param name="stream">输入流。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>解析后的记录列表。</returns>
|
||||
Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -26,6 +26,11 @@ public sealed class CreateDictionaryGroupRequest
|
||||
[Required]
|
||||
public DictionaryScope Scope { get; set; } = DictionaryScope.Business;
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许租户覆盖。
|
||||
/// </summary>
|
||||
public bool AllowOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
|
||||
@@ -19,14 +19,14 @@ public sealed class CreateDictionaryItemRequest
|
||||
/// <summary>
|
||||
/// 字典项键。
|
||||
/// </summary>
|
||||
[Required, MaxLength(64)]
|
||||
[Required, MaxLength(128)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项值。
|
||||
/// </summary>
|
||||
[Required, MaxLength(256)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
[Required]
|
||||
public Dictionary<string, string> Value { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认项。
|
||||
|
||||
@@ -12,6 +12,36 @@ public sealed class DictionaryGroupQuery
|
||||
/// </summary>
|
||||
public DictionaryScope? Scope { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(匹配编码或名称)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 启用状态过滤。
|
||||
/// </summary>
|
||||
public bool? IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分页页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 分页大小。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段。
|
||||
/// </summary>
|
||||
public string? SortBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序方向(asc/desc)。
|
||||
/// </summary>
|
||||
public string? SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含字典项。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入请求。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组 ID。
|
||||
/// </summary>
|
||||
public long GroupId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件名称。
|
||||
/// </summary>
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小(字节)。
|
||||
/// </summary>
|
||||
public long FileSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 冲突解决模式。
|
||||
/// </summary>
|
||||
public ConflictResolutionMode ConflictMode { get; init; } = ConflictResolutionMode.Skip;
|
||||
|
||||
/// <summary>
|
||||
/// 文件流。
|
||||
/// </summary>
|
||||
public Stream FileStream { get; init; } = Stream.Null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典覆盖隐藏项请求。
|
||||
/// </summary>
|
||||
public sealed class DictionaryOverrideHiddenItemsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 需要隐藏的系统字典项 ID 列表。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long[] HiddenItemIds { get; set; } = Array.Empty<long>();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典覆盖排序请求。
|
||||
/// </summary>
|
||||
public sealed class DictionaryOverrideSortOrderRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 排序配置(字典项 ID -> 排序值)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Dictionary<long, int> SortOrder { get; set; } = new();
|
||||
}
|
||||
@@ -19,8 +19,18 @@ public sealed class UpdateDictionaryGroupRequest
|
||||
[MaxLength(512)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许租户覆盖。
|
||||
/// </summary>
|
||||
public bool AllowOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 行版本,用于并发控制。
|
||||
/// </summary>
|
||||
public byte[]? RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,11 +7,17 @@ namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
/// </summary>
|
||||
public sealed class UpdateDictionaryItemRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 字典项键。
|
||||
/// </summary>
|
||||
[Required, MaxLength(128)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项值。
|
||||
/// </summary>
|
||||
[Required, MaxLength(256)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
[Required]
|
||||
public Dictionary<string, string> Value { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认项。
|
||||
@@ -33,4 +39,9 @@ public sealed class UpdateDictionaryItemRequest
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 行版本,用于并发控制。
|
||||
/// </summary>
|
||||
public byte[]? RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ public static class DictionaryServiceCollectionExtensions
|
||||
public static IServiceCollection AddDictionaryApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IDictionaryAppService, DictionaryAppService>();
|
||||
services.AddScoped<DictionaryCommandService>();
|
||||
services.AddScoped<DictionaryQueryService>();
|
||||
services.AddScoped<DictionaryMergeService>();
|
||||
services.AddScoped<DictionaryOverrideService>();
|
||||
services.AddScoped<DictionaryImportExportService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ public sealed class DictionaryGroupDto
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
/// </summary>
|
||||
@@ -35,11 +41,31 @@ public sealed class DictionaryGroupDto
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许覆盖。
|
||||
/// </summary>
|
||||
public bool AllowOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>
|
||||
/// 字典项集合。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 成功数量。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 跳过数量。
|
||||
/// </summary>
|
||||
public int SkipCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误数量。
|
||||
/// </summary>
|
||||
public int ErrorCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<ImportError> Errors { get; init; } = Array.Empty<ImportError>();
|
||||
|
||||
/// <summary>
|
||||
/// 处理耗时。
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 导入错误详情。
|
||||
/// </summary>
|
||||
public sealed class ImportError
|
||||
{
|
||||
/// <summary>
|
||||
/// 行号。
|
||||
/// </summary>
|
||||
public int RowNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 字段名。
|
||||
/// </summary>
|
||||
public string Field { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息。
|
||||
/// </summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入记录。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportRow
|
||||
{
|
||||
/// <summary>
|
||||
/// 行号(从 1 开始,包含表头行的偏移)。
|
||||
/// </summary>
|
||||
public int RowNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组编码。
|
||||
/// </summary>
|
||||
public string? Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典项键。
|
||||
/// </summary>
|
||||
public string? Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典项值(JSON 字符串)。
|
||||
/// </summary>
|
||||
public string? Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int? SortOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源:system / tenant。
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
@@ -28,7 +28,8 @@ public sealed class DictionaryItemDto
|
||||
/// <summary>
|
||||
/// 值。
|
||||
/// </summary>
|
||||
public string Value { get; init; } = string.Empty;
|
||||
[JsonPropertyName("value")]
|
||||
public Dictionary<string, string> Value { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认值。
|
||||
@@ -49,4 +50,14 @@ public sealed class DictionaryItemDto
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源:system / tenant。
|
||||
/// </summary>
|
||||
public string Source { get; init; } = "system";
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖配置 DTO。
|
||||
/// </summary>
|
||||
public sealed class OverrideConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 系统字典分组编码。
|
||||
/// </summary>
|
||||
public string SystemDictionaryGroupCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用覆盖。
|
||||
/// </summary>
|
||||
public bool OverrideEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 隐藏的系统字典项 ID 列表。
|
||||
/// </summary>
|
||||
public long[] HiddenSystemItemIds { get; init; } = Array.Empty<long>();
|
||||
|
||||
/// <summary>
|
||||
/// 自定义排序映射。
|
||||
/// </summary>
|
||||
public Dictionary<long, int> CustomSortOrder { get; init; } = new();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Security.Cryptography;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
@@ -47,8 +48,10 @@ public sealed class DictionaryAppService(
|
||||
Code = normalizedCode,
|
||||
Name = request.Name.Trim(),
|
||||
Scope = request.Scope,
|
||||
AllowOverride = request.AllowOverride,
|
||||
Description = request.Description?.Trim(),
|
||||
IsEnabled = true
|
||||
IsEnabled = true,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
};
|
||||
|
||||
// 4. 持久化并返回
|
||||
@@ -71,13 +74,32 @@ public sealed class DictionaryAppService(
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureScopePermission(group.Scope);
|
||||
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
if (!request.RowVersion.SequenceEqual(group.RowVersion))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
// 2. 更新字段
|
||||
group.Name = request.Name.Trim();
|
||||
group.Description = request.Description?.Trim();
|
||||
group.IsEnabled = request.IsEnabled;
|
||||
group.AllowOverride = request.AllowOverride;
|
||||
group.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
// 3. 持久化并失效缓存
|
||||
try
|
||||
{
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
||||
}
|
||||
await InvalidateCacheAsync(group, cancellationToken);
|
||||
logger.LogInformation("更新字典分组:{GroupId}", group.Id);
|
||||
return MapGroup(group, includeItems: false);
|
||||
@@ -154,11 +176,12 @@ public sealed class DictionaryAppService(
|
||||
TenantId = group.TenantId,
|
||||
GroupId = group.Id,
|
||||
Key = request.Key.Trim(),
|
||||
Value = request.Value.Trim(),
|
||||
Value = DictionaryValueConverter.Serialize(request.Value),
|
||||
Description = request.Description?.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
IsDefault = request.IsDefault,
|
||||
IsEnabled = request.IsEnabled
|
||||
IsEnabled = request.IsEnabled,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
};
|
||||
|
||||
// 3. 持久化并失效缓存
|
||||
@@ -183,15 +206,34 @@ public sealed class DictionaryAppService(
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
EnsureScopePermission(group.Scope);
|
||||
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
if (!request.RowVersion.SequenceEqual(item.RowVersion))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
// 2. 更新字段
|
||||
item.Value = request.Value.Trim();
|
||||
item.Key = request.Key.Trim();
|
||||
item.Value = DictionaryValueConverter.Serialize(request.Value);
|
||||
item.Description = request.Description?.Trim();
|
||||
item.SortOrder = request.SortOrder;
|
||||
item.IsDefault = request.IsDefault;
|
||||
item.IsEnabled = request.IsEnabled;
|
||||
item.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
// 3. 持久化并失效缓存
|
||||
try
|
||||
{
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
||||
}
|
||||
await InvalidateCacheAsync(group, cancellationToken);
|
||||
logger.LogInformation("更新字典项:{ItemId}", item.Id);
|
||||
return MapItem(item);
|
||||
@@ -381,10 +423,15 @@ public sealed class DictionaryAppService(
|
||||
{
|
||||
Id = group.Id,
|
||||
Code = group.Code,
|
||||
TenantId = group.TenantId,
|
||||
Name = group.Name,
|
||||
Scope = group.Scope,
|
||||
Description = group.Description,
|
||||
AllowOverride = group.AllowOverride,
|
||||
IsEnabled = group.IsEnabled,
|
||||
CreatedAt = group.CreatedAt,
|
||||
UpdatedAt = group.UpdatedAt,
|
||||
RowVersion = group.RowVersion,
|
||||
Items = includeItems ? items ?? group.Items.Select(MapItem).ToList() : Array.Empty<DictionaryItemDto>()
|
||||
};
|
||||
}
|
||||
@@ -395,10 +442,15 @@ public sealed class DictionaryAppService(
|
||||
Id = item.Id,
|
||||
GroupId = item.GroupId,
|
||||
Key = item.Key,
|
||||
Value = item.Value,
|
||||
Value = DictionaryValueConverter.Deserialize(item.Value),
|
||||
IsDefault = item.IsDefault,
|
||||
IsEnabled = item.IsEnabled,
|
||||
SortOrder = item.SortOrder,
|
||||
Description = item.Description
|
||||
Description = item.Description,
|
||||
Source = item.TenantId == 0 ? "system" : "tenant",
|
||||
RowVersion = item.RowVersion
|
||||
};
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存键生成器。
|
||||
/// </summary>
|
||||
internal static class DictionaryCacheKeys
|
||||
{
|
||||
internal const string DictionaryPrefix = "dict:";
|
||||
internal const string GroupPrefix = "dict:groups:";
|
||||
internal const string ItemPrefix = "dict:items:";
|
||||
|
||||
internal static string BuildDictionaryKey(long tenantId, DictionaryCode code)
|
||||
=> $"{DictionaryPrefix}{tenantId}:{code.Value}";
|
||||
|
||||
internal static string BuildGroupKey(
|
||||
long tenantId,
|
||||
DictionaryScope scope,
|
||||
int page,
|
||||
int pageSize,
|
||||
string? keyword,
|
||||
bool? isEnabled,
|
||||
string? sortBy,
|
||||
bool sortDescending)
|
||||
{
|
||||
return $"{GroupPrefix}{tenantId}:{scope}:{page}:{pageSize}:{Normalize(keyword)}:{Normalize(isEnabled)}:{Normalize(sortBy)}:{(sortDescending ? "desc" : "asc")}";
|
||||
}
|
||||
|
||||
internal static string BuildGroupPrefix(long tenantId)
|
||||
=> $"{GroupPrefix}{tenantId}:";
|
||||
|
||||
internal static string BuildItemKey(long groupId)
|
||||
=> $"{ItemPrefix}{groupId}";
|
||||
|
||||
private static string Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? "all"
|
||||
: value.Trim().ToLowerInvariant().Replace(":", "_", StringComparison.Ordinal);
|
||||
|
||||
private static string Normalize(bool? value)
|
||||
=> value.HasValue ? (value.Value ? "1" : "0") : "all";
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Security.Cryptography;
|
||||
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.Domain.Dictionary.ValueObjects;
|
||||
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 DictionaryCommandService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<DictionaryCommandService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建字典分组。
|
||||
/// </summary>
|
||||
public async Task<DictionaryGroupDto> CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var targetTenantId = ResolveTargetTenant(request.Scope);
|
||||
var code = new DictionaryCode(request.Code);
|
||||
|
||||
var existing = await groupRepository.GetByCodeAsync(targetTenantId, code, cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"字典分组编码 {code.Value} 已存在");
|
||||
}
|
||||
|
||||
var group = new DictionaryGroup
|
||||
{
|
||||
TenantId = targetTenantId,
|
||||
Code = code,
|
||||
Name = request.Name.Trim(),
|
||||
Scope = request.Scope,
|
||||
AllowOverride = request.AllowOverride,
|
||||
Description = request.Description?.Trim(),
|
||||
IsEnabled = true,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
};
|
||||
|
||||
await groupRepository.AddAsync(group, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildGroupPrefix(targetTenantId),
|
||||
CacheInvalidationOperation.Create,
|
||||
cancellationToken);
|
||||
|
||||
logger.LogInformation("创建字典分组 {GroupCode}", group.Code);
|
||||
return DictionaryMapper.ToGroupDto(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典分组。
|
||||
/// </summary>
|
||||
public async Task<DictionaryGroupDto> UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
EnsureRowVersion(request.RowVersion, group.RowVersion, "字典分组");
|
||||
|
||||
group.Name = request.Name.Trim();
|
||||
group.Description = request.Description?.Trim();
|
||||
group.IsEnabled = request.IsEnabled;
|
||||
group.AllowOverride = request.AllowOverride;
|
||||
group.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
try
|
||||
{
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
await InvalidateGroupCacheAsync(group, CacheInvalidationOperation.Update, cancellationToken);
|
||||
logger.LogInformation("更新字典分组 {GroupId}", group.Id);
|
||||
return DictionaryMapper.ToGroupDto(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典分组。
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteGroupAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
foreach (var item in items)
|
||||
{
|
||||
await itemRepository.RemoveAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
await groupRepository.RemoveAsync(group, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateGroupCacheAsync(group, CacheInvalidationOperation.Delete, cancellationToken);
|
||||
|
||||
logger.LogInformation("删除字典分组 {GroupId}", group.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典项。
|
||||
/// </summary>
|
||||
public async Task<DictionaryItemDto> CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(request.GroupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
var normalizedKey = request.Key.Trim();
|
||||
if (items.Any(item => string.Equals(item.Key, normalizedKey, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"字典项键 {normalizedKey} 已存在");
|
||||
}
|
||||
|
||||
var sortOrder = request.SortOrder;
|
||||
if (sortOrder <= 0)
|
||||
{
|
||||
sortOrder = items.Count == 0 ? 10 : items.Max(item => item.SortOrder) + 10;
|
||||
}
|
||||
|
||||
var item = new DictionaryItem
|
||||
{
|
||||
TenantId = group.TenantId,
|
||||
GroupId = group.Id,
|
||||
Key = normalizedKey,
|
||||
Value = DictionaryValueConverter.Serialize(request.Value),
|
||||
Description = request.Description?.Trim(),
|
||||
SortOrder = sortOrder,
|
||||
IsDefault = request.IsDefault,
|
||||
IsEnabled = request.IsEnabled,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
};
|
||||
|
||||
await itemRepository.AddAsync(item, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Create, cancellationToken);
|
||||
|
||||
logger.LogInformation("新增字典项 {ItemId}", item.Id);
|
||||
return DictionaryMapper.ToItemDto(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典项。
|
||||
/// </summary>
|
||||
public async Task<DictionaryItemDto> UpdateItemAsync(long itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await RequireItemAsync(itemId, cancellationToken);
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
EnsureRowVersion(request.RowVersion, item.RowVersion, "字典项");
|
||||
|
||||
var normalizedKey = request.Key.Trim();
|
||||
if (!string.Equals(item.Key, normalizedKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
if (items.Any(existing => existing.Id != item.Id && string.Equals(existing.Key, normalizedKey, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"字典项键 {normalizedKey} 已存在");
|
||||
}
|
||||
}
|
||||
|
||||
item.Key = normalizedKey;
|
||||
item.Value = DictionaryValueConverter.Serialize(request.Value);
|
||||
item.Description = request.Description?.Trim();
|
||||
item.SortOrder = request.SortOrder;
|
||||
item.IsDefault = request.IsDefault;
|
||||
item.IsEnabled = request.IsEnabled;
|
||||
item.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
try
|
||||
{
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Update, cancellationToken);
|
||||
logger.LogInformation("更新字典项 {ItemId}", item.Id);
|
||||
return DictionaryMapper.ToItemDto(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典项。
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteItemAsync(long itemId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await itemRepository.GetByIdAsync(itemId, cancellationToken);
|
||||
if (item == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
await itemRepository.RemoveAsync(item, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Delete, cancellationToken);
|
||||
|
||||
logger.LogInformation("删除字典项 {ItemId}", item.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private long ResolveTargetTenant(DictionaryScope scope)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (scope == DictionaryScope.System)
|
||||
{
|
||||
if (tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可创建系统字典");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "业务字典必须在租户上下文中创建");
|
||||
}
|
||||
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
private void EnsureGroupAccess(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
|
||||
if (group.Scope == DictionaryScope.Business && tenantId != group.TenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureRowVersion(byte[]? requestVersion, byte[] entityVersion, string resourceName)
|
||||
{
|
||||
if (requestVersion == null || requestVersion.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
if (!requestVersion.SequenceEqual(entityVersion))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"{resourceName}已被修改,请刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DictionaryGroup> RequireGroupAsync(long groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private async Task<DictionaryItem> RequireItemAsync(long itemId, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await itemRepository.GetByIdAsync(itemId, cancellationToken);
|
||||
if (item == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典项不存在");
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private Task InvalidateGroupCacheAsync(
|
||||
DictionaryGroup group,
|
||||
CacheInvalidationOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildGroupPrefix(group.TenantId), operation, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), operation, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), operation, cancellationToken)
|
||||
};
|
||||
|
||||
if (group.Scope == DictionaryScope.System)
|
||||
{
|
||||
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, operation, cancellationToken));
|
||||
}
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private Task InvalidateItemCacheAsync(
|
||||
DictionaryGroup group,
|
||||
CacheInvalidationOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), operation, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), operation, cancellationToken)
|
||||
};
|
||||
|
||||
if (group.Scope == DictionaryScope.System)
|
||||
{
|
||||
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, operation, cancellationToken));
|
||||
}
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
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.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入导出服务。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportExportService(
|
||||
ICsvDictionaryParser csvParser,
|
||||
IJsonDictionaryParser jsonParser,
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
IDictionaryImportLogRepository importLogRepository,
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUser,
|
||||
ILogger<DictionaryImportExportService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 导出 CSV。
|
||||
/// </summary>
|
||||
public async Task ExportToCsvAsync(long groupId, Stream output, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureGroupReadable(group);
|
||||
|
||||
var items = await ResolveExportItemsAsync(group, cancellationToken);
|
||||
await WriteCsvAsync(group, items, output, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出 JSON。
|
||||
/// </summary>
|
||||
public async Task ExportToJsonAsync(long groupId, Stream output, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureGroupReadable(group);
|
||||
|
||||
var items = await ResolveExportItemsAsync(group, cancellationToken);
|
||||
var payload = items.Select(item => new DictionaryExportRow
|
||||
{
|
||||
Code = group.Code.Value,
|
||||
Key = item.Key,
|
||||
Value = item.Value,
|
||||
SortOrder = item.SortOrder,
|
||||
IsEnabled = item.IsEnabled,
|
||||
Description = item.Description,
|
||||
Source = item.Source
|
||||
});
|
||||
|
||||
await JsonSerializer.SerializeAsync(output, payload, JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入 CSV。
|
||||
/// </summary>
|
||||
public async Task<DictionaryImportResultDto> ImportFromCsvAsync(DictionaryImportRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rows = await csvParser.ParseAsync(request.FileStream, cancellationToken);
|
||||
return await ImportAsync(request, rows, "CSV", cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入 JSON。
|
||||
/// </summary>
|
||||
public async Task<DictionaryImportResultDto> ImportFromJsonAsync(DictionaryImportRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rows = await jsonParser.ParseAsync(request.FileStream, cancellationToken);
|
||||
return await ImportAsync(request, rows, "JSON", cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<DictionaryImportResultDto> ImportAsync(
|
||||
DictionaryImportRequest request,
|
||||
IReadOnlyList<DictionaryImportRow> rows,
|
||||
string format,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var group = await RequireGroupAsync(request.GroupId, cancellationToken);
|
||||
EnsureGroupWritable(group);
|
||||
|
||||
var errors = new List<DictionaryImportResultDto.ImportError>();
|
||||
var validRows = new List<NormalizedRow>(rows.Count);
|
||||
var hasFatalErrors = false;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!TryNormalizeRow(group, row, errors, out var normalized))
|
||||
{
|
||||
hasFatalErrors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
validRows.Add(normalized);
|
||||
}
|
||||
|
||||
if (hasFatalErrors)
|
||||
{
|
||||
var failed = BuildResult(0, 0, errors, stopwatch.Elapsed);
|
||||
await RecordImportLogAsync(request, group, format, failed, stopwatch.Elapsed, cancellationToken);
|
||||
return failed;
|
||||
}
|
||||
|
||||
var existingItems = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
var existingMap = existingItems.ToDictionary(item => item.Key, StringComparer.OrdinalIgnoreCase);
|
||||
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var successCount = 0;
|
||||
var skipCount = 0;
|
||||
var nextSortOrder = existingItems.Count == 0 ? 0 : existingItems.Max(item => item.SortOrder);
|
||||
|
||||
foreach (var row in validRows)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!seenKeys.Add(row.Key))
|
||||
{
|
||||
skipCount++;
|
||||
errors.Add(CreateError(row.RowNumber, "key", $"字典项键 {row.Key} 在导入文件中重复"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingMap.TryGetValue(row.Key, out var existing))
|
||||
{
|
||||
if (request.ConflictMode != ConflictResolutionMode.Overwrite)
|
||||
{
|
||||
skipCount++;
|
||||
errors.Add(CreateError(row.RowNumber, "key", $"字典项键 {row.Key} 已存在"));
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplyUpdate(existing, row, existing.SortOrder, overwriteSort: row.SortOrder.HasValue);
|
||||
await itemRepository.UpdateAsync(existing, cancellationToken);
|
||||
successCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var sortOrder = row.SortOrder ?? 0;
|
||||
if (!row.SortOrder.HasValue)
|
||||
{
|
||||
nextSortOrder = nextSortOrder == 0 ? 10 : nextSortOrder + 10;
|
||||
sortOrder = nextSortOrder;
|
||||
}
|
||||
|
||||
var item = new DictionaryItem
|
||||
{
|
||||
TenantId = group.TenantId,
|
||||
GroupId = group.Id,
|
||||
Key = row.Key,
|
||||
Value = row.ValueJson,
|
||||
SortOrder = sortOrder,
|
||||
IsEnabled = row.IsEnabled ?? true,
|
||||
IsDefault = false,
|
||||
Description = row.Description,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
};
|
||||
|
||||
await itemRepository.AddAsync(item, cancellationToken);
|
||||
existingMap[item.Key] = item;
|
||||
successCount++;
|
||||
}
|
||||
|
||||
await itemRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateGroupCacheAsync(group, cancellationToken);
|
||||
|
||||
var result = BuildResult(successCount, skipCount, errors, stopwatch.Elapsed);
|
||||
await RecordImportLogAsync(request, group, format, result, stopwatch.Elapsed, cancellationToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ApplyUpdate(DictionaryItem item, NormalizedRow row, int defaultSortOrder, bool overwriteSort)
|
||||
{
|
||||
item.Key = row.Key;
|
||||
item.Value = row.ValueJson;
|
||||
if (overwriteSort)
|
||||
{
|
||||
item.SortOrder = row.SortOrder ?? defaultSortOrder;
|
||||
}
|
||||
|
||||
if (row.IsEnabled.HasValue)
|
||||
{
|
||||
item.IsEnabled = row.IsEnabled.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(row.Description))
|
||||
{
|
||||
item.Description = row.Description;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<DictionaryItemDto>> ResolveExportItemsAsync(DictionaryGroup group, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0)
|
||||
{
|
||||
var mergedItems = await itemRepository.GetMergedItemsAsync(tenantId, group.Id, includeOverrides: true, cancellationToken);
|
||||
return mergedItems.Select(DictionaryMapper.ToItemDto).ToList();
|
||||
}
|
||||
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
return items.Select(DictionaryMapper.ToItemDto).ToList();
|
||||
}
|
||||
|
||||
private static async Task WriteCsvAsync(
|
||||
DictionaryGroup group,
|
||||
IReadOnlyList<DictionaryItemDto> items,
|
||||
Stream output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var writer = new StreamWriter(output, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
|
||||
|
||||
await writer.WriteLineAsync("code,key,value,sortOrder,isEnabled,description,source");
|
||||
foreach (var item in items)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var valueJson = JsonSerializer.Serialize(item.Value, JsonOptions);
|
||||
var row = new[]
|
||||
{
|
||||
group.Code.Value,
|
||||
item.Key,
|
||||
valueJson,
|
||||
item.SortOrder.ToString(),
|
||||
item.IsEnabled ? "true" : "false",
|
||||
item.Description ?? string.Empty,
|
||||
item.Source
|
||||
};
|
||||
|
||||
await writer.WriteLineAsync(ToCsvRow(row));
|
||||
}
|
||||
|
||||
await writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string ToCsvRow(IEnumerable<string> fields)
|
||||
=> string.Join(",", fields.Select(EscapeCsvField));
|
||||
|
||||
private static string EscapeCsvField(string value)
|
||||
{
|
||||
if (value.Contains('"', StringComparison.Ordinal))
|
||||
{
|
||||
value = value.Replace("\"", "\"\"");
|
||||
}
|
||||
|
||||
if (value.Contains(',', StringComparison.Ordinal) ||
|
||||
value.Contains('\n', StringComparison.Ordinal) ||
|
||||
value.Contains('\r', StringComparison.Ordinal) ||
|
||||
value.Contains('"', StringComparison.Ordinal))
|
||||
{
|
||||
return $"\"{value}\"";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private bool TryNormalizeRow(
|
||||
DictionaryGroup group,
|
||||
DictionaryImportRow row,
|
||||
ICollection<DictionaryImportResultDto.ImportError> errors,
|
||||
out NormalizedRow normalized)
|
||||
{
|
||||
normalized = default;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(row.Code) &&
|
||||
!string.Equals(row.Code.Trim(), group.Code.Value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "code", "字典分组编码不匹配"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(row.Key))
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "key", "字典项键不能为空"));
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = row.Key.Trim();
|
||||
if (key.Length > 128)
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "key", "字典项键长度不能超过 128"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(row.Value))
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "value", "字典项值不能为空"));
|
||||
return false;
|
||||
}
|
||||
|
||||
string valueJson;
|
||||
try
|
||||
{
|
||||
var i18n = I18nValue.FromJson(row.Value);
|
||||
valueJson = i18n.ToJson();
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "value", "字典项值必须为合法的多语言 JSON"));
|
||||
return false;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "value", "字典项值必须为合法的多语言 JSON"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (row.SortOrder.HasValue && row.SortOrder.Value < 0)
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "sortOrder", "排序值不能小于 0"));
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = new NormalizedRow
|
||||
{
|
||||
RowNumber = row.RowNumber,
|
||||
Key = key,
|
||||
ValueJson = valueJson,
|
||||
SortOrder = row.SortOrder,
|
||||
IsEnabled = row.IsEnabled,
|
||||
Description = row.Description?.Trim()
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DictionaryImportResultDto BuildResult(
|
||||
int successCount,
|
||||
int skipCount,
|
||||
IReadOnlyList<DictionaryImportResultDto.ImportError> errors,
|
||||
TimeSpan duration)
|
||||
{
|
||||
return new DictionaryImportResultDto
|
||||
{
|
||||
SuccessCount = successCount,
|
||||
SkipCount = skipCount,
|
||||
ErrorCount = errors.Count,
|
||||
Errors = errors.ToArray(),
|
||||
Duration = duration
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RecordImportLogAsync(
|
||||
DictionaryImportRequest request,
|
||||
DictionaryGroup group,
|
||||
string format,
|
||||
DictionaryImportResultDto result,
|
||||
TimeSpan duration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var log = new DictionaryImportLog
|
||||
{
|
||||
TenantId = group.TenantId,
|
||||
OperatorId = currentUser.UserId,
|
||||
DictionaryGroupCode = group.Code.Value,
|
||||
FileName = request.FileName,
|
||||
FileSize = request.FileSize,
|
||||
Format = format,
|
||||
ConflictMode = request.ConflictMode,
|
||||
SuccessCount = result.SuccessCount,
|
||||
SkipCount = result.SkipCount,
|
||||
ErrorDetails = result.Errors.Count == 0 ? null : JsonSerializer.Serialize(result.Errors, JsonOptions),
|
||||
ProcessedAt = DateTime.UtcNow,
|
||||
Duration = duration
|
||||
};
|
||||
|
||||
await importLogRepository.AddAsync(log, cancellationToken);
|
||||
await importLogRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "记录字典导入日志失败");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InvalidateGroupCacheAsync(DictionaryGroup group, CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildGroupPrefix(group.TenantId), CacheInvalidationOperation.Update, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), CacheInvalidationOperation.Update, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), CacheInvalidationOperation.Update, cancellationToken)
|
||||
};
|
||||
|
||||
if (group.Scope == DictionaryScope.System)
|
||||
{
|
||||
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, CacheInvalidationOperation.Update, cancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private async Task<DictionaryGroup> RequireGroupAsync(long groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private void EnsureGroupAccess(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
|
||||
if (group.Scope == DictionaryScope.Business && tenantId != group.TenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureGroupReadable(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId != 0 && group.Scope == DictionaryScope.Business && group.TenantId != tenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureGroupWritable(DictionaryGroup group)
|
||||
{
|
||||
EnsureGroupAccess(group);
|
||||
}
|
||||
|
||||
private static DictionaryImportResultDto.ImportError CreateError(int rowNumber, string field, string message)
|
||||
=> new()
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Field = field,
|
||||
Message = message
|
||||
};
|
||||
|
||||
private sealed class DictionaryExportRow
|
||||
{
|
||||
public string Code { get; init; } = string.Empty;
|
||||
public string Key { get; init; } = string.Empty;
|
||||
public Dictionary<string, string> Value { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public int SortOrder { get; init; }
|
||||
public bool IsEnabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string Source { get; init; } = "system";
|
||||
}
|
||||
|
||||
private readonly struct NormalizedRow
|
||||
{
|
||||
public int RowNumber { get; init; }
|
||||
public string Key { get; init; }
|
||||
public string ValueJson { get; init; }
|
||||
public int? SortOrder { get; init; }
|
||||
public bool? IsEnabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典实体映射辅助。
|
||||
/// </summary>
|
||||
internal static class DictionaryMapper
|
||||
{
|
||||
internal static DictionaryGroupDto ToGroupDto(DictionaryGroup group, IReadOnlyList<DictionaryItemDto>? items = null)
|
||||
{
|
||||
return new DictionaryGroupDto
|
||||
{
|
||||
Id = group.Id,
|
||||
TenantId = group.TenantId,
|
||||
Code = group.Code,
|
||||
Name = group.Name,
|
||||
Scope = group.Scope,
|
||||
AllowOverride = group.AllowOverride,
|
||||
Description = group.Description,
|
||||
IsEnabled = group.IsEnabled,
|
||||
CreatedAt = group.CreatedAt,
|
||||
UpdatedAt = group.UpdatedAt,
|
||||
RowVersion = group.RowVersion,
|
||||
Items = items ?? Array.Empty<DictionaryItemDto>()
|
||||
};
|
||||
}
|
||||
|
||||
internal static DictionaryItemDto ToItemDto(DictionaryItem item)
|
||||
{
|
||||
return new DictionaryItemDto
|
||||
{
|
||||
Id = item.Id,
|
||||
GroupId = item.GroupId,
|
||||
Key = item.Key,
|
||||
Value = DictionaryValueConverter.Deserialize(item.Value),
|
||||
IsDefault = item.IsDefault,
|
||||
IsEnabled = item.IsEnabled,
|
||||
SortOrder = item.SortOrder,
|
||||
Description = item.Description,
|
||||
Source = item.TenantId == 0 ? "system" : "tenant",
|
||||
RowVersion = item.RowVersion
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典覆盖合并服务。
|
||||
/// </summary>
|
||||
public sealed class DictionaryMergeService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
ITenantDictionaryOverrideRepository overrideRepository)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 合并系统字典项与租户字典项。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>> MergeItemsAsync(
|
||||
long tenantId,
|
||||
long systemGroupId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var systemGroup = await groupRepository.GetByIdAsync(systemGroupId, cancellationToken);
|
||||
if (systemGroup == null || systemGroup.Scope != DictionaryScope.System || !systemGroup.IsEnabled)
|
||||
{
|
||||
return Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
|
||||
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroupId, cancellationToken);
|
||||
var activeSystem = systemItems.Where(item => item.IsEnabled).ToList();
|
||||
|
||||
if (tenantId == 0)
|
||||
{
|
||||
return activeSystem.Select(DictionaryMapper.ToItemDto).ToList();
|
||||
}
|
||||
|
||||
var overrideConfig = await overrideRepository.GetAsync(tenantId, systemGroupId, cancellationToken);
|
||||
if (overrideConfig == null || !overrideConfig.OverrideEnabled)
|
||||
{
|
||||
return activeSystem.Select(DictionaryMapper.ToItemDto).ToList();
|
||||
}
|
||||
|
||||
var tenantGroup = await groupRepository.GetByCodeAsync(tenantId, systemGroup.Code, cancellationToken);
|
||||
var tenantItems = tenantGroup != null && tenantGroup.IsEnabled
|
||||
? await itemRepository.GetByGroupIdAsync(tenantId, tenantGroup.Id, cancellationToken)
|
||||
: Array.Empty<DictionaryItem>();
|
||||
|
||||
var activeTenant = tenantItems.Where(item => item.IsEnabled).ToList();
|
||||
var hiddenSet = new HashSet<long>(overrideConfig.HiddenSystemItemIds);
|
||||
var merged = activeSystem
|
||||
.Where(item => !hiddenSet.Contains(item.Id))
|
||||
.Concat(activeTenant)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
|
||||
var sortOrder = ParseSortOrder(overrideConfig.CustomSortOrder);
|
||||
return merged
|
||||
.OrderBy(item => sortOrder.TryGetValue(item.Id, out var custom) ? custom : item.SortOrder)
|
||||
.ThenBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Dictionary<long, int> ParseSortOrder(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new Dictionary<long, int>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<long, int>>(json, JsonOptions) ?? new Dictionary<long, int>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<long, int>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖配置服务。
|
||||
/// </summary>
|
||||
public sealed class DictionaryOverrideService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
ITenantDictionaryOverrideRepository overrideRepository,
|
||||
IDictionaryHybridCache cache)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户覆盖配置列表。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<OverrideConfigDto>> GetOverridesAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var configs = await overrideRepository.ListAsync(tenantId, cancellationToken);
|
||||
if (configs.Count == 0)
|
||||
{
|
||||
return Array.Empty<OverrideConfigDto>();
|
||||
}
|
||||
|
||||
var groupIds = configs.Select(config => config.SystemDictionaryGroupId).Distinct().ToArray();
|
||||
var groups = await groupRepository.GetByIdsAsync(groupIds, cancellationToken);
|
||||
var codeMap = groups
|
||||
.Where(group => group.Scope == DictionaryScope.System)
|
||||
.ToDictionary(group => group.Id, group => group.Code);
|
||||
|
||||
var result = new List<OverrideConfigDto>(configs.Count);
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (codeMap.TryGetValue(config.SystemDictionaryGroupId, out var code))
|
||||
{
|
||||
result.Add(MapOverrideDto(config, code));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户指定分组的覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<OverrideConfigDto?> GetOverrideAsync(long tenantId, string systemGroupCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
|
||||
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
|
||||
return config == null ? null : MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<OverrideConfigDto> EnableOverrideAsync(long tenantId, string systemGroupCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
|
||||
if (!systemGroup.AllowOverride)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "该系统字典不允许租户覆盖");
|
||||
}
|
||||
|
||||
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
|
||||
if (config == null)
|
||||
{
|
||||
config = new TenantDictionaryOverride
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SystemDictionaryGroupId = systemGroup.Id,
|
||||
OverrideEnabled = true,
|
||||
HiddenSystemItemIds = Array.Empty<long>(),
|
||||
CustomSortOrder = "{}"
|
||||
};
|
||||
await overrideRepository.AddAsync(config, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.OverrideEnabled = true;
|
||||
await overrideRepository.UpdateAsync(config, cancellationToken);
|
||||
}
|
||||
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<bool> DisableOverrideAsync(long tenantId, string systemGroupCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
|
||||
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
|
||||
if (config == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
config.OverrideEnabled = false;
|
||||
await overrideRepository.UpdateAsync(config, cancellationToken);
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新隐藏系统字典项。
|
||||
/// </summary>
|
||||
public async Task<OverrideConfigDto> UpdateHiddenItemsAsync(
|
||||
long tenantId,
|
||||
string systemGroupCode,
|
||||
long[] hiddenIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
|
||||
if (!systemGroup.AllowOverride)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "该系统字典不允许租户覆盖");
|
||||
}
|
||||
|
||||
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, cancellationToken);
|
||||
var validIds = systemItems.Select(item => item.Id).ToHashSet();
|
||||
var normalized = hiddenIds?.Distinct().ToArray() ?? Array.Empty<long>();
|
||||
|
||||
if (normalized.Any(id => !validIds.Contains(id)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "隐藏项包含无效的系统字典项");
|
||||
}
|
||||
|
||||
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
|
||||
if (config == null)
|
||||
{
|
||||
config = new TenantDictionaryOverride
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SystemDictionaryGroupId = systemGroup.Id,
|
||||
OverrideEnabled = true,
|
||||
HiddenSystemItemIds = normalized,
|
||||
CustomSortOrder = "{}"
|
||||
};
|
||||
await overrideRepository.AddAsync(config, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.HiddenSystemItemIds = normalized;
|
||||
await overrideRepository.UpdateAsync(config, cancellationToken);
|
||||
}
|
||||
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新自定义排序配置。
|
||||
/// </summary>
|
||||
public async Task<OverrideConfigDto> UpdateCustomSortOrderAsync(
|
||||
long tenantId,
|
||||
string systemGroupCode,
|
||||
Dictionary<long, int> sortOrderMap,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
|
||||
if (!systemGroup.AllowOverride)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "该系统字典不允许租户覆盖");
|
||||
}
|
||||
|
||||
var validIds = await CollectValidItemIdsAsync(tenantId, systemGroup, cancellationToken);
|
||||
if (sortOrderMap.Keys.Any(id => !validIds.Contains(id)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "排序配置包含无效的字典项");
|
||||
}
|
||||
|
||||
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
|
||||
if (config == null)
|
||||
{
|
||||
config = new TenantDictionaryOverride
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SystemDictionaryGroupId = systemGroup.Id,
|
||||
OverrideEnabled = true,
|
||||
HiddenSystemItemIds = Array.Empty<long>(),
|
||||
CustomSortOrder = SerializeSortOrder(sortOrderMap)
|
||||
};
|
||||
await overrideRepository.AddAsync(config, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.CustomSortOrder = SerializeSortOrder(sortOrderMap);
|
||||
await overrideRepository.UpdateAsync(config, cancellationToken);
|
||||
}
|
||||
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
|
||||
private async Task<HashSet<long>> CollectValidItemIdsAsync(long tenantId, DictionaryGroup systemGroup, CancellationToken cancellationToken)
|
||||
{
|
||||
var validIds = (await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, cancellationToken))
|
||||
.Select(item => item.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var tenantGroup = await groupRepository.GetByCodeAsync(tenantId, systemGroup.Code, cancellationToken);
|
||||
if (tenantGroup != null)
|
||||
{
|
||||
foreach (var item in await itemRepository.GetByGroupIdAsync(tenantId, tenantGroup.Id, cancellationToken))
|
||||
{
|
||||
validIds.Add(item.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return validIds;
|
||||
}
|
||||
|
||||
private async Task<DictionaryGroup> RequireSystemGroupAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = new DictionaryCode(code);
|
||||
var group = await groupRepository.GetByCodeAsync(0, normalized, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "系统字典分组不存在");
|
||||
}
|
||||
|
||||
if (group.Scope != DictionaryScope.System)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "仅支持系统字典覆盖配置");
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static OverrideConfigDto MapOverrideDto(TenantDictionaryOverride config, DictionaryCode systemCode)
|
||||
{
|
||||
return new OverrideConfigDto
|
||||
{
|
||||
TenantId = config.TenantId,
|
||||
SystemDictionaryGroupCode = systemCode.Value,
|
||||
OverrideEnabled = config.OverrideEnabled,
|
||||
HiddenSystemItemIds = config.HiddenSystemItemIds,
|
||||
CustomSortOrder = ParseSortOrder(config.CustomSortOrder)
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<long, int> ParseSortOrder(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new Dictionary<long, int>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<long, int>>(json, JsonOptions) ?? new Dictionary<long, int>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<long, int>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeSortOrder(Dictionary<long, int> map)
|
||||
=> JsonSerializer.Serialize(map ?? new Dictionary<long, int>(), JsonOptions);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典查询服务。
|
||||
/// </summary>
|
||||
public sealed class DictionaryQueryService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
DictionaryMergeService mergeService,
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider)
|
||||
{
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// 获取字典分组分页数据。
|
||||
/// </summary>
|
||||
public async Task<PagedResult<DictionaryGroupDto>> GetGroupsAsync(
|
||||
DictionaryGroupQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var scope = query.Scope ?? (tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business);
|
||||
var sortDescending = string.Equals(query.SortOrder, "desc", StringComparison.OrdinalIgnoreCase);
|
||||
var targetTenant = scope == DictionaryScope.System ? 0 : tenantId;
|
||||
|
||||
var cacheKey = DictionaryCacheKeys.BuildGroupKey(
|
||||
targetTenant,
|
||||
scope,
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
query.Keyword,
|
||||
query.IsEnabled,
|
||||
query.SortBy,
|
||||
sortDescending);
|
||||
|
||||
var cached = await cache.GetOrCreateAsync<DictionaryGroupPage>(
|
||||
cacheKey,
|
||||
CacheTtl,
|
||||
async token =>
|
||||
{
|
||||
var groups = await groupRepository.GetPagedAsync(
|
||||
targetTenant,
|
||||
scope,
|
||||
query.Keyword,
|
||||
query.IsEnabled,
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
query.SortBy,
|
||||
sortDescending,
|
||||
token);
|
||||
|
||||
var total = await groupRepository.CountAsync(
|
||||
targetTenant,
|
||||
scope,
|
||||
query.Keyword,
|
||||
query.IsEnabled,
|
||||
token);
|
||||
|
||||
var items = new List<DictionaryGroupDto>(groups.Count);
|
||||
foreach (var group in groups)
|
||||
{
|
||||
IReadOnlyList<DictionaryItemDto>? groupItems = null;
|
||||
if (query.IncludeItems)
|
||||
{
|
||||
var groupItemEntities = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, token);
|
||||
groupItems = groupItemEntities
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
items.Add(DictionaryMapper.ToGroupDto(group, groupItems));
|
||||
}
|
||||
|
||||
return new DictionaryGroupPage
|
||||
{
|
||||
Items = items,
|
||||
Page = query.Page,
|
||||
PageSize = query.PageSize,
|
||||
TotalCount = total
|
||||
};
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var page = cached ?? new DictionaryGroupPage
|
||||
{
|
||||
Items = Array.Empty<DictionaryGroupDto>(),
|
||||
Page = query.Page,
|
||||
PageSize = query.PageSize,
|
||||
TotalCount = 0
|
||||
};
|
||||
|
||||
return new PagedResult<DictionaryGroupDto>(page.Items, page.Page, page.PageSize, page.TotalCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取字典分组详情。
|
||||
/// </summary>
|
||||
public async Task<DictionaryGroupDto?> GetGroupByIdAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
EnsureGroupReadable(group);
|
||||
return DictionaryMapper.ToGroupDto(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取分组下字典项列表。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = DictionaryCacheKeys.BuildItemKey(groupId);
|
||||
var cached = await cache.GetOrCreateAsync<IReadOnlyList<DictionaryItemDto>>(
|
||||
cacheKey,
|
||||
CacheTtl,
|
||||
async token =>
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, token);
|
||||
if (group == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
||||
}
|
||||
|
||||
EnsureGroupReadable(group);
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, groupId, token);
|
||||
return items
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return cached ?? Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取合并后的字典项列表。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>> GetMergedDictionaryAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!DictionaryCode.IsValid(code))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "字典编码格式不正确");
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalized = new DictionaryCode(code);
|
||||
var cacheKey = DictionaryCacheKeys.BuildDictionaryKey(tenantId, normalized);
|
||||
|
||||
var cached = await cache.GetOrCreateAsync<IReadOnlyList<DictionaryItemDto>>(
|
||||
cacheKey,
|
||||
CacheTtl,
|
||||
async token =>
|
||||
{
|
||||
var systemGroup = await groupRepository.GetByCodeAsync(0, normalized, token);
|
||||
if (systemGroup == null || !systemGroup.IsEnabled)
|
||||
{
|
||||
return Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
|
||||
if (tenantId == 0)
|
||||
{
|
||||
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, token);
|
||||
return systemItems
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return await mergeService.MergeItemsAsync(tenantId, systemGroup.Id, token);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return cached ?? Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取字典项。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>> BatchGetDictionariesAsync(
|
||||
IEnumerable<string> codes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCodes = codes
|
||||
.Where(DictionaryCode.IsValid)
|
||||
.Select(code => new DictionaryCode(code).Value)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var result = new Dictionary<string, IReadOnlyList<DictionaryItemDto>>(StringComparer.OrdinalIgnoreCase);
|
||||
if (normalizedCodes.Length == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var tasks = normalizedCodes.Select(async code =>
|
||||
{
|
||||
var items = await GetMergedDictionaryAsync(code, cancellationToken);
|
||||
return (code, items);
|
||||
});
|
||||
|
||||
foreach (var pair in await Task.WhenAll(tasks))
|
||||
{
|
||||
result[pair.code] = pair.items;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void EnsureGroupReadable(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId != 0 && group.Scope == DictionaryScope.Business && group.TenantId != tenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DictionaryGroupPage
|
||||
{
|
||||
public IReadOnlyList<DictionaryGroupDto> Items { get; init; } = Array.Empty<DictionaryGroupDto>();
|
||||
public int Page { get; init; }
|
||||
public int PageSize { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典值序列化与反序列化辅助。
|
||||
/// </summary>
|
||||
internal static class DictionaryValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// 将多语言字典序列化为 JSON。
|
||||
/// </summary>
|
||||
public static string Serialize(Dictionary<string, string> values)
|
||||
{
|
||||
var i18n = new I18nValue(values);
|
||||
return i18n.ToJson();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 JSON 解析为多语言字典。
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> Deserialize(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return I18nValue.FromJson(json).ToDictionary();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["zh-CN"] = json.Trim()
|
||||
};
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典分组请求验证器。
|
||||
/// </summary>
|
||||
public sealed class CreateDictionaryGroupValidator : AbstractValidator<CreateDictionaryGroupRequest>
|
||||
{
|
||||
public CreateDictionaryGroupValidator()
|
||||
{
|
||||
RuleFor(x => x.Code)
|
||||
.NotEmpty()
|
||||
.Length(2, 64)
|
||||
.Matches("^[a-zA-Z0-9_]+$");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(128);
|
||||
|
||||
RuleFor(x => x.Scope)
|
||||
.IsInEnum();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 多语言值校验器。
|
||||
/// </summary>
|
||||
public sealed class I18nValueValidator : AbstractValidator<Dictionary<string, string>>
|
||||
{
|
||||
public I18nValueValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.NotNull()
|
||||
.Must(HasAtLeastOneValue)
|
||||
.WithMessage("至少需要提供一种语言的值。");
|
||||
|
||||
RuleForEach(x => x.Keys)
|
||||
.NotEmpty()
|
||||
.Matches("^[a-zA-Z]{2,5}(-[a-zA-Z]{2,5})?$")
|
||||
.WithMessage("语言代码格式不正确。");
|
||||
}
|
||||
|
||||
private static bool HasAtLeastOneValue(Dictionary<string, string>? values)
|
||||
{
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return values.Any(pair => !string.IsNullOrWhiteSpace(pair.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典项请求验证器。
|
||||
/// </summary>
|
||||
public sealed class UpdateDictionaryItemValidator : AbstractValidator<UpdateDictionaryItemRequest>
|
||||
{
|
||||
public UpdateDictionaryItemValidator()
|
||||
{
|
||||
RuleFor(x => x.Key)
|
||||
.NotEmpty()
|
||||
.MaximumLength(128);
|
||||
|
||||
RuleFor(x => x.Value)
|
||||
.SetValidator(new I18nValueValidator());
|
||||
|
||||
RuleFor(x => x.SortOrder)
|
||||
.GreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
@@ -7,6 +9,8 @@ using System.Collections.Generic;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using FluentValidationException = FluentValidation.ValidationException;
|
||||
using SharedValidationException = TakeoutSaaS.Shared.Abstractions.Exceptions.ValidationException;
|
||||
|
||||
namespace TakeoutSaaS.Shared.Web.Middleware;
|
||||
|
||||
@@ -79,9 +83,27 @@ public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<Ex
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
ValidationException validationException => (
|
||||
DbUpdateConcurrencyException => (
|
||||
StatusCodes.Status409Conflict,
|
||||
ApiResponse<object>.Error(
|
||||
ErrorCodes.Conflict,
|
||||
"数据已被他人修改,请刷新后重试",
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
["RowVersion"] = ["数据已被他人修改,请刷新后重试"]
|
||||
})),
|
||||
UnauthorizedAccessException => (
|
||||
StatusCodes.Status403Forbidden,
|
||||
ApiResponse<object>.Error(ErrorCodes.Forbidden, "无权访问该资源")),
|
||||
SharedValidationException validationException => (
|
||||
StatusCodes.Status422UnprocessableEntity,
|
||||
ApiResponse<object>.Error(ErrorCodes.ValidationFailed, "请求参数验证失败", validationException.Errors)),
|
||||
FluentValidationException fluentValidationException => (
|
||||
StatusCodes.Status422UnprocessableEntity,
|
||||
ApiResponse<object>.Error(
|
||||
ErrorCodes.ValidationFailed,
|
||||
"请求参数验证失败",
|
||||
NormalizeValidationErrors(fluentValidationException.Errors))),
|
||||
BusinessException businessException => (
|
||||
// 1. 仅当业务错误码在白名单且位于 400-499 时透传,否则回退 400
|
||||
AllowedHttpErrorCodes.Contains(businessException.ErrorCode) && businessException.ErrorCode is >= 400 and < 500
|
||||
@@ -93,4 +115,25 @@ public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<Ex
|
||||
ApiResponse<object>.Error(ErrorCodes.InternalServerError, "服务器开小差啦,请稍后再试"))
|
||||
};
|
||||
}
|
||||
|
||||
private static IDictionary<string, string[]> NormalizeValidationErrors(IEnumerable<ValidationFailure> failures)
|
||||
{
|
||||
var result = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var failure in failures)
|
||||
{
|
||||
var key = string.IsNullOrWhiteSpace(failure.PropertyName) ? "request" : failure.PropertyName;
|
||||
if (!result.TryGetValue(key, out var list))
|
||||
{
|
||||
list = new List<string>();
|
||||
result[key] = list;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(failure.ErrorMessage))
|
||||
{
|
||||
list.Add(failure.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToDictionary(pair => pair.Key, pair => pair.Value.Distinct().ToArray(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,12 @@ public static class SwaggerExtensions
|
||||
{
|
||||
var provider = app.ApplicationServices.GetRequiredService<IApiVersionDescriptionProvider>();
|
||||
var settings = app.ApplicationServices.GetRequiredService<SwaggerDocumentSettings>();
|
||||
const string routePrefix = "api/docs";
|
||||
// 1. 注册 Swagger 中间件
|
||||
app.UseSwagger();
|
||||
app.UseSwagger(options => { options.RouteTemplate = $"{routePrefix}/{{documentName}}/swagger.json"; });
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.RoutePrefix = routePrefix;
|
||||
foreach (var description in provider.ApiVersionDescriptions)
|
||||
{
|
||||
// 3. 使用相对路径适配反向代理/网关前缀
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.0" />
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存失效日志。
|
||||
/// </summary>
|
||||
public sealed class CacheInvalidationLog : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 发生时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典编码。
|
||||
/// </summary>
|
||||
public string DictionaryCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 字典作用域。
|
||||
/// </summary>
|
||||
public DictionaryScope Scope { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 影响的缓存键数量。
|
||||
/// </summary>
|
||||
public int AffectedCacheKeyCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作人用户标识。
|
||||
/// </summary>
|
||||
public long OperatorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作类型。
|
||||
/// </summary>
|
||||
public CacheInvalidationOperation Operation { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
@@ -11,7 +12,7 @@ public sealed class DictionaryGroup : MultiTenantEntityBase
|
||||
/// <summary>
|
||||
/// 分组编码(唯一)。
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public DictionaryCode Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
@@ -23,6 +24,11 @@ public sealed class DictionaryGroup : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public DictionaryScope Scope { get; set; } = DictionaryScope.Business;
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许租户覆盖。
|
||||
/// </summary>
|
||||
public bool AllowOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
@@ -33,6 +39,11 @@ public sealed class DictionaryGroup : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>
|
||||
/// 字典项集合。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入审计日志。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportLog : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作人用户标识。
|
||||
/// </summary>
|
||||
public long OperatorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组编码。
|
||||
/// </summary>
|
||||
public string DictionaryGroupCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导入文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小(字节)。
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件格式(CSV/JSON)。
|
||||
/// </summary>
|
||||
public string Format { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 冲突处理模式。
|
||||
/// </summary>
|
||||
public ConflictResolutionMode ConflictMode { get; set; } = ConflictResolutionMode.Skip;
|
||||
|
||||
/// <summary>
|
||||
/// 成功导入数量。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 跳过数量。
|
||||
/// </summary>
|
||||
public int SkipCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误明细(JSON)。
|
||||
/// </summary>
|
||||
public string? ErrorDetails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 处理时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime ProcessedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 处理耗时。
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; set; }
|
||||
}
|
||||
@@ -42,6 +42,11 @@ public sealed class DictionaryItem : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性:所属分组。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖配置。
|
||||
/// </summary>
|
||||
public sealed class TenantDictionaryOverride : IMultiTenantEntity, IAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 系统字典分组 ID。
|
||||
/// </summary>
|
||||
public long SystemDictionaryGroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用覆盖。
|
||||
/// </summary>
|
||||
public bool OverrideEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 隐藏的系统字典项 ID 列表。
|
||||
/// </summary>
|
||||
public long[] HiddenSystemItemIds { get; set; } = Array.Empty<long>();
|
||||
|
||||
/// <summary>
|
||||
/// 自定义排序映射(JSON)。
|
||||
/// </summary>
|
||||
public string CustomSortOrder { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 删除时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建人用户标识。
|
||||
/// </summary>
|
||||
public long? CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后更新人用户标识。
|
||||
/// </summary>
|
||||
public long? UpdatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 删除人用户标识。
|
||||
/// </summary>
|
||||
public long? DeletedBy { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存失效操作类型。
|
||||
/// </summary>
|
||||
public enum CacheInvalidationOperation
|
||||
{
|
||||
Create = 1,
|
||||
Update = 2,
|
||||
Delete = 3
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入冲突处理策略。
|
||||
/// </summary>
|
||||
public enum ConflictResolutionMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 跳过重复项。
|
||||
/// </summary>
|
||||
Skip = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖重复项。
|
||||
/// </summary>
|
||||
Overwrite = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 追加新项。
|
||||
/// </summary>
|
||||
Append = 3
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存失效日志仓储。
|
||||
/// </summary>
|
||||
public interface ICacheInvalidationLogRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 新增失效日志。
|
||||
/// </summary>
|
||||
Task AddAsync(CacheInvalidationLog log, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询失效日志。
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<CacheInvalidationLog> Items, int TotalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 保存变更。
|
||||
/// </summary>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组仓储契约。
|
||||
/// </summary>
|
||||
public interface IDictionaryGroupRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 按 ID 获取字典分组。
|
||||
/// </summary>
|
||||
/// <param name="groupId">分组 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分组实体或 null。</returns>
|
||||
Task<DictionaryGroup?> GetByIdAsync(long groupId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按编码获取字典分组。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="code">分组编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分组实体或 null。</returns>
|
||||
Task<DictionaryGroup?> GetByCodeAsync(long tenantId, DictionaryCode code, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 分页获取字典分组。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="scope">作用域过滤。</param>
|
||||
/// <param name="keyword">关键字过滤。</param>
|
||||
/// <param name="isEnabled">启用状态过滤。</param>
|
||||
/// <param name="page">页码。</param>
|
||||
/// <param name="pageSize">页大小。</param>
|
||||
/// <param name="sortBy">排序字段。</param>
|
||||
/// <param name="sortDescending">是否降序。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分组集合。</returns>
|
||||
Task<IReadOnlyList<DictionaryGroup>> GetPagedAsync(
|
||||
long tenantId,
|
||||
DictionaryScope? scope,
|
||||
string? keyword,
|
||||
bool? isEnabled,
|
||||
int page,
|
||||
int pageSize,
|
||||
string? sortBy,
|
||||
bool sortDescending,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取满足条件的分组数量。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="scope">作用域过滤。</param>
|
||||
/// <param name="keyword">关键字过滤。</param>
|
||||
/// <param name="isEnabled">启用状态过滤。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分组数量。</returns>
|
||||
Task<int> CountAsync(
|
||||
long tenantId,
|
||||
DictionaryScope? scope,
|
||||
string? keyword,
|
||||
bool? isEnabled,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取字典分组。
|
||||
/// </summary>
|
||||
/// <param name="groupIds">分组 ID 列表。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分组集合。</returns>
|
||||
Task<IReadOnlyList<DictionaryGroup>> GetByIdsAsync(IEnumerable<long> groupIds, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增分组。
|
||||
/// </summary>
|
||||
/// <param name="group">分组实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task AddAsync(DictionaryGroup group, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新分组。
|
||||
/// </summary>
|
||||
/// <param name="group">分组实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task UpdateAsync(DictionaryGroup group, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除分组。
|
||||
/// </summary>
|
||||
/// <param name="group">分组实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task RemoveAsync(DictionaryGroup group, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入日志仓储契约。
|
||||
/// </summary>
|
||||
public interface IDictionaryImportLogRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 新增导入日志。
|
||||
/// </summary>
|
||||
/// <param name="log">导入日志。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task AddAsync(DictionaryImportLog log, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项仓储契约。
|
||||
/// </summary>
|
||||
public interface IDictionaryItemRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据 ID 获取字典项。
|
||||
/// </summary>
|
||||
/// <param name="itemId">字典项 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>字典项或 null。</returns>
|
||||
Task<DictionaryItem?> GetByIdAsync(long itemId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取分组下字典项列表。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="groupId">分组 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>字典项集合。</returns>
|
||||
Task<IReadOnlyList<DictionaryItem>> GetByGroupIdAsync(long tenantId, long groupId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取合并后的系统/租户字典项。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="systemGroupId">系统分组 ID。</param>
|
||||
/// <param name="includeOverrides">是否包含租户覆盖。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>字典项集合。</returns>
|
||||
Task<IReadOnlyList<DictionaryItem>> GetMergedItemsAsync(long tenantId, long systemGroupId, bool includeOverrides, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增字典项。
|
||||
/// </summary>
|
||||
/// <param name="item">字典项实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task AddAsync(DictionaryItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典项。
|
||||
/// </summary>
|
||||
/// <param name="item">字典项实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task UpdateAsync(DictionaryItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典项。
|
||||
/// </summary>
|
||||
/// <param name="item">字典项实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task RemoveAsync(DictionaryItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖仓储契约。
|
||||
/// </summary>
|
||||
public interface ITenantDictionaryOverrideRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取租户覆盖配置。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="systemGroupId">系统字典分组 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>覆盖配置或 null。</returns>
|
||||
Task<TenantDictionaryOverride?> GetAsync(long tenantId, long systemGroupId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户全部覆盖配置。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>覆盖配置列表。</returns>
|
||||
Task<IReadOnlyList<TenantDictionaryOverride>> ListAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增覆盖配置。
|
||||
/// </summary>
|
||||
/// <param name="overrideConfig">覆盖配置。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task AddAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新覆盖配置。
|
||||
/// </summary>
|
||||
/// <param name="overrideConfig">覆盖配置。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task UpdateAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组编码值对象。
|
||||
/// </summary>
|
||||
public readonly struct DictionaryCode : IEquatable<DictionaryCode>
|
||||
{
|
||||
private static readonly Regex CodePattern = new("^[a-zA-Z0-9_]{2,64}$", RegexOptions.Compiled);
|
||||
private readonly string? _value;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化字典编码并进行规范化。
|
||||
/// </summary>
|
||||
/// <param name="value">原始编码。</param>
|
||||
/// <exception cref="ArgumentException">编码非法时抛出。</exception>
|
||||
public DictionaryCode(string value)
|
||||
{
|
||||
_value = Normalize(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规范化后的编码值。
|
||||
/// </summary>
|
||||
public string Value => _value ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 判断编码是否符合规则。
|
||||
/// </summary>
|
||||
public static bool IsValid(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
return CodePattern.IsMatch(trimmed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规范化编码(去空格、转小写并校验)。
|
||||
/// </summary>
|
||||
/// <param name="value">原始编码。</param>
|
||||
/// <returns>规范化后的编码。</returns>
|
||||
/// <exception cref="ArgumentException">编码非法时抛出。</exception>
|
||||
public static string Normalize(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Dictionary code is required.", nameof(value));
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (!CodePattern.IsMatch(trimmed))
|
||||
{
|
||||
throw new ArgumentException("Dictionary code must be 2-64 characters of letters, digits, or underscore.", nameof(value));
|
||||
}
|
||||
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(DictionaryCode other)
|
||||
=> StringComparer.Ordinal.Equals(Value, other.Value);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj)
|
||||
=> obj is DictionaryCode other && Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
=> StringComparer.Ordinal.GetHashCode(Value);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
=> Value;
|
||||
|
||||
public static bool operator ==(DictionaryCode left, DictionaryCode right)
|
||||
=> left.Equals(right);
|
||||
|
||||
public static bool operator !=(DictionaryCode left, DictionaryCode right)
|
||||
=> !left.Equals(right);
|
||||
|
||||
public static implicit operator string(DictionaryCode code)
|
||||
=> code.Value;
|
||||
|
||||
public static implicit operator DictionaryCode(string value)
|
||||
=> new(value);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// 多语言字典值对象,封装语言键值映射。
|
||||
/// </summary>
|
||||
public sealed class I18nValue : IEquatable<I18nValue>
|
||||
{
|
||||
private static readonly string[] FallbackLocales = ["zh-CN", "en"];
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
private readonly Dictionary<string, string> _values;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化多语言值。
|
||||
/// </summary>
|
||||
/// <param name="values">语言键值映射。</param>
|
||||
/// <exception cref="ArgumentException">传入值为空或无有效条目时抛出。</exception>
|
||||
public I18nValue(IDictionary<string, string> values)
|
||||
{
|
||||
if (values == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(values));
|
||||
}
|
||||
|
||||
_values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_values[key.Trim()] = value.Trim();
|
||||
}
|
||||
|
||||
if (_values.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("I18nValue requires at least one non-empty entry.", nameof(values));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 语言键值只读视图。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Values => _values;
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定语言值,支持回退策略。
|
||||
/// </summary>
|
||||
/// <param name="locale">语言标识。</param>
|
||||
/// <returns>匹配语言值。</returns>
|
||||
public string Extract(string? locale)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(locale) && _values.TryGetValue(locale, out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
foreach (var fallback in FallbackLocales)
|
||||
{
|
||||
if (_values.TryGetValue(fallback, out var fallbackValue))
|
||||
{
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
return _values.Values.First();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 转换为普通字典。
|
||||
/// </summary>
|
||||
public Dictionary<string, string> ToDictionary()
|
||||
=> new(_values, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 转换为 JSON 字符串。
|
||||
/// </summary>
|
||||
public string ToJson()
|
||||
=> JsonSerializer.Serialize(_values, JsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// 从 JSON 字符串解析多语言值。
|
||||
/// </summary>
|
||||
public static I18nValue FromJson(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
throw new ArgumentException("JSON payload is required.", nameof(json));
|
||||
}
|
||||
|
||||
var values = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions) ?? new Dictionary<string, string>();
|
||||
return new I18nValue(values);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(I18nValue? other)
|
||||
{
|
||||
if (other == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(this, other))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_values.Count != other._values.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in _values)
|
||||
{
|
||||
if (!other._values.TryGetValue(key, out var otherValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(value, otherValue, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj)
|
||||
=> obj is I18nValue other && Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
foreach (var pair in _values.OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
hash.Add(pair.Key, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(pair.Value, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存命中/耗时指标采集器。
|
||||
/// </summary>
|
||||
public sealed class CacheMetricsCollector
|
||||
{
|
||||
private const string MeterName = "TakeoutSaaS.DictionaryCache";
|
||||
private static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
|
||||
private readonly Counter<long> _hitCounter;
|
||||
private readonly Counter<long> _missCounter;
|
||||
private readonly Counter<long> _invalidationCounter;
|
||||
private readonly Histogram<double> _durationHistogram;
|
||||
private readonly ConcurrentQueue<CacheQueryRecord> _queries = new();
|
||||
private readonly TimeSpan _retention = TimeSpan.FromDays(7);
|
||||
|
||||
private long _hitTotal;
|
||||
private long _missTotal;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化指标采集器。
|
||||
/// </summary>
|
||||
public CacheMetricsCollector()
|
||||
{
|
||||
_hitCounter = Meter.CreateCounter<long>("cache_hit_count");
|
||||
_missCounter = Meter.CreateCounter<long>("cache_miss_count");
|
||||
_invalidationCounter = Meter.CreateCounter<long>("cache_invalidation_count");
|
||||
_durationHistogram = Meter.CreateHistogram<double>("cache_query_duration_ms");
|
||||
|
||||
Meter.CreateObservableGauge(
|
||||
"cache_hit_ratio",
|
||||
() => new Measurement<double>(CalculateHitRatio()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存命中。
|
||||
/// </summary>
|
||||
public void RecordHit(string cacheLevel, string dictionaryCode)
|
||||
{
|
||||
Interlocked.Increment(ref _hitTotal);
|
||||
_hitCounter.Add(1, new TagList
|
||||
{
|
||||
{ "cache_level", cacheLevel },
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存未命中。
|
||||
/// </summary>
|
||||
public void RecordMiss(string cacheLevel, string dictionaryCode)
|
||||
{
|
||||
Interlocked.Increment(ref _missTotal);
|
||||
_missCounter.Add(1, new TagList
|
||||
{
|
||||
{ "cache_level", cacheLevel },
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存查询耗时。
|
||||
/// </summary>
|
||||
public void RecordDuration(string dictionaryCode, double durationMs)
|
||||
{
|
||||
_durationHistogram.Record(durationMs, new TagList
|
||||
{
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录查询详情,用于统计窗口分析。
|
||||
/// </summary>
|
||||
public void RecordQuery(string dictionaryCode, bool l1Hit, bool l2Hit, double durationMs)
|
||||
{
|
||||
var record = new CacheQueryRecord(DateTime.UtcNow, NormalizeCode(dictionaryCode), l1Hit, l2Hit, durationMs);
|
||||
_queries.Enqueue(record);
|
||||
PruneOldRecords();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存失效事件。
|
||||
/// </summary>
|
||||
public void RecordInvalidation(string dictionaryCode)
|
||||
{
|
||||
_invalidationCounter.Add(1, new TagList
|
||||
{
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定时间范围内的统计快照。
|
||||
/// </summary>
|
||||
public CacheStatsSnapshot GetSnapshot(TimeSpan window)
|
||||
{
|
||||
var since = DateTime.UtcNow.Subtract(window);
|
||||
var records = _queries.Where(record => record.Timestamp >= since).ToList();
|
||||
|
||||
var l1Hits = records.Count(record => record.L1Hit);
|
||||
var l1Misses = records.Count(record => !record.L1Hit);
|
||||
var l2Hits = records.Count(record => record.L2Hit);
|
||||
var l2Misses = records.Count(record => !record.L1Hit && !record.L2Hit);
|
||||
|
||||
var totalHits = l1Hits + l2Hits;
|
||||
var totalMisses = l1Misses + l2Misses;
|
||||
var hitRatio = totalHits + totalMisses == 0 ? 0 : totalHits / (double)(totalHits + totalMisses);
|
||||
var averageDuration = records.Count == 0 ? 0 : records.Average(record => record.DurationMs);
|
||||
|
||||
var topQueried = records
|
||||
.GroupBy(record => record.DictionaryCode)
|
||||
.Select(group => new DictionaryQueryCount(group.Key, group.Count()))
|
||||
.OrderByDescending(item => item.QueryCount)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
return new CacheStatsSnapshot(
|
||||
totalHits,
|
||||
totalMisses,
|
||||
hitRatio,
|
||||
new CacheLevelStats(l1Hits, l2Hits),
|
||||
new CacheLevelStats(l1Misses, l2Misses),
|
||||
averageDuration,
|
||||
topQueried);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从缓存键解析字典编码。
|
||||
/// </summary>
|
||||
public static string ExtractDictionaryCode(string cacheKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:groups:", StringComparison.Ordinal))
|
||||
{
|
||||
return "groups";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:items:", StringComparison.Ordinal))
|
||||
{
|
||||
return "items";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = cacheKey.Split(':', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
return parts[2];
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static string NormalizeCode(string? code)
|
||||
=> string.IsNullOrWhiteSpace(code) ? "unknown" : code.Trim().ToLowerInvariant();
|
||||
|
||||
private double CalculateHitRatio()
|
||||
{
|
||||
var hits = Interlocked.Read(ref _hitTotal);
|
||||
var misses = Interlocked.Read(ref _missTotal);
|
||||
return hits + misses == 0 ? 0 : hits / (double)(hits + misses);
|
||||
}
|
||||
|
||||
private void PruneOldRecords()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.Subtract(_retention);
|
||||
while (_queries.TryPeek(out var record) && record.Timestamp < cutoff)
|
||||
{
|
||||
_queries.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CacheQueryRecord(
|
||||
DateTime Timestamp,
|
||||
string DictionaryCode,
|
||||
bool L1Hit,
|
||||
bool L2Hit,
|
||||
double DurationMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 缓存统计快照。
|
||||
/// </summary>
|
||||
public sealed record CacheStatsSnapshot(
|
||||
long TotalHits,
|
||||
long TotalMisses,
|
||||
double HitRatio,
|
||||
CacheLevelStats HitsByLevel,
|
||||
CacheLevelStats MissesByLevel,
|
||||
double AverageQueryDurationMs,
|
||||
IReadOnlyList<DictionaryQueryCount> TopQueriedDictionaries);
|
||||
|
||||
/// <summary>
|
||||
/// 命中统计。
|
||||
/// </summary>
|
||||
public sealed record CacheLevelStats(long L1, long L2);
|
||||
|
||||
/// <summary>
|
||||
/// 字典查询次数统计。
|
||||
/// </summary>
|
||||
public sealed record DictionaryQueryCount(string Code, int QueryCount);
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存预热服务。
|
||||
/// </summary>
|
||||
public sealed class CacheWarmupService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DictionaryCacheWarmupOptions> options,
|
||||
ILogger<CacheWarmupService> logger) : IHostedService
|
||||
{
|
||||
private const int MaxWarmupCount = 10;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var codes = options.Value.DictionaryCodes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxWarmupCount)
|
||||
.ToArray();
|
||||
|
||||
if (codes.Length == 0)
|
||||
{
|
||||
logger.LogInformation("未配置字典缓存预热列表。");
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var queryService = scope.ServiceProvider.GetRequiredService<DictionaryQueryService>();
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await queryService.GetMergedDictionaryAsync(code, cancellationToken);
|
||||
logger.LogInformation("字典缓存预热完成: {DictionaryCode}", code);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "字典缓存预热失败: {DictionaryCode}", code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 两级缓存封装:L1 内存 + L2 Redis。
|
||||
/// </summary>
|
||||
public sealed class HybridCacheService : IDictionaryHybridCache
|
||||
{
|
||||
private static readonly RedisChannel InvalidationChannel = RedisChannel.Literal("dictionary:cache:invalidate");
|
||||
|
||||
private readonly MemoryCacheService _memoryCache;
|
||||
private readonly RedisCacheService _redisCache;
|
||||
private readonly ISubscriber? _subscriber;
|
||||
private readonly ILogger<HybridCacheService>? _logger;
|
||||
private readonly CacheMetricsCollector? _metrics;
|
||||
private readonly IServiceScopeFactory? _scopeFactory;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化两级缓存服务。
|
||||
/// </summary>
|
||||
public HybridCacheService(
|
||||
MemoryCacheService memoryCache,
|
||||
RedisCacheService redisCache,
|
||||
IConnectionMultiplexer? multiplexer = null,
|
||||
ILogger<HybridCacheService>? logger = null,
|
||||
CacheMetricsCollector? metrics = null,
|
||||
IServiceScopeFactory? scopeFactory = null)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_redisCache = redisCache;
|
||||
_logger = logger;
|
||||
_subscriber = multiplexer?.GetSubscriber();
|
||||
_metrics = metrics;
|
||||
_scopeFactory = scopeFactory;
|
||||
|
||||
if (_subscriber != null)
|
||||
{
|
||||
_subscriber.Subscribe(InvalidationChannel, (_, value) =>
|
||||
{
|
||||
var prefix = value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
_memoryCache.RemoveByPrefix(prefix);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取缓存,如果不存在则创建并回填。
|
||||
/// </summary>
|
||||
public async Task<T?> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
TimeSpan ttl,
|
||||
Func<CancellationToken, Task<T?>> factory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(key);
|
||||
var l1Hit = false;
|
||||
var l2Hit = false;
|
||||
|
||||
var cached = await _memoryCache.GetAsync<T>(key, cancellationToken);
|
||||
if (cached != null)
|
||||
{
|
||||
l1Hit = true;
|
||||
_metrics?.RecordHit("L1", dictionaryCode);
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_metrics?.RecordMiss("L1", dictionaryCode);
|
||||
|
||||
try
|
||||
{
|
||||
cached = await _redisCache.GetAsync<T>(key, cancellationToken);
|
||||
if (cached != null)
|
||||
{
|
||||
l2Hit = true;
|
||||
_metrics?.RecordHit("L2", dictionaryCode);
|
||||
await _memoryCache.SetAsync(key, cached, ttl, cancellationToken);
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_metrics?.RecordMiss("L2", dictionaryCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics?.RecordMiss("L2", dictionaryCode);
|
||||
_logger?.LogWarning(ex, "读取 Redis 缓存失败,降级为数据库查询。");
|
||||
}
|
||||
|
||||
var created = await factory(cancellationToken);
|
||||
if (created == null)
|
||||
{
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return default;
|
||||
}
|
||||
|
||||
await _memoryCache.SetAsync(key, created, ttl, cancellationToken);
|
||||
try
|
||||
{
|
||||
await _redisCache.SetAsync(key, created, ttl, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "写入 Redis 缓存失败。");
|
||||
}
|
||||
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return created;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 失效指定前缀的缓存键。
|
||||
/// </summary>
|
||||
public async Task InvalidateAsync(
|
||||
string prefix,
|
||||
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(prefix);
|
||||
_metrics?.RecordInvalidation(dictionaryCode);
|
||||
|
||||
var removedCount = _memoryCache.RemoveByPrefixWithCount(prefix);
|
||||
long redisRemoved = 0;
|
||||
try
|
||||
{
|
||||
redisRemoved = await _redisCache.RemoveByPrefixWithCountAsync(prefix, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "删除 Redis 缓存失败。");
|
||||
}
|
||||
|
||||
var totalRemoved = removedCount + (int)Math.Min(redisRemoved, int.MaxValue);
|
||||
|
||||
if (_subscriber != null && !string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
await _subscriber.PublishAsync(InvalidationChannel, prefix);
|
||||
}
|
||||
|
||||
_ = WriteInvalidationLogAsync(prefix, dictionaryCode, totalRemoved, operation);
|
||||
}
|
||||
|
||||
private async Task WriteInvalidationLogAsync(
|
||||
string prefix,
|
||||
string dictionaryCode,
|
||||
int removedCount,
|
||||
CacheInvalidationOperation operation)
|
||||
{
|
||||
if (_scopeFactory == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetService<ICacheInvalidationLogRepository>();
|
||||
if (repo == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentUser = scope.ServiceProvider.GetService<ICurrentUserAccessor>();
|
||||
var tenantId = TryExtractTenantId(prefix) ?? 0;
|
||||
var scopeType = tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business;
|
||||
|
||||
var log = new CacheInvalidationLog
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
DictionaryCode = dictionaryCode,
|
||||
Scope = scopeType,
|
||||
AffectedCacheKeyCount = removedCount,
|
||||
OperatorId = currentUser?.IsAuthenticated == true ? currentUser.UserId : 0,
|
||||
Operation = operation
|
||||
};
|
||||
|
||||
await repo.AddAsync(log);
|
||||
await repo.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "写入缓存失效日志失败。");
|
||||
}
|
||||
}
|
||||
|
||||
private static long? TryExtractTenantId(string prefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (prefix.StartsWith("dict:groups:", StringComparison.Ordinal))
|
||||
{
|
||||
var token = prefix.Replace("dict:groups:", string.Empty, StringComparison.Ordinal).Trim(':');
|
||||
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
|
||||
? tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
if (prefix.StartsWith("dict:", StringComparison.Ordinal) && !prefix.StartsWith("dict:items:", StringComparison.Ordinal))
|
||||
{
|
||||
var token = prefix.Replace("dict:", string.Empty, StringComparison.Ordinal);
|
||||
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
|
||||
? tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 本地内存缓存封装。
|
||||
/// </summary>
|
||||
public sealed class MemoryCacheService(IMemoryCache cache)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte> _keys = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// 读取缓存。
|
||||
/// </summary>
|
||||
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(cache.TryGetValue(key, out T? value) ? value : default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存。
|
||||
/// </summary>
|
||||
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cache.Set(key, value, new MemoryCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = ttl
|
||||
});
|
||||
_keys.TryAdd(key, 0);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除缓存键。
|
||||
/// </summary>
|
||||
public void Remove(string key)
|
||||
{
|
||||
cache.Remove(key);
|
||||
_keys.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键。
|
||||
/// </summary>
|
||||
public void RemoveByPrefix(string prefix)
|
||||
=> RemoveByPrefixWithCount(prefix);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键并返回数量。
|
||||
/// </summary>
|
||||
public int RemoveByPrefixWithCount(string prefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var removed = 0;
|
||||
foreach (var key in _keys.Keys)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
Remove(key);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有缓存。
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var key in _keys.Keys)
|
||||
{
|
||||
Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using System.Text.Json;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 缓存访问封装。
|
||||
/// </summary>
|
||||
public sealed class RedisCacheService(IDistributedCache cache, IConnectionMultiplexer? multiplexer = null)
|
||||
{
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly IDatabase? _database = multiplexer?.GetDatabase();
|
||||
private readonly IConnectionMultiplexer? _multiplexer = multiplexer;
|
||||
|
||||
/// <summary>
|
||||
/// 读取缓存。
|
||||
/// </summary>
|
||||
public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = await cache.GetAsync(key, cancellationToken);
|
||||
if (payload == null || payload.Length == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(payload, _serializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存。
|
||||
/// </summary>
|
||||
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(value, _serializerOptions);
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = ttl
|
||||
};
|
||||
return cache.SetAsync(key, payload, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除缓存键。
|
||||
/// </summary>
|
||||
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> cache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键。
|
||||
/// </summary>
|
||||
public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default)
|
||||
=> await RemoveByPrefixWithCountAsync(prefix, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键并返回数量。
|
||||
/// </summary>
|
||||
public async Task<long> RemoveByPrefixWithCountAsync(string prefix, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_multiplexer == null || _database == null || string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var pattern = prefix.EndsWith('*') ? prefix : $"{prefix}*";
|
||||
long removed = 0;
|
||||
foreach (var endpoint in _multiplexer.GetEndPoints())
|
||||
{
|
||||
var server = _multiplexer.GetServer(endpoint);
|
||||
foreach (var key in server.Keys(pattern: pattern))
|
||||
{
|
||||
await _database.KeyDeleteAsync(key).ConfigureAwait(false);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Caching.StackExchangeRedis;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.SystemParameters.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Common.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
@@ -30,13 +36,64 @@ public static class DictionaryServiceCollectionExtensions
|
||||
services.AddPostgresDbContext<DictionaryDbContext>(DatabaseConstants.DictionaryDataSource);
|
||||
|
||||
services.AddScoped<IDictionaryRepository, EfDictionaryRepository>();
|
||||
services.AddScoped<IDictionaryGroupRepository, DictionaryGroupRepository>();
|
||||
services.AddScoped<IDictionaryItemRepository, DictionaryItemRepository>();
|
||||
services.AddScoped<ITenantDictionaryOverrideRepository, TenantDictionaryOverrideRepository>();
|
||||
services.AddScoped<IDictionaryImportLogRepository, DictionaryImportLogRepository>();
|
||||
services.AddScoped<ICacheInvalidationLogRepository, CacheInvalidationLogRepository>();
|
||||
services.AddScoped<ISystemParameterRepository, EfSystemParameterRepository>();
|
||||
services.AddScoped<IDictionaryCache, DistributedDictionaryCache>();
|
||||
services.AddScoped<ICsvDictionaryParser, CsvDictionaryParser>();
|
||||
services.AddScoped<IJsonDictionaryParser, JsonDictionaryParser>();
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
var redisConnection = configuration.GetConnectionString("Redis");
|
||||
var hasDistributedCache = services.Any(descriptor => descriptor.ServiceType == typeof(IDistributedCache));
|
||||
if (!hasDistributedCache)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnection;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddDistributedMemoryCache();
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(redisConnection) && !services.Any(descriptor => descriptor.ServiceType == typeof(IConnectionMultiplexer)))
|
||||
{
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(redisConnection));
|
||||
}
|
||||
|
||||
services.AddSingleton<MemoryCacheService>();
|
||||
services.AddSingleton<CacheMetricsCollector>();
|
||||
services.AddSingleton(sp => new RedisCacheService(
|
||||
sp.GetRequiredService<IDistributedCache>(),
|
||||
sp.GetService<IConnectionMultiplexer>()));
|
||||
services.AddSingleton(sp => new HybridCacheService(
|
||||
sp.GetRequiredService<MemoryCacheService>(),
|
||||
sp.GetRequiredService<RedisCacheService>(),
|
||||
sp.GetService<IConnectionMultiplexer>(),
|
||||
sp.GetService<ILogger<HybridCacheService>>(),
|
||||
sp.GetService<CacheMetricsCollector>(),
|
||||
sp.GetService<IServiceScopeFactory>()));
|
||||
services.AddSingleton<IDictionaryHybridCache>(sp => sp.GetRequiredService<HybridCacheService>());
|
||||
|
||||
services.AddOptions<DictionaryCacheOptions>()
|
||||
.Bind(configuration.GetSection("Dictionary:Cache"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddOptions<DictionaryCacheWarmupOptions>()
|
||||
.Bind(configuration.GetSection("CacheWarmup"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddHostedService<CacheWarmupService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
|
||||
|
||||
/// <summary>
|
||||
/// CSV 字典导入解析器。
|
||||
/// </summary>
|
||||
public sealed class CsvDictionaryParser : ICsvDictionaryParser
|
||||
{
|
||||
private static readonly CsvConfiguration CsvConfiguration = new(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HasHeaderRecord = true,
|
||||
MissingFieldFound = null,
|
||||
BadDataFound = null,
|
||||
DetectColumnCountChanges = false,
|
||||
TrimOptions = TrimOptions.Trim,
|
||||
PrepareHeaderForMatch = args => args.Header?.Trim().ToLowerInvariant() ?? string.Empty
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
var rows = new List<DictionaryImportRow>();
|
||||
using var reader = new StreamReader(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||
using var csv = new CsvReader(reader, CsvConfiguration);
|
||||
|
||||
if (!await csv.ReadAsync() || !csv.ReadHeader())
|
||||
{
|
||||
return rows;
|
||||
}
|
||||
|
||||
while (await csv.ReadAsync())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var rowNumber = csv.Context?.Parser?.Row ?? 0;
|
||||
rows.Add(new DictionaryImportRow
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Code = ReadString(csv, "code"),
|
||||
Key = ReadString(csv, "key"),
|
||||
Value = ReadString(csv, "value"),
|
||||
SortOrder = ReadInt(csv, "sortorder"),
|
||||
IsEnabled = ReadBool(csv, "isenabled"),
|
||||
Description = ReadString(csv, "description"),
|
||||
Source = ReadString(csv, "source")
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string? ReadString(CsvReader csv, string name)
|
||||
{
|
||||
return csv.TryGetField(name, out string? value)
|
||||
? string.IsNullOrWhiteSpace(value) ? null : value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int? ReadInt(CsvReader csv, string name)
|
||||
{
|
||||
if (csv.TryGetField(name, out string? value) && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? ReadBool(CsvReader csv, string name)
|
||||
{
|
||||
if (csv.TryGetField(name, out string? value) && bool.TryParse(value, out var flag))
|
||||
{
|
||||
return flag;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
|
||||
|
||||
/// <summary>
|
||||
/// JSON 字典导入解析器。
|
||||
/// </summary>
|
||||
public sealed class JsonDictionaryParser : IJsonDictionaryParser
|
||||
{
|
||||
private static readonly JsonDocumentOptions DocumentOptions = new()
|
||||
{
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
using var document = await JsonDocument.ParseAsync(stream, DocumentOptions, cancellationToken);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<DictionaryImportRow>();
|
||||
}
|
||||
|
||||
var rows = new List<DictionaryImportRow>();
|
||||
var index = 0;
|
||||
|
||||
foreach (var element in document.RootElement.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
index++;
|
||||
|
||||
rows.Add(new DictionaryImportRow
|
||||
{
|
||||
RowNumber = index,
|
||||
Code = ReadString(element, "code"),
|
||||
Key = ReadString(element, "key"),
|
||||
Value = ReadValue(element, "value"),
|
||||
SortOrder = ReadInt(element, "sortOrder"),
|
||||
IsEnabled = ReadBool(element, "isEnabled"),
|
||||
Description = ReadString(element, "description"),
|
||||
Source = ReadString(element, "source")
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value) || value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.GetRawText();
|
||||
}
|
||||
|
||||
private static string? ReadValue(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value) || value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.GetRawText();
|
||||
}
|
||||
|
||||
private static int? ReadInt(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? ReadBool(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
return value.GetBoolean();
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String && bool.TryParse(value.GetString(), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存预热配置。
|
||||
/// </summary>
|
||||
public sealed class DictionaryCacheWarmupOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 预热字典编码列表(最多前 10 个)。
|
||||
/// </summary>
|
||||
public string[] DictionaryCodes { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Domain.SystemParameters.Entities;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
@@ -29,6 +30,21 @@ public sealed class DictionaryDbContext(
|
||||
/// </summary>
|
||||
public DbSet<DictionaryItem> DictionaryItems => Set<DictionaryItem>();
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖集合。
|
||||
/// </summary>
|
||||
public DbSet<TenantDictionaryOverride> TenantDictionaryOverrides => Set<TenantDictionaryOverride>();
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入日志集合。
|
||||
/// </summary>
|
||||
public DbSet<DictionaryImportLog> DictionaryImportLogs => Set<DictionaryImportLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 缓存失效日志集合。
|
||||
/// </summary>
|
||||
public DbSet<CacheInvalidationLog> CacheInvalidationLogs => Set<CacheInvalidationLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 系统参数集合。
|
||||
/// </summary>
|
||||
@@ -41,8 +57,13 @@ public sealed class DictionaryDbContext(
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>());
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>());
|
||||
var provider = Database.ProviderName;
|
||||
var isSqlite = provider != null && provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase);
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>(), isSqlite);
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>(), isSqlite);
|
||||
ConfigureOverride(modelBuilder.Entity<TenantDictionaryOverride>());
|
||||
ConfigureImportLog(modelBuilder.Entity<DictionaryImportLog>());
|
||||
ConfigureCacheInvalidationLog(modelBuilder.Entity<CacheInvalidationLog>());
|
||||
ConfigureSystemParameter(modelBuilder.Entity<SystemParameter>());
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
@@ -51,48 +72,140 @@ public sealed class DictionaryDbContext(
|
||||
/// 配置字典分组。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder)
|
||||
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder, bool isSqlite)
|
||||
{
|
||||
builder.ToTable("dictionary_groups");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.Code).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Code)
|
||||
.HasConversion(code => code.Value, value => new DictionaryCode(value))
|
||||
.HasMaxLength(64)
|
||||
.IsRequired();
|
||||
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Scope).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.AllowOverride).HasDefaultValue(false);
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
var rowVersion = builder.Property(x => x.RowVersion)
|
||||
.IsConcurrencyToken();
|
||||
|
||||
if (isSqlite)
|
||||
{
|
||||
rowVersion.ValueGeneratedNever();
|
||||
rowVersion.HasColumnType("BLOB");
|
||||
}
|
||||
else
|
||||
{
|
||||
rowVersion.IsRowVersion().HasColumnType("bytea");
|
||||
}
|
||||
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.Code })
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
builder.HasIndex(x => new { x.TenantId, x.Scope, x.IsEnabled });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置字典项。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder)
|
||||
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder, bool isSqlite)
|
||||
{
|
||||
builder.ToTable("dictionary_items");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.GroupId).IsRequired();
|
||||
builder.Property(x => x.Key).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Value).HasMaxLength(256).IsRequired();
|
||||
builder.Property(x => x.Key).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Value).HasColumnType("jsonb").IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.Property(x => x.SortOrder).HasDefaultValue(100);
|
||||
builder.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
var rowVersion = builder.Property(x => x.RowVersion)
|
||||
.IsConcurrencyToken();
|
||||
|
||||
if (isSqlite)
|
||||
{
|
||||
rowVersion.ValueGeneratedNever();
|
||||
rowVersion.HasColumnType("BLOB");
|
||||
}
|
||||
else
|
||||
{
|
||||
rowVersion.IsRowVersion().HasColumnType("bytea");
|
||||
}
|
||||
|
||||
builder.HasOne(x => x.Group)
|
||||
.WithMany(g => g.Items)
|
||||
.HasForeignKey(x => x.GroupId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.GroupId, x.Key }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.GroupId, x.Key })
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
builder.HasIndex(x => new { x.GroupId, x.IsEnabled, x.SortOrder });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置租户字典覆盖。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureOverride(EntityTypeBuilder<TenantDictionaryOverride> builder)
|
||||
{
|
||||
builder.ToTable("tenant_dictionary_overrides");
|
||||
builder.HasKey(x => new { x.TenantId, x.SystemDictionaryGroupId });
|
||||
builder.Property(x => x.OverrideEnabled).HasDefaultValue(false);
|
||||
builder.Property(x => x.HiddenSystemItemIds).HasColumnType("bigint[]");
|
||||
builder.Property(x => x.CustomSortOrder).HasColumnType("jsonb");
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
builder.HasIndex(x => x.HiddenSystemItemIds).HasMethod("gin");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置字典导入日志。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureImportLog(EntityTypeBuilder<DictionaryImportLog> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_import_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.OperatorId).IsRequired();
|
||||
builder.Property(x => x.DictionaryGroupCode).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.FileName).HasMaxLength(256).IsRequired();
|
||||
builder.Property(x => x.Format).HasMaxLength(16).IsRequired();
|
||||
builder.Property(x => x.ErrorDetails).HasColumnType("jsonb");
|
||||
builder.Property(x => x.ProcessedAt).IsRequired();
|
||||
builder.Property(x => x.Duration).HasColumnType("interval");
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.ProcessedAt });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置缓存失效日志。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureCacheInvalidationLog(EntityTypeBuilder<CacheInvalidationLog> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_cache_invalidation_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.DictionaryCode).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Scope).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.Operation).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.Timestamp).IsRequired();
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.Timestamp });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Initial system dictionary seed data.
|
||||
-- Target database: takeout_dictionary_db
|
||||
|
||||
-- ========================================
|
||||
-- 1. ORDER_STATUS
|
||||
-- ========================================
|
||||
INSERT INTO dictionary_groups ("Id", "TenantId", "Code", "Name", "Scope", "AllowOverride", "IsEnabled", "Description", "CreatedAt", "CreatedBy")
|
||||
VALUES
|
||||
(1001, 0, 'order_status', '订单状态', 1, true, true, '外卖订单生命周期状态', CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT ("TenantId", "Code") DO NOTHING;
|
||||
|
||||
INSERT INTO dictionary_items ("Id", "TenantId", "GroupId", "Key", "Value", "IsDefault", "IsEnabled", "SortOrder", "Description", "CreatedAt", "CreatedBy")
|
||||
VALUES
|
||||
(10011, 0, 1001, 'PENDING', '{"zh-CN":"待接单","en":"Pending"}', true, true, 10, '订单已创建,等待商家接单', CURRENT_TIMESTAMP, 0),
|
||||
(10012, 0, 1001, 'ACCEPTED', '{"zh-CN":"已接单","en":"Accepted"}', false, true, 20, '商家已接受订单', CURRENT_TIMESTAMP, 0),
|
||||
(10013, 0, 1001, 'PREPARING', '{"zh-CN":"制作中","en":"Preparing"}', false, true, 30, '商家正在准备餐品', CURRENT_TIMESTAMP, 0),
|
||||
(10014, 0, 1001, 'DELIVERING', '{"zh-CN":"配送中","en":"Delivering"}', false, true, 40, '骑手正在配送', CURRENT_TIMESTAMP, 0),
|
||||
(10015, 0, 1001, 'COMPLETED', '{"zh-CN":"已完成","en":"Completed"}', false, true, 50, '订单已送达', CURRENT_TIMESTAMP, 0),
|
||||
(10016, 0, 1001, 'CANCELLED', '{"zh-CN":"已取消","en":"Cancelled"}', false, true, 60, '订单已取消', CURRENT_TIMESTAMP, 0),
|
||||
(10017, 0, 1001, 'REFUNDED', '{"zh-CN":"已退款","en":"Refunded"}', false, true, 70, '订单已退款', CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT ("TenantId", "GroupId", "Key") DO NOTHING;
|
||||
|
||||
-- ========================================
|
||||
-- 2. PAYMENT_METHOD
|
||||
-- ========================================
|
||||
INSERT INTO dictionary_groups ("Id", "TenantId", "Code", "Name", "Scope", "AllowOverride", "IsEnabled", "Description", "CreatedAt", "CreatedBy")
|
||||
VALUES
|
||||
(1002, 0, 'payment_method', '支付方式', 1, true, true, '订单支付方式选项', CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT ("TenantId", "Code") DO NOTHING;
|
||||
|
||||
INSERT INTO dictionary_items ("Id", "TenantId", "GroupId", "Key", "Value", "IsDefault", "IsEnabled", "SortOrder", "Description", "CreatedAt", "CreatedBy")
|
||||
VALUES
|
||||
(10021, 0, 1002, 'ALIPAY', '{"zh-CN":"支付宝","en":"Alipay"}', true, true, 10, '支付宝在线支付', CURRENT_TIMESTAMP, 0),
|
||||
(10022, 0, 1002, 'WECHAT', '{"zh-CN":"微信支付","en":"WeChat Pay"}', false, true, 20, '微信在线支付', CURRENT_TIMESTAMP, 0),
|
||||
(10023, 0, 1002, 'CREDIT_CARD', '{"zh-CN":"信用卡","en":"Credit Card"}', false, true, 30, '信用卡支付', CURRENT_TIMESTAMP, 0),
|
||||
(10024, 0, 1002, 'BALANCE', '{"zh-CN":"余额支付","en":"Balance"}', false, true, 40, '账户余额支付', CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT ("TenantId", "GroupId", "Key") DO NOTHING;
|
||||
|
||||
-- ========================================
|
||||
-- 3. SHIPPING_METHOD
|
||||
-- ========================================
|
||||
INSERT INTO dictionary_groups ("Id", "TenantId", "Code", "Name", "Scope", "AllowOverride", "IsEnabled", "Description", "CreatedAt", "CreatedBy")
|
||||
VALUES
|
||||
(1003, 0, 'shipping_method', '配送方式', 1, true, true, '订单配送方式', CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT ("TenantId", "Code") DO NOTHING;
|
||||
|
||||
INSERT INTO dictionary_items ("Id", "TenantId", "GroupId", "Key", "Value", "IsDefault", "IsEnabled", "SortOrder", "Description", "CreatedAt", "CreatedBy")
|
||||
VALUES
|
||||
(10031, 0, 1003, 'PLATFORM_DELIVERY', '{"zh-CN":"平台配送","en":"Platform Delivery"}', true, true, 10, '平台自有骑手配送', CURRENT_TIMESTAMP, 0),
|
||||
(10032, 0, 1003, 'MERCHANT_DELIVERY', '{"zh-CN":"商家配送","en":"Merchant Delivery"}', false, true, 20, '商家自行配送', CURRENT_TIMESTAMP, 0),
|
||||
(10033, 0, 1003, 'SELF_PICKUP', '{"zh-CN":"到店自取","en":"Self Pickup"}', false, true, 30, '顾客到店自取', CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT ("TenantId", "GroupId", "Key") DO NOTHING;
|
||||
|
||||
-- ========================================
|
||||
-- 4. PRODUCT_CATEGORY
|
||||
-- ========================================
|
||||
INSERT INTO dictionary_groups ("Id", "TenantId", "Code", "Name", "Scope", "AllowOverride", "IsEnabled", "Description", "CreatedAt", "CreatedBy")
|
||||
VALUES
|
||||
(1004, 0, 'product_category', '商品分类', 1, false, true, '外卖商品通用分类 (不允许租户覆盖)', CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT ("TenantId", "Code") DO NOTHING;
|
||||
|
||||
INSERT INTO dictionary_items ("Id", "TenantId", "GroupId", "Key", "Value", "IsDefault", "IsEnabled", "SortOrder", "Description", "CreatedAt", "CreatedBy")
|
||||
VALUES
|
||||
(10041, 0, 1004, 'STAPLE_FOOD', '{"zh-CN":"主食","en":"Staple Food"}', false, true, 10, '米饭、面条等主食', CURRENT_TIMESTAMP, 0),
|
||||
(10042, 0, 1004, 'APPETIZER', '{"zh-CN":"小吃/开胃菜","en":"Appetizer"}', false, true, 20, '小吃和开胃菜', CURRENT_TIMESTAMP, 0),
|
||||
(10043, 0, 1004, 'MAIN_COURSE', '{"zh-CN":"主菜","en":"Main Course"}', true, true, 30, '正餐主菜', CURRENT_TIMESTAMP, 0),
|
||||
(10044, 0, 1004, 'BEVERAGE', '{"zh-CN":"饮品","en":"Beverage"}', false, true, 40, '饮料、茶饮等', CURRENT_TIMESTAMP, 0),
|
||||
(10045, 0, 1004, 'DESSERT', '{"zh-CN":"甜品","en":"Dessert"}', false, true, 50, '甜品、糕点', CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT ("TenantId", "GroupId", "Key") DO NOTHING;
|
||||
|
||||
-- ========================================
|
||||
-- 5. USER_ROLE
|
||||
-- ========================================
|
||||
INSERT INTO dictionary_groups ("Id", "TenantId", "Code", "Name", "Scope", "AllowOverride", "IsEnabled", "Description", "CreatedAt", "CreatedBy")
|
||||
VALUES
|
||||
(1005, 0, 'user_role', '用户角色', 1, false, true, '系统用户角色类型 (平台级,不可覆盖)', CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT ("TenantId", "Code") DO NOTHING;
|
||||
|
||||
INSERT INTO dictionary_items ("Id", "TenantId", "GroupId", "Key", "Value", "IsDefault", "IsEnabled", "SortOrder", "Description", "CreatedAt", "CreatedBy")
|
||||
VALUES
|
||||
(10051, 0, 1005, 'PLATFORM_ADMIN', '{"zh-CN":"平台管理员","en":"Platform Admin"}', false, true, 10, '平台超级管理员', CURRENT_TIMESTAMP, 0),
|
||||
(10052, 0, 1005, 'TENANT_ADMIN', '{"zh-CN":"租户管理员","en":"Tenant Admin"}', false, true, 20, '租户企业管理员', CURRENT_TIMESTAMP, 0),
|
||||
(10053, 0, 1005, 'TENANT_USER', '{"zh-CN":"租户员工","en":"Tenant User"}', true, true, 30, '租户普通员工', CURRENT_TIMESTAMP, 0),
|
||||
(10054, 0, 1005, 'CUSTOMER', '{"zh-CN":"顾客","en":"Customer"}', false, true, 40, '终端用户/顾客', CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT ("TenantId", "GroupId", "Key") DO NOTHING;
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存失效日志仓储实现。
|
||||
/// </summary>
|
||||
public sealed class CacheInvalidationLogRepository(DictionaryDbContext context) : ICacheInvalidationLogRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 新增失效日志。
|
||||
/// </summary>
|
||||
public Task AddAsync(CacheInvalidationLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.CacheInvalidationLogs.Add(log);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询失效日志。
|
||||
/// </summary>
|
||||
public async Task<(IReadOnlyList<CacheInvalidationLog> Items, int TotalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.CacheInvalidationLogs.AsNoTracking();
|
||||
|
||||
if (startDate.HasValue)
|
||||
{
|
||||
query = query.Where(log => log.Timestamp >= startDate.Value);
|
||||
}
|
||||
|
||||
if (endDate.HasValue)
|
||||
{
|
||||
query = query.Where(log => log.Timestamp <= endDate.Value);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(log => log.Timestamp)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存变更。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组仓储实现。
|
||||
/// </summary>
|
||||
public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDictionaryGroupRepository
|
||||
{
|
||||
private static readonly Func<DictionaryDbContext, long, DictionaryCode, Task<DictionaryGroup?>> GetByCodeQuery =
|
||||
EF.CompileAsyncQuery((DictionaryDbContext db, long tenantId, DictionaryCode code) =>
|
||||
db.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefault(group => group.TenantId == tenantId && group.DeletedAt == null && group.Code == code));
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 获取字典分组。
|
||||
/// </summary>
|
||||
public Task<DictionaryGroup?> GetByIdAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryGroups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(group => group.Id == groupId && group.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按编码获取字典分组。
|
||||
/// </summary>
|
||||
public Task<DictionaryGroup?> GetByCodeAsync(long tenantId, DictionaryCode code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
return GetByCodeQuery(context, tenantId, code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页获取字典分组。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryGroup>> GetPagedAsync(
|
||||
long tenantId,
|
||||
DictionaryScope? scope,
|
||||
string? keyword,
|
||||
bool? isEnabled,
|
||||
int page,
|
||||
int pageSize,
|
||||
string? sortBy,
|
||||
bool sortDescending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = BuildQuery(tenantId, scope, keyword, isEnabled);
|
||||
|
||||
var skip = Math.Max(page - 1, 0) * Math.Max(pageSize, 1);
|
||||
query = ApplyOrdering(query, sortBy, sortDescending);
|
||||
|
||||
return await query
|
||||
.Skip(skip)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取满足条件的分组数量。
|
||||
/// </summary>
|
||||
public Task<int> CountAsync(
|
||||
long tenantId,
|
||||
DictionaryScope? scope,
|
||||
string? keyword,
|
||||
bool? isEnabled,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return BuildQuery(tenantId, scope, keyword, isEnabled)
|
||||
.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取字典分组。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryGroup>> GetByIdsAsync(IEnumerable<long> groupIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = groupIds?.Distinct().ToArray() ?? Array.Empty<long>();
|
||||
if (ids.Length == 0)
|
||||
{
|
||||
return Array.Empty<DictionaryGroup>();
|
||||
}
|
||||
|
||||
return await context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(group => ids.Contains(group.Id) && group.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static IQueryable<DictionaryGroup> ApplyOrdering(IQueryable<DictionaryGroup> query, string? sortBy, bool sortDescending)
|
||||
{
|
||||
var normalized = sortBy?.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"name" => sortDescending ? query.OrderByDescending(group => group.Name) : query.OrderBy(group => group.Name),
|
||||
"createdat" => sortDescending ? query.OrderByDescending(group => group.CreatedAt) : query.OrderBy(group => group.CreatedAt),
|
||||
"updatedat" => sortDescending ? query.OrderByDescending(group => group.UpdatedAt) : query.OrderBy(group => group.UpdatedAt),
|
||||
_ => sortDescending ? query.OrderByDescending(group => group.Code) : query.OrderBy(group => group.Code)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增分组。
|
||||
/// </summary>
|
||||
public Task AddAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryGroups.Add(group);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新分组。
|
||||
/// </summary>
|
||||
public Task UpdateAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryGroups.Update(group);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除分组。
|
||||
/// </summary>
|
||||
public Task RemoveAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryGroups.Remove(group);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
private IQueryable<DictionaryGroup> BuildQuery(long tenantId, DictionaryScope? scope, string? keyword, bool? isEnabled)
|
||||
{
|
||||
var query = context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(group => group.TenantId == tenantId && group.DeletedAt == null);
|
||||
|
||||
if (scope.HasValue)
|
||||
{
|
||||
query = query.Where(group => group.Scope == scope.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var trimmed = keyword.Trim();
|
||||
query = query.Where(group =>
|
||||
EF.Property<string>(group, "Code").Contains(trimmed) ||
|
||||
group.Name.Contains(trimmed));
|
||||
}
|
||||
|
||||
if (isEnabled.HasValue)
|
||||
{
|
||||
query = query.Where(group => group.IsEnabled == isEnabled.Value);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入日志仓储实现。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportLogRepository(DictionaryDbContext context) : IDictionaryImportLogRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 新增导入日志。
|
||||
/// </summary>
|
||||
public Task AddAsync(DictionaryImportLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryImportLogs.Add(log);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项仓储实现。
|
||||
/// </summary>
|
||||
public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDictionaryItemRepository
|
||||
{
|
||||
private static readonly Func<DictionaryDbContext, long, long, IEnumerable<DictionaryItem>> GetByGroupQuery =
|
||||
EF.CompileQuery((DictionaryDbContext db, long tenantId, long groupId) =>
|
||||
(IEnumerable<DictionaryItem>)db.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(item => item.GroupId == groupId && item.TenantId == tenantId && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder));
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取字典项。
|
||||
/// </summary>
|
||||
public Task<DictionaryItem?> GetByIdAsync(long itemId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryItems
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(item => item.Id == itemId && item.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取分组下字典项列表。
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<DictionaryItem>> GetByGroupIdAsync(
|
||||
long tenantId,
|
||||
long groupId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
return Task.FromResult<IReadOnlyList<DictionaryItem>>(
|
||||
GetByGroupQuery(context, tenantId, groupId).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取系统与租户合并的字典项列表。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryItem>> GetMergedItemsAsync(
|
||||
long tenantId,
|
||||
long systemGroupId,
|
||||
bool includeOverrides,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var systemGroup = await context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(group => group.Id == systemGroupId && group.DeletedAt == null, cancellationToken);
|
||||
|
||||
if (systemGroup == null)
|
||||
{
|
||||
return Array.Empty<DictionaryItem>();
|
||||
}
|
||||
|
||||
var result = new List<DictionaryItem>();
|
||||
var systemItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(item => item.GroupId == systemGroupId && item.TenantId == 0 && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
result.AddRange(systemItems);
|
||||
|
||||
if (!includeOverrides || tenantId == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var tenantGroup = await context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(group =>
|
||||
group.TenantId == tenantId &&
|
||||
group.DeletedAt == null &&
|
||||
group.Code == systemGroup.Code,
|
||||
cancellationToken);
|
||||
|
||||
if (tenantGroup == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var tenantItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(item => item.GroupId == tenantGroup.Id && item.TenantId == tenantId && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
result.AddRange(tenantItems);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增字典项。
|
||||
/// </summary>
|
||||
public Task AddAsync(DictionaryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryItems.Add(item);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典项。
|
||||
/// </summary>
|
||||
public Task UpdateAsync(DictionaryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = context.Entry(item);
|
||||
if (entry.State == EntityState.Detached)
|
||||
{
|
||||
context.DictionaryItems.Attach(item);
|
||||
entry = context.Entry(item);
|
||||
}
|
||||
|
||||
entry.State = EntityState.Modified;
|
||||
var originalVersion = item.RowVersion;
|
||||
var nextVersion = RandomNumberGenerator.GetBytes(16);
|
||||
entry.Property(x => x.RowVersion).OriginalValue = originalVersion;
|
||||
entry.Property(x => x.RowVersion).CurrentValue = nextVersion;
|
||||
item.RowVersion = nextVersion;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典项。
|
||||
/// </summary>
|
||||
public Task RemoveAsync(DictionaryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryItems.Remove(item);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
@@ -27,7 +28,7 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>匹配分组或 null。</returns>
|
||||
public Task<DictionaryGroup?> FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == code, cancellationToken);
|
||||
=> context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == new DictionaryCode(code), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 搜索分组列表。
|
||||
@@ -153,8 +154,8 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
|
||||
// 1. 规范化编码
|
||||
var normalizedCodes = codes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(code => new DictionaryCode(code))
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (normalizedCodes.Length == 0)
|
||||
@@ -167,7 +168,7 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Include(item => item.Group)
|
||||
.Where(item => normalizedCodes.Contains(item.Group!.Code));
|
||||
.Where(item => normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null);
|
||||
|
||||
// 3. 按租户或系统级过滤
|
||||
query = query.Where(item => item.TenantId == tenantId || (includeSystem && item.TenantId == 0));
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖仓储实现。
|
||||
/// </summary>
|
||||
public sealed class TenantDictionaryOverrideRepository(DictionaryDbContext context) : ITenantDictionaryOverrideRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取租户覆盖配置。
|
||||
/// </summary>
|
||||
public Task<TenantDictionaryOverride?> GetAsync(long tenantId, long systemGroupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantDictionaryOverrides
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(config =>
|
||||
config.TenantId == tenantId &&
|
||||
config.SystemDictionaryGroupId == systemGroupId &&
|
||||
config.DeletedAt == null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户全部覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<TenantDictionaryOverride>> ListAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantDictionaryOverrides
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(config => config.TenantId == tenantId && config.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增覆盖配置。
|
||||
/// </summary>
|
||||
public Task AddAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantDictionaryOverrides.Add(overrideConfig);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新覆盖配置。
|
||||
/// </summary>
|
||||
public Task UpdateAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantDictionaryOverrides.Update(overrideConfig);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
{
|
||||
[DbContext(typeof(DictionaryDbContext))]
|
||||
[Migration("20251230044727_UpdateDictionarySchema")]
|
||||
partial class UpdateDictionarySchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<bool>("AllowOverride")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否允许租户覆盖。");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("描述信息。");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("分组名称。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("分组作用域:系统/业务。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "Code")
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("TenantId", "Scope", "IsEnabled");
|
||||
|
||||
b.ToTable("dictionary_groups", null, t =>
|
||||
{
|
||||
t.HasComment("参数字典分组(系统参数、业务参数)。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryImportLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("ConflictMode")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("冲突处理模式。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("DictionaryGroupCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("字典分组编码。");
|
||||
|
||||
b.Property<TimeSpan>("Duration")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("处理耗时。");
|
||||
|
||||
b.Property<string>("ErrorDetails")
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("错误明细(JSON)。");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("导入文件名。");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("文件大小(字节)。");
|
||||
|
||||
b.Property<string>("Format")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasComment("文件格式(CSV/JSON)。");
|
||||
|
||||
b.Property<long>("OperatorId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("操作人用户标识。");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("处理时间(UTC)。");
|
||||
|
||||
b.Property<int>("SkipCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("跳过数量。");
|
||||
|
||||
b.Property<int>("SuccessCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("成功导入数量。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "ProcessedAt");
|
||||
|
||||
b.ToTable("dictionary_import_logs", null, t =>
|
||||
{
|
||||
t.HasComment("字典导入审计日志。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("描述信息。");
|
||||
|
||||
b.Property<long>("GroupId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("关联分组 ID。");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否默认项。");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("字典项键。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(100)
|
||||
.HasComment("排序值,越小越靠前。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("字典项值。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("GroupId", "IsEnabled", "SortOrder");
|
||||
|
||||
b.HasIndex("TenantId", "GroupId", "Key")
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("dictionary_items", null, t =>
|
||||
{
|
||||
t.HasComment("参数字典项。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.TenantDictionaryOverride", b =>
|
||||
{
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<long>("SystemDictionaryGroupId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("系统字典分组 ID。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识。");
|
||||
|
||||
b.Property<string>("CustomSortOrder")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("自定义排序映射(JSON)。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("删除时间(UTC)。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识。");
|
||||
|
||||
b.PrimitiveCollection<long[]>("HiddenSystemItemIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("bigint[]")
|
||||
.HasComment("隐藏的系统字典项 ID 列表。");
|
||||
|
||||
b.Property<bool>("OverrideEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否启用覆盖。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近更新时间(UTC)。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识。");
|
||||
|
||||
b.HasKey("TenantId", "SystemDictionaryGroupId");
|
||||
|
||||
b.HasIndex("HiddenSystemItemIds");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("HiddenSystemItemIds"), "gin");
|
||||
|
||||
b.ToTable("tenant_dictionary_overrides", null, t =>
|
||||
{
|
||||
t.HasComment("租户字典覆盖配置。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.SystemParameters.Entities.SystemParameter", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("描述信息。");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("参数键,租户内唯一。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(100)
|
||||
.HasComment("排序值,越小越靠前。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasComment("参数值,支持文本或 JSON。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("system_parameters", null, t =>
|
||||
{
|
||||
t.HasComment("系统参数实体:支持按租户维护的键值型配置。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b =>
|
||||
{
|
||||
b.HasOne("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", "Group")
|
||||
.WithMany("Items")
|
||||
.HasForeignKey("GroupId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Group");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateDictionarySchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_dictionary_items_GroupId_Key",
|
||||
table: "dictionary_items");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_dictionary_groups_TenantId_Code",
|
||||
table: "dictionary_groups");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Value",
|
||||
table: "dictionary_items",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
comment: "字典项值。",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(256)",
|
||||
oldMaxLength: 256,
|
||||
oldComment: "字典项值。");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Key",
|
||||
table: "dictionary_items",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: false,
|
||||
comment: "字典项键。",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(64)",
|
||||
oldMaxLength: 64,
|
||||
oldComment: "字典项键。");
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "RowVersion",
|
||||
table: "dictionary_items",
|
||||
type: "bytea",
|
||||
rowVersion: true,
|
||||
nullable: false,
|
||||
defaultValue: new byte[0],
|
||||
comment: "并发控制字段。");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Code",
|
||||
table: "dictionary_groups",
|
||||
type: "character varying(64)",
|
||||
maxLength: 64,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(64)",
|
||||
oldMaxLength: 64,
|
||||
oldComment: "分组编码(唯一)。");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowOverride",
|
||||
table: "dictionary_groups",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false,
|
||||
comment: "是否允许租户覆盖。");
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "RowVersion",
|
||||
table: "dictionary_groups",
|
||||
type: "bytea",
|
||||
rowVersion: true,
|
||||
nullable: false,
|
||||
defaultValue: new byte[0],
|
||||
comment: "并发控制字段。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "dictionary_import_logs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
OperatorId = table.Column<long>(type: "bigint", nullable: false, comment: "操作人用户标识。"),
|
||||
DictionaryGroupCode = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "字典分组编码。"),
|
||||
FileName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false, comment: "导入文件名。"),
|
||||
FileSize = table.Column<long>(type: "bigint", nullable: false, comment: "文件大小(字节)。"),
|
||||
Format = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false, comment: "文件格式(CSV/JSON)。"),
|
||||
ConflictMode = table.Column<int>(type: "integer", nullable: false, comment: "冲突处理模式。"),
|
||||
SuccessCount = table.Column<int>(type: "integer", nullable: false, comment: "成功导入数量。"),
|
||||
SkipCount = table.Column<int>(type: "integer", nullable: false, comment: "跳过数量。"),
|
||||
ErrorDetails = table.Column<string>(type: "jsonb", nullable: true, comment: "错误明细(JSON)。"),
|
||||
ProcessedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "处理时间(UTC)。"),
|
||||
Duration = table.Column<TimeSpan>(type: "interval", nullable: false, comment: "处理耗时。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_dictionary_import_logs", x => x.Id);
|
||||
},
|
||||
comment: "字典导入审计日志。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_dictionary_overrides",
|
||||
columns: table => new
|
||||
{
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。"),
|
||||
SystemDictionaryGroupId = table.Column<long>(type: "bigint", nullable: false, comment: "系统字典分组 ID。"),
|
||||
OverrideEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false, comment: "是否启用覆盖。"),
|
||||
HiddenSystemItemIds = table.Column<long[]>(type: "bigint[]", nullable: false, comment: "隐藏的系统字典项 ID 列表。"),
|
||||
CustomSortOrder = table.Column<string>(type: "jsonb", nullable: false, comment: "自定义排序映射(JSON)。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近更新时间(UTC)。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "删除时间(UTC)。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tenant_dictionary_overrides", x => new { x.TenantId, x.SystemDictionaryGroupId });
|
||||
},
|
||||
comment: "租户字典覆盖配置。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_dictionary_items_GroupId_IsEnabled_SortOrder",
|
||||
table: "dictionary_items",
|
||||
columns: new[] { "GroupId", "IsEnabled", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_dictionary_items_TenantId_GroupId_Key",
|
||||
table: "dictionary_items",
|
||||
columns: new[] { "TenantId", "GroupId", "Key" },
|
||||
unique: true,
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_dictionary_groups_TenantId_Code",
|
||||
table: "dictionary_groups",
|
||||
columns: new[] { "TenantId", "Code" },
|
||||
unique: true,
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_dictionary_groups_TenantId_Scope_IsEnabled",
|
||||
table: "dictionary_groups",
|
||||
columns: new[] { "TenantId", "Scope", "IsEnabled" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_dictionary_import_logs_TenantId_ProcessedAt",
|
||||
table: "dictionary_import_logs",
|
||||
columns: new[] { "TenantId", "ProcessedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tenant_dictionary_overrides_HiddenSystemItemIds",
|
||||
table: "tenant_dictionary_overrides",
|
||||
column: "HiddenSystemItemIds")
|
||||
.Annotation("Npgsql:IndexMethod", "gin");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "dictionary_import_logs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_dictionary_overrides");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_dictionary_items_GroupId_IsEnabled_SortOrder",
|
||||
table: "dictionary_items");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_dictionary_items_TenantId_GroupId_Key",
|
||||
table: "dictionary_items");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_dictionary_groups_TenantId_Code",
|
||||
table: "dictionary_groups");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_dictionary_groups_TenantId_Scope_IsEnabled",
|
||||
table: "dictionary_groups");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RowVersion",
|
||||
table: "dictionary_items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowOverride",
|
||||
table: "dictionary_groups");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RowVersion",
|
||||
table: "dictionary_groups");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Value",
|
||||
table: "dictionary_items",
|
||||
type: "character varying(256)",
|
||||
maxLength: 256,
|
||||
nullable: false,
|
||||
comment: "字典项值。",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "jsonb",
|
||||
oldComment: "字典项值。");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Key",
|
||||
table: "dictionary_items",
|
||||
type: "character varying(64)",
|
||||
maxLength: 64,
|
||||
nullable: false,
|
||||
comment: "字典项键。",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(128)",
|
||||
oldMaxLength: 128,
|
||||
oldComment: "字典项键。");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Code",
|
||||
table: "dictionary_groups",
|
||||
type: "character varying(64)",
|
||||
maxLength: 64,
|
||||
nullable: false,
|
||||
comment: "分组编码(唯一)。",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(64)",
|
||||
oldMaxLength: 64);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_dictionary_items_GroupId_Key",
|
||||
table: "dictionary_items",
|
||||
columns: new[] { "GroupId", "Key" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_dictionary_groups_TenantId_Code",
|
||||
table: "dictionary_groups",
|
||||
columns: new[] { "TenantId", "Code" },
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCacheInvalidationLogs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "dictionary_cache_invalidation_logs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "发生时间(UTC)。"),
|
||||
DictionaryCode = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "字典编码。"),
|
||||
Scope = table.Column<int>(type: "integer", nullable: false, comment: "字典作用域。"),
|
||||
AffectedCacheKeyCount = table.Column<int>(type: "integer", nullable: false, comment: "影响的缓存键数量。"),
|
||||
OperatorId = table.Column<long>(type: "bigint", nullable: false, comment: "操作人用户标识。"),
|
||||
Operation = table.Column<int>(type: "integer", nullable: false, comment: "操作类型。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_dictionary_cache_invalidation_logs", x => x.Id);
|
||||
},
|
||||
comment: "字典缓存失效日志。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_dictionary_cache_invalidation_logs_TenantId_Timestamp",
|
||||
table: "dictionary_cache_invalidation_logs",
|
||||
columns: new[] { "TenantId", "Timestamp" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "dictionary_cache_invalidation_logs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("ProductVersion", "10.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
@@ -31,11 +31,16 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<bool>("AllowOverride")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否允许租户覆盖。");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("分组编码(唯一)。");
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
@@ -70,6 +75,13 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("分组名称。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("分组作用域:系统/业务。");
|
||||
@@ -91,7 +103,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "Code")
|
||||
.IsUnique();
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("TenantId", "Scope", "IsEnabled");
|
||||
|
||||
b.ToTable("dictionary_groups", null, t =>
|
||||
{
|
||||
@@ -99,6 +114,176 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryImportLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("ConflictMode")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("冲突处理模式。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("DictionaryGroupCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("字典分组编码。");
|
||||
|
||||
b.Property<TimeSpan>("Duration")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("处理耗时。");
|
||||
|
||||
b.Property<string>("ErrorDetails")
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("错误明细(JSON)。");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("导入文件名。");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("文件大小(字节)。");
|
||||
|
||||
b.Property<string>("Format")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasComment("文件格式(CSV/JSON)。");
|
||||
|
||||
b.Property<long>("OperatorId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("操作人用户标识。");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("处理时间(UTC)。");
|
||||
|
||||
b.Property<int>("SkipCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("跳过数量。");
|
||||
|
||||
b.Property<int>("SuccessCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("成功导入数量。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "ProcessedAt");
|
||||
|
||||
b.ToTable("dictionary_import_logs", null, t =>
|
||||
{
|
||||
t.HasComment("字典导入审计日志。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.CacheInvalidationLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("AffectedCacheKeyCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("影响的缓存键数量。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<string>("DictionaryCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("字典编码。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("操作类型。");
|
||||
|
||||
b.Property<long>("OperatorId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("操作人用户标识。");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("字典作用域。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("发生时间(UTC)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "Timestamp");
|
||||
|
||||
b.ToTable("dictionary_cache_invalidation_logs", null, t =>
|
||||
{
|
||||
t.HasComment("字典缓存失效日志。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -145,10 +330,17 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("字典项键。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
@@ -169,16 +361,18 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("字典项值。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("GroupId", "Key")
|
||||
.IsUnique();
|
||||
b.HasIndex("GroupId", "IsEnabled", "SortOrder");
|
||||
|
||||
b.HasIndex("TenantId", "GroupId", "Key")
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("dictionary_items", null, t =>
|
||||
{
|
||||
@@ -186,6 +380,68 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.TenantDictionaryOverride", b =>
|
||||
{
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<long>("SystemDictionaryGroupId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("系统字典分组 ID。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识。");
|
||||
|
||||
b.Property<string>("CustomSortOrder")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("自定义排序映射(JSON)。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("删除时间(UTC)。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识。");
|
||||
|
||||
b.PrimitiveCollection<long[]>("HiddenSystemItemIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("bigint[]")
|
||||
.HasComment("隐藏的系统字典项 ID 列表。");
|
||||
|
||||
b.Property<bool>("OverrideEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否启用覆盖。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近更新时间(UTC)。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识。");
|
||||
|
||||
b.HasKey("TenantId", "SystemDictionaryGroupId");
|
||||
|
||||
b.HasIndex("HiddenSystemItemIds");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("HiddenSystemItemIds"), "gin");
|
||||
|
||||
b.ToTable("tenant_dictionary_overrides", null, t =>
|
||||
{
|
||||
t.HasComment("租户字典覆盖配置。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.SystemParameters.Entities.SystemParameter", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Integration.Tests.Fixtures;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Integration.Tests.App.Dictionary;
|
||||
|
||||
public sealed class DictionaryApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Test_CreateDictionaryGroup_ReturnsCreated()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
using var context = database.CreateContext(tenantId: 0, userId: 11);
|
||||
var tenantProvider = new TestTenantProvider(0);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
|
||||
var service = BuildCommandService(context, tenantProvider, cache);
|
||||
var result = await service.CreateGroupAsync(new CreateDictionaryGroupRequest
|
||||
{
|
||||
Code = "ORDER_STATUS",
|
||||
Name = "Order Status",
|
||||
Scope = DictionaryScope.System,
|
||||
AllowOverride = true,
|
||||
Description = "Order lifecycle"
|
||||
});
|
||||
|
||||
result.Id.Should().NotBe(0);
|
||||
result.Code.Should().Be("order_status");
|
||||
result.Scope.Should().Be(DictionaryScope.System);
|
||||
|
||||
using var verifyContext = database.CreateContext(tenantId: 0);
|
||||
var stored = await verifyContext.DictionaryGroups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(group => group.Id == result.Id);
|
||||
|
||||
stored.Should().NotBeNull();
|
||||
stored!.TenantId.Should().Be(0);
|
||||
stored.Code.Value.Should().Be("order_status");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_GetDictionaryGroups_ReturnsPaged()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
using var context = database.CreateContext(tenantId: 0, userId: 11);
|
||||
|
||||
context.DictionaryGroups.AddRange(
|
||||
CreateSystemGroup(101, "order_status"),
|
||||
CreateSystemGroup(102, "payment_method"));
|
||||
await context.SaveChangesAsync();
|
||||
context.ChangeTracker.Clear();
|
||||
|
||||
var tenantProvider = new TestTenantProvider(0);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var service = BuildQueryService(context, tenantProvider, cache);
|
||||
|
||||
var page = await service.GetGroupsAsync(new DictionaryGroupQuery
|
||||
{
|
||||
Scope = DictionaryScope.System,
|
||||
Page = 1,
|
||||
PageSize = 1
|
||||
});
|
||||
|
||||
page.TotalCount.Should().Be(2);
|
||||
page.Items.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_UpdateDictionaryGroup_WithValidRowVersion_ReturnsOk()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
using var context = database.CreateContext(tenantId: 0, userId: 11);
|
||||
var tenantProvider = new TestTenantProvider(0);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var service = BuildCommandService(context, tenantProvider, cache);
|
||||
|
||||
var created = await service.CreateGroupAsync(new CreateDictionaryGroupRequest
|
||||
{
|
||||
Code = "PAYMENT_METHOD",
|
||||
Name = "Payment Method",
|
||||
Scope = DictionaryScope.System,
|
||||
AllowOverride = true
|
||||
});
|
||||
|
||||
var updated = await service.UpdateGroupAsync(created.Id, new UpdateDictionaryGroupRequest
|
||||
{
|
||||
Name = "Payment Method Updated",
|
||||
Description = "Updated",
|
||||
AllowOverride = false,
|
||||
IsEnabled = false,
|
||||
RowVersion = created.RowVersion
|
||||
});
|
||||
|
||||
updated.Name.Should().Be("Payment Method Updated");
|
||||
updated.AllowOverride.Should().BeFalse();
|
||||
updated.IsEnabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_UpdateDictionaryGroup_WithStaleRowVersion_ReturnsConflict()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
using var context = database.CreateContext(tenantId: 0, userId: 11);
|
||||
var tenantProvider = new TestTenantProvider(0);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var service = BuildCommandService(context, tenantProvider, cache);
|
||||
|
||||
var created = await service.CreateGroupAsync(new CreateDictionaryGroupRequest
|
||||
{
|
||||
Code = "SHIPPING_METHOD",
|
||||
Name = "Shipping Method",
|
||||
Scope = DictionaryScope.System,
|
||||
AllowOverride = true
|
||||
});
|
||||
|
||||
var request = new UpdateDictionaryGroupRequest
|
||||
{
|
||||
Name = "Shipping Method Updated",
|
||||
AllowOverride = true,
|
||||
IsEnabled = true,
|
||||
RowVersion = new byte[] { 9 }
|
||||
};
|
||||
|
||||
Func<Task> act = async () => await service.UpdateGroupAsync(created.Id, request);
|
||||
|
||||
var exception = await act.Should().ThrowAsync<BusinessException>();
|
||||
exception.Which.ErrorCode.Should().Be(ErrorCodes.Conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_DeleteDictionaryGroup_SoftDeletesGroup()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
using var context = database.CreateContext(tenantId: 0, userId: 11);
|
||||
|
||||
var group = CreateSystemGroup(201, "user_role");
|
||||
var item = CreateSystemItem(210, group.Id, "ADMIN", 10);
|
||||
context.DictionaryGroups.Add(group);
|
||||
context.DictionaryItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
context.ChangeTracker.Clear();
|
||||
|
||||
var tenantProvider = new TestTenantProvider(0);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var service = BuildCommandService(context, tenantProvider, cache);
|
||||
|
||||
var result = await service.DeleteGroupAsync(group.Id);
|
||||
result.Should().BeTrue();
|
||||
|
||||
using var verifyContext = database.CreateContext(tenantId: 0);
|
||||
var deletedGroup = await verifyContext.DictionaryGroups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstAsync(x => x.Id == group.Id);
|
||||
deletedGroup.DeletedAt.Should().NotBeNull();
|
||||
|
||||
var deletedItem = await verifyContext.DictionaryItems
|
||||
.IgnoreQueryFilters()
|
||||
.FirstAsync(x => x.Id == item.Id);
|
||||
deletedItem.DeletedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_EnableOverride_CreatesOverrideConfig()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
|
||||
using (var systemContext = database.CreateContext(tenantId: 0, userId: 11))
|
||||
{
|
||||
systemContext.DictionaryGroups.Add(CreateSystemGroup(301, "order_status", allowOverride: true));
|
||||
await systemContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var tenantContext = database.CreateContext(tenantId: 100, userId: 21);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var service = BuildOverrideService(tenantContext, cache);
|
||||
|
||||
var result = await service.EnableOverrideAsync(100, "ORDER_STATUS");
|
||||
|
||||
result.OverrideEnabled.Should().BeTrue();
|
||||
result.SystemDictionaryGroupCode.Should().Be("order_status");
|
||||
|
||||
var stored = await tenantContext.TenantDictionaryOverrides
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == 100 && x.SystemDictionaryGroupId == 301);
|
||||
stored.Should().NotBeNull();
|
||||
stored!.OverrideEnabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_DisableOverride_ClearsCustomization()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
|
||||
using (var systemContext = database.CreateContext(tenantId: 0, userId: 11))
|
||||
{
|
||||
systemContext.DictionaryGroups.Add(CreateSystemGroup(401, "payment_method", allowOverride: true));
|
||||
await systemContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var tenantContext = database.CreateContext(tenantId: 100, userId: 21);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var service = BuildOverrideService(tenantContext, cache);
|
||||
|
||||
await service.EnableOverrideAsync(100, "payment_method");
|
||||
var disabled = await service.DisableOverrideAsync(100, "payment_method");
|
||||
|
||||
disabled.Should().BeTrue();
|
||||
|
||||
var stored = await tenantContext.TenantDictionaryOverrides
|
||||
.IgnoreQueryFilters()
|
||||
.FirstAsync(x => x.TenantId == 100 && x.SystemDictionaryGroupId == 401);
|
||||
stored.OverrideEnabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_UpdateHiddenItems_FiltersSystemItems()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
|
||||
using (var systemContext = database.CreateContext(tenantId: 0, userId: 11))
|
||||
{
|
||||
var group = CreateSystemGroup(501, "shipping_method", allowOverride: true);
|
||||
systemContext.DictionaryGroups.Add(group);
|
||||
systemContext.DictionaryItems.AddRange(
|
||||
CreateSystemItem(510, group.Id, "PLATFORM", 10),
|
||||
CreateSystemItem(511, group.Id, "MERCHANT", 20));
|
||||
await systemContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var tenantContext = database.CreateContext(tenantId: 200, userId: 22);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var overrideService = BuildOverrideService(tenantContext, cache);
|
||||
var mergeService = BuildMergeService(tenantContext);
|
||||
|
||||
await overrideService.UpdateHiddenItemsAsync(200, "shipping_method", new[] { 511L });
|
||||
var merged = await mergeService.MergeItemsAsync(200, 501);
|
||||
|
||||
merged.Should().Contain(item => item.Id == 510);
|
||||
merged.Should().NotContain(item => item.Id == 511);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_GetMergedDictionary_ReturnsMergedResult()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
|
||||
using (var systemContext = database.CreateContext(tenantId: 0, userId: 11))
|
||||
{
|
||||
var group = CreateSystemGroup(601, "order_status", allowOverride: true);
|
||||
systemContext.DictionaryGroups.Add(group);
|
||||
systemContext.DictionaryItems.AddRange(
|
||||
CreateSystemItem(610, group.Id, "PENDING", 10),
|
||||
CreateSystemItem(611, group.Id, "ACCEPTED", 20));
|
||||
await systemContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using (var tenantContext = database.CreateContext(tenantId: 300, userId: 33))
|
||||
{
|
||||
var tenantGroup = CreateTenantGroup(701, 300, "order_status");
|
||||
tenantContext.DictionaryGroups.Add(tenantGroup);
|
||||
tenantContext.DictionaryItems.Add(CreateTenantItem(720, tenantGroup.Id, "CUSTOM", 15));
|
||||
await tenantContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var queryContext = database.CreateContext(tenantId: 300, userId: 33);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var overrideService = BuildOverrideService(queryContext, cache);
|
||||
await overrideService.EnableOverrideAsync(300, "order_status");
|
||||
await overrideService.UpdateHiddenItemsAsync(300, "order_status", new[] { 611L });
|
||||
await overrideService.UpdateCustomSortOrderAsync(300, "order_status", new Dictionary<long, int>
|
||||
{
|
||||
[720L] = 1,
|
||||
[610L] = 2
|
||||
});
|
||||
|
||||
var queryService = BuildQueryService(queryContext, new TestTenantProvider(300), cache);
|
||||
var merged = await queryService.GetMergedDictionaryAsync("order_status");
|
||||
|
||||
merged.Should().Contain(item => item.Id == 610);
|
||||
merged.Should().Contain(item => item.Id == 720 && item.Source == "tenant");
|
||||
merged.Should().NotContain(item => item.Id == 611);
|
||||
merged.First().Id.Should().Be(720);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_ExportCsv_GeneratesValidFile()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
|
||||
using (var systemContext = database.CreateContext(tenantId: 0, userId: 11))
|
||||
{
|
||||
var group = CreateSystemGroup(801, "payment_method", allowOverride: true);
|
||||
systemContext.DictionaryGroups.Add(group);
|
||||
systemContext.DictionaryItems.Add(CreateSystemItem(810, group.Id, "ALIPAY", 10));
|
||||
await systemContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var exportContext = database.CreateContext(tenantId: 0, userId: 11);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var service = BuildImportExportService(exportContext, new TestTenantProvider(0), new TestCurrentUserAccessor(11), cache);
|
||||
|
||||
await using var stream = new MemoryStream();
|
||||
await service.ExportToCsvAsync(801, stream);
|
||||
var csv = Encoding.UTF8.GetString(stream.ToArray());
|
||||
|
||||
csv.Should().Contain("code,key,value,sortOrder,isEnabled,description,source");
|
||||
csv.Should().Contain("payment_method");
|
||||
csv.Should().Contain("ALIPAY");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_ImportCsv_WithSkipMode_SkipsDuplicates()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
|
||||
using (var systemContext = database.CreateContext(tenantId: 0, userId: 11))
|
||||
{
|
||||
var group = CreateSystemGroup(901, "order_status", allowOverride: true);
|
||||
systemContext.DictionaryGroups.Add(group);
|
||||
systemContext.DictionaryItems.Add(CreateSystemItem(910, group.Id, "PENDING", 10));
|
||||
await systemContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var importContext = database.CreateContext(tenantId: 0, userId: 11);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var service = BuildImportExportService(importContext, new TestTenantProvider(0), new TestCurrentUserAccessor(11), cache);
|
||||
|
||||
var csv = BuildCsv(
|
||||
new[] { "code", "key", "value", "sortOrder", "isEnabled", "description", "source" },
|
||||
new[]
|
||||
{
|
||||
new[] { "order_status", "PENDING", BuildValueJson("待接单", "Pending"), "10", "true", "重复项", "system" },
|
||||
new[] { "order_status", "COMPLETED", BuildValueJson("已完成", "Completed"), "20", "true", "新增项", "system" }
|
||||
});
|
||||
|
||||
var result = await service.ImportFromCsvAsync(new DictionaryImportRequest
|
||||
{
|
||||
GroupId = 901,
|
||||
FileName = "import.csv",
|
||||
FileSize = Encoding.UTF8.GetByteCount(csv),
|
||||
ConflictMode = ConflictResolutionMode.Skip,
|
||||
FileStream = new MemoryStream(Encoding.UTF8.GetBytes(csv))
|
||||
});
|
||||
|
||||
result.SkipCount.Should().Be(1);
|
||||
result.SuccessCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_ImportCsv_WithOverwriteMode_UpdatesExisting()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
|
||||
using (var systemContext = database.CreateContext(tenantId: 0, userId: 11))
|
||||
{
|
||||
var group = CreateSystemGroup(1001, "payment_method", allowOverride: true);
|
||||
systemContext.DictionaryGroups.Add(group);
|
||||
systemContext.DictionaryItems.Add(CreateSystemItem(1010, group.Id, "ALIPAY", 10));
|
||||
await systemContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var importContext = database.CreateContext(tenantId: 0, userId: 11);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var service = BuildImportExportService(importContext, new TestTenantProvider(0), new TestCurrentUserAccessor(11), cache);
|
||||
|
||||
var csv = BuildCsv(
|
||||
new[] { "code", "key", "value", "sortOrder", "isEnabled", "description", "source" },
|
||||
new[]
|
||||
{
|
||||
new[] { "payment_method", "ALIPAY", BuildValueJson("支付宝新版", "Alipay New"), "15", "true", "覆盖", "system" }
|
||||
});
|
||||
|
||||
await service.ImportFromCsvAsync(new DictionaryImportRequest
|
||||
{
|
||||
GroupId = 1001,
|
||||
FileName = "import.csv",
|
||||
FileSize = Encoding.UTF8.GetByteCount(csv),
|
||||
ConflictMode = ConflictResolutionMode.Overwrite,
|
||||
FileStream = new MemoryStream(Encoding.UTF8.GetBytes(csv))
|
||||
});
|
||||
|
||||
var updated = await importContext.DictionaryItems
|
||||
.IgnoreQueryFilters()
|
||||
.FirstAsync(item => item.GroupId == 1001 && item.Key == "ALIPAY");
|
||||
|
||||
updated.Value.Should().Contain("支付宝新版");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_ImportCsv_WithInvalidData_ReturnsErrors()
|
||||
{
|
||||
using var database = new DictionarySqliteTestDatabase();
|
||||
|
||||
using (var systemContext = database.CreateContext(tenantId: 0, userId: 11))
|
||||
{
|
||||
systemContext.DictionaryGroups.Add(CreateSystemGroup(1101, "user_role", allowOverride: false));
|
||||
await systemContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var importContext = database.CreateContext(tenantId: 0, userId: 11);
|
||||
var cache = new TestDictionaryHybridCache();
|
||||
var service = BuildImportExportService(importContext, new TestTenantProvider(0), new TestCurrentUserAccessor(11), cache);
|
||||
|
||||
var csv = BuildCsv(
|
||||
new[] { "code", "key", "value", "sortOrder", "isEnabled", "description", "source" },
|
||||
new[]
|
||||
{
|
||||
new[] { "user_role", "ADMIN", "invalid-json", "10", "true", "bad", "system" }
|
||||
});
|
||||
|
||||
var result = await service.ImportFromCsvAsync(new DictionaryImportRequest
|
||||
{
|
||||
GroupId = 1101,
|
||||
FileName = "import.csv",
|
||||
FileSize = Encoding.UTF8.GetByteCount(csv),
|
||||
ConflictMode = ConflictResolutionMode.Skip,
|
||||
FileStream = new MemoryStream(Encoding.UTF8.GetBytes(csv))
|
||||
});
|
||||
|
||||
result.ErrorCount.Should().Be(1);
|
||||
result.SuccessCount.Should().Be(0);
|
||||
}
|
||||
|
||||
private static DictionaryCommandService BuildCommandService(
|
||||
TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext context,
|
||||
TestTenantProvider tenantProvider,
|
||||
TestDictionaryHybridCache cache)
|
||||
{
|
||||
return new DictionaryCommandService(
|
||||
new DictionaryGroupRepository(context),
|
||||
new DictionaryItemRepository(context),
|
||||
cache,
|
||||
tenantProvider,
|
||||
NullLogger<DictionaryCommandService>.Instance);
|
||||
}
|
||||
|
||||
private static DictionaryQueryService BuildQueryService(
|
||||
TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext context,
|
||||
TestTenantProvider tenantProvider,
|
||||
TestDictionaryHybridCache cache)
|
||||
{
|
||||
var groupRepository = new DictionaryGroupRepository(context);
|
||||
var itemRepository = new DictionaryItemRepository(context);
|
||||
var overrideRepository = new TenantDictionaryOverrideRepository(context);
|
||||
var mergeService = new DictionaryMergeService(groupRepository, itemRepository, overrideRepository);
|
||||
|
||||
return new DictionaryQueryService(groupRepository, itemRepository, mergeService, cache, tenantProvider);
|
||||
}
|
||||
|
||||
private static DictionaryOverrideService BuildOverrideService(
|
||||
TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext context,
|
||||
TestDictionaryHybridCache cache)
|
||||
{
|
||||
return new DictionaryOverrideService(
|
||||
new DictionaryGroupRepository(context),
|
||||
new DictionaryItemRepository(context),
|
||||
new TenantDictionaryOverrideRepository(context),
|
||||
cache);
|
||||
}
|
||||
|
||||
private static DictionaryMergeService BuildMergeService(
|
||||
TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext context)
|
||||
{
|
||||
var groupRepository = new DictionaryGroupRepository(context);
|
||||
var itemRepository = new DictionaryItemRepository(context);
|
||||
var overrideRepository = new TenantDictionaryOverrideRepository(context);
|
||||
return new DictionaryMergeService(groupRepository, itemRepository, overrideRepository);
|
||||
}
|
||||
|
||||
private static DictionaryImportExportService BuildImportExportService(
|
||||
TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext context,
|
||||
TestTenantProvider tenantProvider,
|
||||
TestCurrentUserAccessor currentUser,
|
||||
TestDictionaryHybridCache cache)
|
||||
{
|
||||
return new DictionaryImportExportService(
|
||||
new CsvDictionaryParser(),
|
||||
new JsonDictionaryParser(),
|
||||
new DictionaryGroupRepository(context),
|
||||
new DictionaryItemRepository(context),
|
||||
new DictionaryImportLogRepository(context),
|
||||
cache,
|
||||
tenantProvider,
|
||||
currentUser,
|
||||
NullLogger<DictionaryImportExportService>.Instance);
|
||||
}
|
||||
|
||||
private static DictionaryGroup CreateSystemGroup(long id, string code, bool allowOverride = true)
|
||||
{
|
||||
return new DictionaryGroup
|
||||
{
|
||||
Id = id,
|
||||
TenantId = 0,
|
||||
Code = new DictionaryCode(code),
|
||||
Name = code,
|
||||
Scope = DictionaryScope.System,
|
||||
AllowOverride = allowOverride,
|
||||
Description = "Test group",
|
||||
IsEnabled = true,
|
||||
RowVersion = new byte[] { 1 }
|
||||
};
|
||||
}
|
||||
|
||||
private static DictionaryGroup CreateTenantGroup(long id, long tenantId, string code)
|
||||
{
|
||||
return new DictionaryGroup
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
Code = new DictionaryCode(code),
|
||||
Name = code,
|
||||
Scope = DictionaryScope.Business,
|
||||
AllowOverride = false,
|
||||
Description = "Tenant group",
|
||||
IsEnabled = true,
|
||||
RowVersion = new byte[] { 1 }
|
||||
};
|
||||
}
|
||||
|
||||
private static DictionaryItem CreateSystemItem(long id, long groupId, string key, int sortOrder)
|
||||
{
|
||||
return new DictionaryItem
|
||||
{
|
||||
Id = id,
|
||||
TenantId = 0,
|
||||
GroupId = groupId,
|
||||
Key = key,
|
||||
Value = BuildValueJson("测试", "Test"),
|
||||
IsDefault = false,
|
||||
IsEnabled = true,
|
||||
SortOrder = sortOrder,
|
||||
Description = "System item",
|
||||
RowVersion = new byte[] { 1 }
|
||||
};
|
||||
}
|
||||
|
||||
private static DictionaryItem CreateTenantItem(long id, long groupId, string key, int sortOrder)
|
||||
{
|
||||
return new DictionaryItem
|
||||
{
|
||||
Id = id,
|
||||
TenantId = 300,
|
||||
GroupId = groupId,
|
||||
Key = key,
|
||||
Value = BuildValueJson("租户值", "Tenant Value"),
|
||||
IsDefault = false,
|
||||
IsEnabled = true,
|
||||
SortOrder = sortOrder,
|
||||
Description = "Tenant item",
|
||||
RowVersion = new byte[] { 1 }
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildValueJson(string zh, string en)
|
||||
{
|
||||
var value = new I18nValue(new Dictionary<string, string>
|
||||
{
|
||||
["zh-CN"] = zh,
|
||||
["en"] = en
|
||||
});
|
||||
return value.ToJson();
|
||||
}
|
||||
|
||||
private static string BuildCsv(string[] headers, IEnumerable<string[]> rows)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(string.Join(",", headers));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
builder.AppendLine(string.Join(",", row.Select(EscapeCsvField)));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string EscapeCsvField(string value)
|
||||
{
|
||||
if (value.Contains('"', StringComparison.Ordinal))
|
||||
{
|
||||
value = value.Replace("\"", "\"\"");
|
||||
}
|
||||
|
||||
if (value.Contains(',', StringComparison.Ordinal) ||
|
||||
value.Contains('\n', StringComparison.Ordinal) ||
|
||||
value.Contains('\r', StringComparison.Ordinal) ||
|
||||
value.Contains('"', StringComparison.Ordinal))
|
||||
{
|
||||
return $"\"{value}\"";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Integration.Tests.Fixtures;
|
||||
|
||||
public sealed class DictionarySqliteTestDatabase : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private bool _initialized;
|
||||
|
||||
public DictionarySqliteTestDatabase()
|
||||
{
|
||||
_connection = new SqliteConnection("Filename=:memory:");
|
||||
_connection.Open();
|
||||
Options = new DbContextOptionsBuilder<DictionaryDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.EnableSensitiveDataLogging()
|
||||
.Options;
|
||||
}
|
||||
|
||||
public DbContextOptions<DictionaryDbContext> Options { get; }
|
||||
|
||||
public DictionaryDbContext CreateContext(long tenantId, long userId = 0)
|
||||
{
|
||||
EnsureCreated();
|
||||
return new DictionaryDbContext(
|
||||
Options,
|
||||
new TestTenantProvider(tenantId),
|
||||
userId == 0 ? null : new TestCurrentUserAccessor(userId));
|
||||
}
|
||||
|
||||
public void EnsureCreated()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var context = new DictionaryDbContext(Options, new TestTenantProvider(1));
|
||||
context.Database.EnsureCreated();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Collections.Concurrent;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Integration.Tests.Fixtures;
|
||||
|
||||
public sealed class TestDictionaryHybridCache : IDictionaryHybridCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, object> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public async Task<T?> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
TimeSpan ttl,
|
||||
Func<CancellationToken, Task<T?>> factory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var cached) && cached is T typed)
|
||||
{
|
||||
return typed;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken);
|
||||
if (value is not null)
|
||||
{
|
||||
_cache[key] = value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public Task InvalidateAsync(
|
||||
string prefix,
|
||||
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var key in _cache.Keys)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user