From dc9f6136d696a6cc0308dad14ce664a9de351c50 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Tue, 30 Dec 2025 19:38:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AD=97=E5=85=B8?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 +- .../Requests/DictionaryExportRequest.cs | 12 + .../Requests/DictionaryImportFormRequest.cs | 26 + .../Controllers/CacheMetricsController.cs | 65 ++ .../Controllers/DictionaryGroupsController.cs | 185 ++++++ .../Controllers/DictionaryItemsController.cs | 77 +++ .../DictionaryOverridesController.cs | 167 +++++ .../appsettings.Development.json | 9 + .../appsettings.Production.json | 9 + .../appsettings.Seed.Development.json | 4 + .../Controllers/DictionaryController.cs | 51 ++ src/Api/TakeoutSaaS.UserApi/Program.cs | 7 + .../TakeoutSaaS.UserApi.csproj | Bin 3434 -> 3904 bytes .../Abstractions/ICsvDictionaryParser.cs | 17 + .../Abstractions/IDictionaryHybridCache.cs | 26 + .../Abstractions/IJsonDictionaryParser.cs | 17 + .../Contracts/CreateDictionaryGroupRequest.cs | 5 + .../Contracts/CreateDictionaryItemRequest.cs | 6 +- .../Contracts/DictionaryGroupQuery.cs | 30 + .../Contracts/DictionaryImportRequest.cs | 34 + .../DictionaryOverrideHiddenItemsRequest.cs | 15 + .../DictionaryOverrideSortOrderRequest.cs | 15 + .../Contracts/UpdateDictionaryGroupRequest.cs | 10 + .../Contracts/UpdateDictionaryItemRequest.cs | 15 +- .../DictionaryServiceCollectionExtensions.cs | 5 + .../Dictionary/Models/DictionaryGroupDto.cs | 26 + .../Models/DictionaryImportResultDto.cs | 53 ++ .../Dictionary/Models/DictionaryImportRow.cs | 47 ++ .../Dictionary/Models/DictionaryItemDto.cs | 13 +- .../Dictionary/Models/OverrideConfigDto.cs | 36 ++ .../Services/DictionaryAppService.cs | 68 +- .../Services/DictionaryCacheKeys.cs | 44 ++ .../Services/DictionaryCommandService.cs | 338 ++++++++++ .../Services/DictionaryImportExportService.cs | 480 ++++++++++++++ .../Dictionary/Services/DictionaryMapper.cs | 46 ++ .../Services/DictionaryMergeService.cs | 84 +++ .../Services/DictionaryOverrideService.cs | 322 ++++++++++ .../Services/DictionaryQueryService.cs | 246 +++++++ .../Services/DictionaryValueConverter.cs | 46 ++ .../CreateDictionaryGroupValidator.cs | 25 + .../Validators/I18nValueValidator.cs | 32 + .../UpdateDictionaryItemValidator.cs | 23 + .../Middleware/ExceptionHandlingMiddleware.cs | 45 +- .../Swagger/SwaggerExtensions.cs | 4 +- .../TakeoutSaaS.Shared.Web.csproj | 2 + .../Entities/CacheInvalidationLog.cs | 40 ++ .../Dictionary/Entities/DictionaryGroup.cs | 13 +- .../Entities/DictionaryImportLog.cs | 65 ++ .../Dictionary/Entities/DictionaryItem.cs | 5 + .../Entities/TenantDictionaryOverride.cs | 64 ++ .../Enums/CacheInvalidationOperation.cs | 11 + .../Enums/ConflictResolutionMode.cs | 22 + .../ICacheInvalidationLogRepository.cs | 29 + .../IDictionaryGroupRepository.cs | 104 +++ .../IDictionaryImportLogRepository.cs | 22 + .../Repositories/IDictionaryItemRepository.cs | 64 ++ .../ITenantDictionaryOverrideRepository.cs | 47 ++ .../Dictionary/ValueObjects/DictionaryCode.cs | 91 +++ .../Dictionary/ValueObjects/I18nValue.cs | 151 +++++ .../Caching/CacheMetricsCollector.cs | 212 ++++++ .../Dictionary/Caching/CacheWarmupService.cs | 57 ++ .../Dictionary/Caching/HybridCacheService.cs | 229 +++++++ .../Dictionary/Caching/MemoryCacheService.cs | 82 +++ .../Dictionary/Caching/RedisCacheService.cs | 79 +++ .../DictionaryServiceCollectionExtensions.cs | 57 ++ .../ImportExport/CsvDictionaryParser.cs | 91 +++ .../ImportExport/JsonDictionaryParser.cs | 131 ++++ .../Options/DictionaryCacheWarmupOptions.cs | 12 + .../Persistence/DictionaryDbContext.cs | 131 +++- .../Persistence/Seeds/InitialDictionaries.sql | 85 +++ .../CacheInvalidationLogRepository.cs | 59 ++ .../Repositories/DictionaryGroupRepository.cs | 170 +++++ .../DictionaryImportLogRepository.cs | 26 + .../Repositories/DictionaryItemRepository.cs | 148 +++++ .../Repositories/EfDictionaryRepository.cs | 9 +- .../TenantDictionaryOverrideRepository.cs | 62 ++ ...0044727_UpdateDictionarySchema.Designer.cs | 471 ++++++++++++++ .../20251230044727_UpdateDictionarySchema.cs | 256 ++++++++ ...20251230162000_AddCacheInvalidationLogs.cs | 54 ++ .../DictionaryDbContextModelSnapshot.cs | 276 +++++++- .../App/Dictionary/DictionaryApiTests.cs | 605 ++++++++++++++++++ .../Fixtures/DictionarySqliteTestDatabase.cs | 49 ++ .../Fixtures/TestDictionaryHybridCache.cs | 46 ++ 83 files changed, 6901 insertions(+), 50 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/DictionaryExportRequest.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/DictionaryImportFormRequest.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/CacheMetricsController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryGroupsController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryItemsController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryOverridesController.cs create mode 100644 src/Api/TakeoutSaaS.UserApi/Controllers/DictionaryController.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/ICsvDictionaryParser.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryHybridCache.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IJsonDictionaryParser.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryImportRequest.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryOverrideHiddenItemsRequest.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryOverrideSortOrderRequest.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryImportResultDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryImportRow.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Models/OverrideConfigDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCacheKeys.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCommandService.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryMapper.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryMergeService.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryOverrideService.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryQueryService.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryValueConverter.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Validators/CreateDictionaryGroupValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Validators/I18nValueValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/Dictionary/Validators/UpdateDictionaryItemValidator.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/CacheInvalidationLog.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryImportLog.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/TenantDictionaryOverride.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/CacheInvalidationOperation.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/ConflictResolutionMode.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/ICacheInvalidationLogRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryGroupRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryImportLogRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryItemRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/ITenantDictionaryOverrideRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/ValueObjects/DictionaryCode.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Dictionary/ValueObjects/I18nValue.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheMetricsCollector.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheWarmupService.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/HybridCacheService.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/MemoryCacheService.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/RedisCacheService.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/ImportExport/CsvDictionaryParser.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/ImportExport/JsonDictionaryParser.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheWarmupOptions.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/Seeds/InitialDictionaries.sql create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/CacheInvalidationLogRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryImportLogRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/TenantDictionaryOverrideRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.Designer.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230162000_AddCacheInvalidationLogs.cs create mode 100644 tests/TakeoutSaaS.Integration.Tests/App/Dictionary/DictionaryApiTests.cs create mode 100644 tests/TakeoutSaaS.Integration.Tests/Fixtures/DictionarySqliteTestDatabase.cs create mode 100644 tests/TakeoutSaaS.Integration.Tests/Fixtures/TestDictionaryHybridCache.cs diff --git a/README.md b/README.md index 4222930..caaabce 100644 --- a/README.md +++ b/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`:缓存预热字典编码列表 ## 公告管理 diff --git a/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/DictionaryExportRequest.cs b/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/DictionaryExportRequest.cs new file mode 100644 index 0000000..e8f59dc --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/DictionaryExportRequest.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.AdminApi.Contracts.Requests; + +/// +/// 字典导出请求。 +/// +public sealed record DictionaryExportRequest +{ + /// + /// 导出格式(csv/json)。 + /// + public string? Format { get; init; } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/DictionaryImportFormRequest.cs b/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/DictionaryImportFormRequest.cs new file mode 100644 index 0000000..7d24ccc --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/DictionaryImportFormRequest.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; + +namespace TakeoutSaaS.AdminApi.Contracts.Requests; + +/// +/// 字典导入表单请求。 +/// +public sealed record DictionaryImportFormRequest +{ + /// + /// 导入文件。 + /// + [Required] + public required IFormFile File { get; init; } + + /// + /// 冲突解决模式(Skip/Overwrite/Append)。 + /// + public string? ConflictMode { get; init; } + + /// + /// 文件格式(csv/json)。 + /// + public string? Format { get; init; } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/CacheMetricsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/CacheMetricsController.cs new file mode 100644 index 0000000..31e66d1 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/CacheMetricsController.cs @@ -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; + +/// +/// 缓存监控指标接口。 +/// +[ApiVersion("1.0")] +[Authorize(Roles = "PlatformAdmin")] +[Route("api/admin/v{version:apiVersion}/dictionary/metrics")] +public sealed class CacheMetricsController( + CacheMetricsCollector metricsCollector, + ICacheInvalidationLogRepository invalidationLogRepository) + : BaseApiController +{ + /// + /// 获取缓存统计信息。 + /// + [HttpGet("cache-stats")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public ApiResponse 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.Ok(snapshot); + } + + /// + /// 获取缓存失效事件列表。 + /// + [HttpGet("invalidation-events")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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(items, safePage, safePageSize, total); + return ApiResponse>.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryGroupsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryGroupsController.cs new file mode 100644 index 0000000..d9f5b3a --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryGroupsController.cs @@ -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; + +/// +/// 字典分组管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/dictionary/groups")] +public sealed class DictionaryGroupsController( + DictionaryCommandService commandService, + DictionaryQueryService queryService, + DictionaryImportExportService importExportService) + : BaseApiController +{ + /// + /// 查询字典分组。 + /// + [HttpGet] + [PermissionAuthorize("dictionary:group:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List([FromQuery] DictionaryGroupQuery query, CancellationToken cancellationToken) + { + var result = await queryService.GetGroupsAsync(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 获取字典分组详情。 + /// + [HttpGet("{groupId:long}")] + [PermissionAuthorize("dictionary:group:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long groupId, CancellationToken cancellationToken) + { + var result = await queryService.GetGroupByIdAsync(groupId, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "字典分组不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建字典分组。 + /// + [HttpPost] + [PermissionAuthorize("dictionary:group:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody] CreateDictionaryGroupRequest request, CancellationToken cancellationToken) + { + var result = await commandService.CreateGroupAsync(request, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新字典分组。 + /// + [HttpPut("{groupId:long}")] + [PermissionAuthorize("dictionary:group:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Update(long groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken) + { + var result = await commandService.UpdateGroupAsync(groupId, request, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 删除字典分组。 + /// + [HttpDelete("{groupId:long}")] + [PermissionAuthorize("dictionary:group:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long groupId, CancellationToken cancellationToken) + { + var success = await commandService.DeleteGroupAsync(groupId, cancellationToken); + return success + ? ApiResponse.Success() + : ApiResponse.Error(ErrorCodes.NotFound, "字典分组不存在"); + } + + /// + /// 导出字典分组数据。 + /// + [HttpPost("{groupId:long}/export")] + [PermissionAuthorize("dictionary:group:read")] + [Produces("application/octet-stream")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + public async Task 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); + } + + /// + /// 导入字典分组数据。 + /// + [HttpPost("{groupId:long}/import")] + [PermissionAuthorize("dictionary:item:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Import( + long groupId, + [FromForm] DictionaryImportFormRequest request, + CancellationToken cancellationToken) + { + if (request.File.Length > 10 * 1024 * 1024) + { + return ApiResponse.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.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(conflictMode, ignoreCase: true, out var mode) + ? mode + : ConflictResolutionMode.Skip; + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryItemsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryItemsController.cs new file mode 100644 index 0000000..9663260 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryItemsController.cs @@ -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; + +/// +/// 字典项管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/dictionary/groups/{groupId:long}/items")] +public sealed class DictionaryItemsController( + DictionaryCommandService commandService, + DictionaryQueryService queryService) + : BaseApiController +{ + /// + /// 查询字典项列表。 + /// + [HttpGet] + [PermissionAuthorize("dictionary:group:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List(long groupId, CancellationToken cancellationToken) + { + var result = await queryService.GetItemsByGroupIdAsync(groupId, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 创建字典项。 + /// + [HttpPost] + [PermissionAuthorize("dictionary:item:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken) + { + request.GroupId = groupId; + var result = await commandService.CreateItemAsync(request, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新字典项。 + /// + [HttpPut("{itemId:long}")] + [PermissionAuthorize("dictionary:item:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Update(long groupId, long itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken) + { + _ = groupId; + var result = await commandService.UpdateItemAsync(itemId, request, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 删除字典项。 + /// + [HttpDelete("{itemId:long}")] + [PermissionAuthorize("dictionary:item:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long groupId, long itemId, CancellationToken cancellationToken) + { + _ = groupId; + var success = await commandService.DeleteItemAsync(itemId, cancellationToken); + return success + ? ApiResponse.Success() + : ApiResponse.Error(ErrorCodes.NotFound, "字典项不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryOverridesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryOverridesController.cs new file mode 100644 index 0000000..43295ee --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryOverridesController.cs @@ -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; + +/// +/// 租户字典覆盖配置管理。 +/// +[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"; + + /// + /// 获取当前租户的覆盖配置列表。 + /// + [HttpGet] + [PermissionAuthorize("dictionary:override:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List(CancellationToken cancellationToken) + { + var headerError = EnsureTenantHeader>(); + if (headerError != null) + { + return headerError; + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var result = await overrideService.GetOverridesAsync(tenantId, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 获取指定字典分组的覆盖配置。 + /// + [HttpGet("{groupCode}")] + [PermissionAuthorize("dictionary:override:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(string groupCode, CancellationToken cancellationToken) + { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var result = await overrideService.GetOverrideAsync(tenantId, groupCode, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "覆盖配置不存在") + : ApiResponse.Ok(result); + } + + /// + /// 启用覆盖模式。 + /// + [HttpPost("{groupCode}/enable")] + [PermissionAuthorize("dictionary:override:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Enable(string groupCode, CancellationToken cancellationToken) + { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var result = await overrideService.EnableOverrideAsync(tenantId, groupCode, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 禁用覆盖模式。 + /// + [HttpPost("{groupCode}/disable")] + [PermissionAuthorize("dictionary:override:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Disable(string groupCode, CancellationToken cancellationToken) + { + var headerError = EnsureTenantHeader(); + 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, "覆盖配置不存在"); + } + + /// + /// 更新隐藏的系统字典项。 + /// + [HttpPut("{groupCode}/hidden-items")] + [PermissionAuthorize("dictionary:override:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpdateHiddenItems( + string groupCode, + [FromBody] DictionaryOverrideHiddenItemsRequest request, + CancellationToken cancellationToken) + { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var result = await overrideService.UpdateHiddenItemsAsync(tenantId, groupCode, request.HiddenItemIds, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新自定义排序。 + /// + [HttpPut("{groupCode}/sort-order")] + [PermissionAuthorize("dictionary:override:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpdateSortOrder( + string groupCode, + [FromBody] DictionaryOverrideSortOrderRequest request, + CancellationToken cancellationToken) + { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var result = await overrideService.UpdateCustomSortOrderAsync(tenantId, groupCode, request.SortOrder, cancellationToken); + return ApiResponse.Ok(result); + } + + private ApiResponse? EnsureTenantHeader() + { + if (!Request.Headers.TryGetValue(TenantIdHeaderName, out var tenantHeader) || string.IsNullOrWhiteSpace(tenantHeader)) + { + return ApiResponse.Error(StatusCodes.Status400BadRequest, $"缺少租户标识,请在请求头 {TenantIdHeaderName} 指定租户"); + } + + if (!long.TryParse(tenantHeader.FirstOrDefault(), out _)) + { + return ApiResponse.Error(StatusCodes.Status400BadRequest, $"租户标识无效,请在请求头 {TenantIdHeaderName} 指定正确的租户 ID"); + } + + return null; + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json index 247eaa4..6256ff4 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json @@ -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", diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json index e6b7183..3eb75e2 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json @@ -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", diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 5a797c0..7c4a6c2 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -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" ] }, diff --git a/src/Api/TakeoutSaaS.UserApi/Controllers/DictionaryController.cs b/src/Api/TakeoutSaaS.UserApi/Controllers/DictionaryController.cs new file mode 100644 index 0000000..3f3405d --- /dev/null +++ b/src/Api/TakeoutSaaS.UserApi/Controllers/DictionaryController.cs @@ -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; + +/// +/// 字典查询接口。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/user/v{version:apiVersion}/dictionary")] +public sealed class DictionaryController(DictionaryQueryService queryService) : BaseApiController +{ + /// + /// 获取指定字典分组的合并结果。 + /// + [HttpGet("{code}")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetByCode(string code, CancellationToken cancellationToken) + { + Response.Headers[HeaderNames.CacheControl] = "max-age=1800"; + var result = await queryService.GetMergedDictionaryAsync(code, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 批量获取字典分组。 + /// + [HttpPost("batch")] + [ProducesResponseType(typeof(ApiResponse>>), StatusCodes.Status200OK)] + public async Task>>> 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>>.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.UserApi/Program.cs b/src/Api/TakeoutSaaS.UserApi/Program.cs index 180aa83..25bd2a7 100644 --- a/src/Api/TakeoutSaaS.UserApi/Program.cs +++ b/src/Api/TakeoutSaaS.UserApi/Program.cs @@ -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(); diff --git a/src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj b/src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj index ed59abd3b0db16a1be14c9f166242c2eace42caa..e6d50e9faf26fb9355619e4c0d873dceafc56b37 100644 GIT binary patch delta 156 zcmaDQbwF;zF>Yy3hCGHeh9ZVUhGK>iAYICk45UketklVj+|m@uCvwjgc45c_o0!Rv Z540%}Xl^CQ+{y2_`LM}tPUPlf0sv_^BsKs5 delta 12 UcmX>g_eyHRG49QCc$Y8&04DDRWdHyG diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/ICsvDictionaryParser.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/ICsvDictionaryParser.cs new file mode 100644 index 0000000..6b206b2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/ICsvDictionaryParser.cs @@ -0,0 +1,17 @@ +using TakeoutSaaS.Application.Dictionary.Models; + +namespace TakeoutSaaS.Application.Dictionary.Abstractions; + +/// +/// CSV 字典导入解析器。 +/// +public interface ICsvDictionaryParser +{ + /// + /// 解析 CSV 数据。 + /// + /// 输入流。 + /// 取消标记。 + /// 解析后的记录列表。 + Task> ParseAsync(Stream stream, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryHybridCache.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryHybridCache.cs new file mode 100644 index 0000000..8a4c736 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryHybridCache.cs @@ -0,0 +1,26 @@ +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Application.Dictionary.Abstractions; + +/// +/// 字典两级缓存访问接口。 +/// +public interface IDictionaryHybridCache +{ + /// + /// 读取缓存,不存在时通过工厂生成并回填。 + /// + Task GetOrCreateAsync( + string key, + TimeSpan ttl, + Func> factory, + CancellationToken cancellationToken = default); + + /// + /// 按前缀失效缓存。 + /// + Task InvalidateAsync( + string prefix, + CacheInvalidationOperation operation = CacheInvalidationOperation.Update, + CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IJsonDictionaryParser.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IJsonDictionaryParser.cs new file mode 100644 index 0000000..990639f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IJsonDictionaryParser.cs @@ -0,0 +1,17 @@ +using TakeoutSaaS.Application.Dictionary.Models; + +namespace TakeoutSaaS.Application.Dictionary.Abstractions; + +/// +/// JSON 字典导入解析器。 +/// +public interface IJsonDictionaryParser +{ + /// + /// 解析 JSON 数据。 + /// + /// 输入流。 + /// 取消标记。 + /// 解析后的记录列表。 + Task> ParseAsync(Stream stream, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryGroupRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryGroupRequest.cs index 10454ff..17de073 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryGroupRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryGroupRequest.cs @@ -26,6 +26,11 @@ public sealed class CreateDictionaryGroupRequest [Required] public DictionaryScope Scope { get; set; } = DictionaryScope.Business; + /// + /// 是否允许租户覆盖。 + /// + public bool AllowOverride { get; set; } + /// /// 描述信息。 /// diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs index b19ccce..bcd5c4e 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs @@ -19,14 +19,14 @@ public sealed class CreateDictionaryItemRequest /// /// 字典项键。 /// - [Required, MaxLength(64)] + [Required, MaxLength(128)] public string Key { get; set; } = string.Empty; /// /// 字典项值。 /// - [Required, MaxLength(256)] - public string Value { get; set; } = string.Empty; + [Required] + public Dictionary Value { get; set; } = new(StringComparer.OrdinalIgnoreCase); /// /// 是否默认项。 diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryGroupQuery.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryGroupQuery.cs index 861b082..c7afa74 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryGroupQuery.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryGroupQuery.cs @@ -12,6 +12,36 @@ public sealed class DictionaryGroupQuery /// public DictionaryScope? Scope { get; set; } + /// + /// 关键字(匹配编码或名称)。 + /// + public string? Keyword { get; set; } + + /// + /// 启用状态过滤。 + /// + public bool? IsEnabled { get; set; } + + /// + /// 分页页码。 + /// + public int Page { get; set; } = 1; + + /// + /// 分页大小。 + /// + public int PageSize { get; set; } = 20; + + /// + /// 排序字段。 + /// + public string? SortBy { get; set; } + + /// + /// 排序方向(asc/desc)。 + /// + public string? SortOrder { get; set; } + /// /// 是否包含字典项。 /// diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryImportRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryImportRequest.cs new file mode 100644 index 0000000..76d69b7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryImportRequest.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 字典导入请求。 +/// +public sealed class DictionaryImportRequest +{ + /// + /// 分组 ID。 + /// + public long GroupId { get; init; } + + /// + /// 文件名称。 + /// + public string FileName { get; init; } = string.Empty; + + /// + /// 文件大小(字节)。 + /// + public long FileSize { get; init; } + + /// + /// 冲突解决模式。 + /// + public ConflictResolutionMode ConflictMode { get; init; } = ConflictResolutionMode.Skip; + + /// + /// 文件流。 + /// + public Stream FileStream { get; init; } = Stream.Null; +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryOverrideHiddenItemsRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryOverrideHiddenItemsRequest.cs new file mode 100644 index 0000000..dd92927 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryOverrideHiddenItemsRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 更新字典覆盖隐藏项请求。 +/// +public sealed class DictionaryOverrideHiddenItemsRequest +{ + /// + /// 需要隐藏的系统字典项 ID 列表。 + /// + [Required] + public long[] HiddenItemIds { get; set; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryOverrideSortOrderRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryOverrideSortOrderRequest.cs new file mode 100644 index 0000000..cb7eabf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryOverrideSortOrderRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 更新字典覆盖排序请求。 +/// +public sealed class DictionaryOverrideSortOrderRequest +{ + /// + /// 排序配置(字典项 ID -> 排序值)。 + /// + [Required] + public Dictionary SortOrder { get; set; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs index 4ed0fdc..8c66b3a 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs @@ -19,8 +19,18 @@ public sealed class UpdateDictionaryGroupRequest [MaxLength(512)] public string? Description { get; set; } + /// + /// 是否允许租户覆盖。 + /// + public bool AllowOverride { get; set; } + /// /// 是否启用。 /// public bool IsEnabled { get; set; } = true; + + /// + /// 行版本,用于并发控制。 + /// + public byte[]? RowVersion { get; set; } } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs index f2c9871..b2d00f5 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs @@ -7,11 +7,17 @@ namespace TakeoutSaaS.Application.Dictionary.Contracts; /// public sealed class UpdateDictionaryItemRequest { + /// + /// 字典项键。 + /// + [Required, MaxLength(128)] + public string Key { get; set; } = string.Empty; + /// /// 字典项值。 /// - [Required, MaxLength(256)] - public string Value { get; set; } = string.Empty; + [Required] + public Dictionary Value { get; set; } = new(StringComparer.OrdinalIgnoreCase); /// /// 是否默认项。 @@ -33,4 +39,9 @@ public sealed class UpdateDictionaryItemRequest /// [MaxLength(512)] public string? Description { get; set; } + + /// + /// 行版本,用于并发控制。 + /// + public byte[]? RowVersion { get; set; } } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs index 5b03aa0..ce1411e 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -15,6 +15,11 @@ public static class DictionaryServiceCollectionExtensions public static IServiceCollection AddDictionaryApplication(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs index d932554..8fb64fc 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs @@ -20,6 +20,12 @@ public sealed class DictionaryGroupDto /// public string Code { get; init; } = string.Empty; + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + /// /// 分组名称。 /// @@ -35,11 +41,31 @@ public sealed class DictionaryGroupDto /// public string? Description { get; init; } + /// + /// 是否允许覆盖。 + /// + public bool AllowOverride { get; init; } + /// /// 是否启用。 /// public bool IsEnabled { get; init; } + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间(UTC)。 + /// + public DateTime? UpdatedAt { get; init; } + + /// + /// 并发控制字段。 + /// + public byte[] RowVersion { get; init; } = Array.Empty(); + /// /// 字典项集合。 /// diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryImportResultDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryImportResultDto.cs new file mode 100644 index 0000000..e1ddbe5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryImportResultDto.cs @@ -0,0 +1,53 @@ +namespace TakeoutSaaS.Application.Dictionary.Models; + +/// +/// 字典导入结果 DTO。 +/// +public sealed class DictionaryImportResultDto +{ + /// + /// 成功数量。 + /// + public int SuccessCount { get; init; } + + /// + /// 跳过数量。 + /// + public int SkipCount { get; init; } + + /// + /// 错误数量。 + /// + public int ErrorCount { get; init; } + + /// + /// 错误列表。 + /// + public IReadOnlyList Errors { get; init; } = Array.Empty(); + + /// + /// 处理耗时。 + /// + public TimeSpan Duration { get; init; } + + /// + /// 导入错误详情。 + /// + public sealed class ImportError + { + /// + /// 行号。 + /// + public int RowNumber { get; init; } + + /// + /// 字段名。 + /// + public string Field { get; init; } = string.Empty; + + /// + /// 错误信息。 + /// + public string Message { get; init; } = string.Empty; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryImportRow.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryImportRow.cs new file mode 100644 index 0000000..c6039b1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryImportRow.cs @@ -0,0 +1,47 @@ +namespace TakeoutSaaS.Application.Dictionary.Models; + +/// +/// 字典导入记录。 +/// +public sealed class DictionaryImportRow +{ + /// + /// 行号(从 1 开始,包含表头行的偏移)。 + /// + public int RowNumber { get; init; } + + /// + /// 字典分组编码。 + /// + public string? Code { get; init; } + + /// + /// 字典项键。 + /// + public string? Key { get; init; } + + /// + /// 字典项值(JSON 字符串)。 + /// + public string? Value { get; init; } + + /// + /// 排序值。 + /// + public int? SortOrder { get; init; } + + /// + /// 是否启用。 + /// + public bool? IsEnabled { get; init; } + + /// + /// 描述。 + /// + public string? Description { get; init; } + + /// + /// 来源:system / tenant。 + /// + public string? Source { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs index c0282ad..36a56c8 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs @@ -28,7 +28,8 @@ public sealed class DictionaryItemDto /// /// 值。 /// - public string Value { get; init; } = string.Empty; + [JsonPropertyName("value")] + public Dictionary Value { get; init; } = new(StringComparer.OrdinalIgnoreCase); /// /// 是否默认值。 @@ -49,4 +50,14 @@ public sealed class DictionaryItemDto /// 描述。 /// public string? Description { get; init; } + + /// + /// 来源:system / tenant。 + /// + public string Source { get; init; } = "system"; + + /// + /// 并发控制字段。 + /// + public byte[] RowVersion { get; init; } = Array.Empty(); } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/OverrideConfigDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/OverrideConfigDto.cs new file mode 100644 index 0000000..574e800 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/OverrideConfigDto.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Dictionary.Models; + +/// +/// 租户字典覆盖配置 DTO。 +/// +public sealed class OverrideConfigDto +{ + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 系统字典分组编码。 + /// + public string SystemDictionaryGroupCode { get; init; } = string.Empty; + + /// + /// 是否启用覆盖。 + /// + public bool OverrideEnabled { get; init; } + + /// + /// 隐藏的系统字典项 ID 列表。 + /// + public long[] HiddenSystemItemIds { get; init; } = Array.Empty(); + + /// + /// 自定义排序映射。 + /// + public Dictionary CustomSortOrder { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs index fd59e46..13365c6 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs @@ -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. 持久化并失效缓存 - await repository.SaveChangesAsync(cancellationToken); + 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. 持久化并失效缓存 - await repository.SaveChangesAsync(cancellationToken); + 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() }; } @@ -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); } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCacheKeys.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCacheKeys.cs new file mode 100644 index 0000000..9a45af5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCacheKeys.cs @@ -0,0 +1,44 @@ +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Domain.Dictionary.ValueObjects; + +namespace TakeoutSaaS.Application.Dictionary.Services; + +/// +/// 字典缓存键生成器。 +/// +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"; +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCommandService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCommandService.cs new file mode 100644 index 0000000..8be748a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCommandService.cs @@ -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; + +/// +/// 字典写操作服务。 +/// +public sealed class DictionaryCommandService( + IDictionaryGroupRepository groupRepository, + IDictionaryItemRepository itemRepository, + IDictionaryHybridCache cache, + ITenantProvider tenantProvider, + ILogger logger) +{ + /// + /// 创建字典分组。 + /// + public async Task 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); + } + + /// + /// 更新字典分组。 + /// + public async Task 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); + } + + /// + /// 删除字典分组。 + /// + public async Task 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; + } + + /// + /// 创建字典项。 + /// + public async Task 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); + } + + /// + /// 更新字典项。 + /// + public async Task 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); + } + + /// + /// 删除字典项。 + /// + public async Task 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 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 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 + { + 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 + { + 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); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs new file mode 100644 index 0000000..b3553c4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs @@ -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; + +/// +/// 字典导入导出服务。 +/// +public sealed class DictionaryImportExportService( + ICsvDictionaryParser csvParser, + IJsonDictionaryParser jsonParser, + IDictionaryGroupRepository groupRepository, + IDictionaryItemRepository itemRepository, + IDictionaryImportLogRepository importLogRepository, + IDictionaryHybridCache cache, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUser, + ILogger logger) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// 导出 CSV。 + /// + 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); + } + + /// + /// 导出 JSON。 + /// + 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); + } + + /// + /// 导入 CSV。 + /// + public async Task ImportFromCsvAsync(DictionaryImportRequest request, CancellationToken cancellationToken = default) + { + var rows = await csvParser.ParseAsync(request.FileStream, cancellationToken); + return await ImportAsync(request, rows, "CSV", cancellationToken); + } + + /// + /// 导入 JSON。 + /// + public async Task ImportFromJsonAsync(DictionaryImportRequest request, CancellationToken cancellationToken = default) + { + var rows = await jsonParser.ParseAsync(request.FileStream, cancellationToken); + return await ImportAsync(request, rows, "JSON", cancellationToken); + } + + private async Task ImportAsync( + DictionaryImportRequest request, + IReadOnlyList rows, + string format, + CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + var group = await RequireGroupAsync(request.GroupId, cancellationToken); + EnsureGroupWritable(group); + + var errors = new List(); + var validRows = new List(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(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> 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 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 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 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 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 + { + 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 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 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; } + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryMapper.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryMapper.cs new file mode 100644 index 0000000..06ea7fc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryMapper.cs @@ -0,0 +1,46 @@ +using TakeoutSaaS.Application.Dictionary.Models; +using TakeoutSaaS.Domain.Dictionary.Entities; + +namespace TakeoutSaaS.Application.Dictionary.Services; + +/// +/// 字典实体映射辅助。 +/// +internal static class DictionaryMapper +{ + internal static DictionaryGroupDto ToGroupDto(DictionaryGroup group, IReadOnlyList? 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() + }; + } + + 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryMergeService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryMergeService.cs new file mode 100644 index 0000000..61e4642 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryMergeService.cs @@ -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; + +/// +/// 字典覆盖合并服务。 +/// +public sealed class DictionaryMergeService( + IDictionaryGroupRepository groupRepository, + IDictionaryItemRepository itemRepository, + ITenantDictionaryOverrideRepository overrideRepository) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// 合并系统字典项与租户字典项。 + /// + public async Task> 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(); + } + + 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(); + + var activeTenant = tenantItems.Where(item => item.IsEnabled).ToList(); + var hiddenSet = new HashSet(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 ParseSortOrder(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new Dictionary(); + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? new Dictionary(); + } + catch (JsonException) + { + return new Dictionary(); + } + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryOverrideService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryOverrideService.cs new file mode 100644 index 0000000..325500a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryOverrideService.cs @@ -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; + +/// +/// 租户字典覆盖配置服务。 +/// +public sealed class DictionaryOverrideService( + IDictionaryGroupRepository groupRepository, + IDictionaryItemRepository itemRepository, + ITenantDictionaryOverrideRepository overrideRepository, + IDictionaryHybridCache cache) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// 获取租户覆盖配置列表。 + /// + public async Task> 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(); + } + + 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(configs.Count); + foreach (var config in configs) + { + if (codeMap.TryGetValue(config.SystemDictionaryGroupId, out var code)) + { + result.Add(MapOverrideDto(config, code)); + } + } + + return result; + } + + /// + /// 获取租户指定分组的覆盖配置。 + /// + public async Task 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); + } + + /// + /// 启用覆盖配置。 + /// + public async Task 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(), + 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); + } + + /// + /// 关闭覆盖配置。 + /// + public async Task 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; + } + + /// + /// 更新隐藏系统字典项。 + /// + public async Task 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(); + + 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); + } + + /// + /// 更新自定义排序配置。 + /// + public async Task UpdateCustomSortOrderAsync( + long tenantId, + string systemGroupCode, + Dictionary 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(), + 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> 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 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 ParseSortOrder(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new Dictionary(); + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? new Dictionary(); + } + catch (JsonException) + { + return new Dictionary(); + } + } + + private static string SerializeSortOrder(Dictionary map) + => JsonSerializer.Serialize(map ?? new Dictionary(), JsonOptions); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryQueryService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryQueryService.cs new file mode 100644 index 0000000..3d33d0d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryQueryService.cs @@ -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; + +/// +/// 字典查询服务。 +/// +public sealed class DictionaryQueryService( + IDictionaryGroupRepository groupRepository, + IDictionaryItemRepository itemRepository, + DictionaryMergeService mergeService, + IDictionaryHybridCache cache, + ITenantProvider tenantProvider) +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30); + + /// + /// 获取字典分组分页数据。 + /// + public async Task> 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( + 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(groups.Count); + foreach (var group in groups) + { + IReadOnlyList? 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(), + Page = query.Page, + PageSize = query.PageSize, + TotalCount = 0 + }; + + return new PagedResult(page.Items, page.Page, page.PageSize, page.TotalCount); + } + + /// + /// 获取字典分组详情。 + /// + public async Task GetGroupByIdAsync(long groupId, CancellationToken cancellationToken = default) + { + var group = await groupRepository.GetByIdAsync(groupId, cancellationToken); + if (group == null) + { + return null; + } + + EnsureGroupReadable(group); + return DictionaryMapper.ToGroupDto(group); + } + + /// + /// 获取分组下字典项列表。 + /// + public async Task> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default) + { + var cacheKey = DictionaryCacheKeys.BuildItemKey(groupId); + var cached = await cache.GetOrCreateAsync>( + 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(); + } + + /// + /// 获取合并后的字典项列表。 + /// + public async Task> 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>( + cacheKey, + CacheTtl, + async token => + { + var systemGroup = await groupRepository.GetByCodeAsync(0, normalized, token); + if (systemGroup == null || !systemGroup.IsEnabled) + { + return Array.Empty(); + } + + 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(); + } + + /// + /// 批量获取字典项。 + /// + public async Task>> BatchGetDictionariesAsync( + IEnumerable codes, + CancellationToken cancellationToken = default) + { + var normalizedCodes = codes + .Where(DictionaryCode.IsValid) + .Select(code => new DictionaryCode(code).Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var result = new Dictionary>(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 Items { get; init; } = Array.Empty(); + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalCount { get; init; } + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryValueConverter.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryValueConverter.cs new file mode 100644 index 0000000..7fe46a2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryValueConverter.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using TakeoutSaaS.Domain.Dictionary.ValueObjects; + +namespace TakeoutSaaS.Application.Dictionary.Services; + +/// +/// 字典值序列化与反序列化辅助。 +/// +internal static class DictionaryValueConverter +{ + /// + /// 将多语言字典序列化为 JSON。 + /// + public static string Serialize(Dictionary values) + { + var i18n = new I18nValue(values); + return i18n.ToJson(); + } + + /// + /// 将 JSON 解析为多语言字典。 + /// + public static Dictionary Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + try + { + return I18nValue.FromJson(json).ToDictionary(); + } + catch (JsonException) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["zh-CN"] = json.Trim() + }; + } + catch (ArgumentException) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Validators/CreateDictionaryGroupValidator.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Validators/CreateDictionaryGroupValidator.cs new file mode 100644 index 0000000..024e6ca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Validators/CreateDictionaryGroupValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using TakeoutSaaS.Application.Dictionary.Contracts; + +namespace TakeoutSaaS.Application.Dictionary.Validators; + +/// +/// 创建字典分组请求验证器。 +/// +public sealed class CreateDictionaryGroupValidator : AbstractValidator +{ + 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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Validators/I18nValueValidator.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Validators/I18nValueValidator.cs new file mode 100644 index 0000000..2fdb6ef --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Validators/I18nValueValidator.cs @@ -0,0 +1,32 @@ +using FluentValidation; + +namespace TakeoutSaaS.Application.Dictionary.Validators; + +/// +/// 多语言值校验器。 +/// +public sealed class I18nValueValidator : AbstractValidator> +{ + 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? values) + { + if (values == null || values.Count == 0) + { + return false; + } + + return values.Any(pair => !string.IsNullOrWhiteSpace(pair.Value)); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Validators/UpdateDictionaryItemValidator.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Validators/UpdateDictionaryItemValidator.cs new file mode 100644 index 0000000..4de9c23 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Validators/UpdateDictionaryItemValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.Dictionary.Contracts; + +namespace TakeoutSaaS.Application.Dictionary.Validators; + +/// +/// 更新字典项请求验证器。 +/// +public sealed class UpdateDictionaryItemValidator : AbstractValidator +{ + public UpdateDictionaryItemValidator() + { + RuleFor(x => x.Key) + .NotEmpty() + .MaximumLength(128); + + RuleFor(x => x.Value) + .SetValidator(new I18nValueValidator()); + + RuleFor(x => x.SortOrder) + .GreaterThanOrEqualTo(0); + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs index 72d18d4..6af83c7 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs @@ -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 ( + DbUpdateConcurrencyException => ( + StatusCodes.Status409Conflict, + ApiResponse.Error( + ErrorCodes.Conflict, + "数据已被他人修改,请刷新后重试", + new Dictionary + { + ["RowVersion"] = ["数据已被他人修改,请刷新后重试"] + })), + UnauthorizedAccessException => ( + StatusCodes.Status403Forbidden, + ApiResponse.Error(ErrorCodes.Forbidden, "无权访问该资源")), + SharedValidationException validationException => ( StatusCodes.Status422UnprocessableEntity, ApiResponse.Error(ErrorCodes.ValidationFailed, "请求参数验证失败", validationException.Errors)), + FluentValidationException fluentValidationException => ( + StatusCodes.Status422UnprocessableEntity, + ApiResponse.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.Error(ErrorCodes.InternalServerError, "服务器开小差啦,请稍后再试")) }; } + + private static IDictionary NormalizeValidationErrors(IEnumerable failures) + { + var result = new Dictionary>(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(); + 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); + } } diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs index 54c6bcb..48021b1 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs @@ -51,10 +51,12 @@ public static class SwaggerExtensions { var provider = app.ApplicationServices.GetRequiredService(); var settings = app.ApplicationServices.GetRequiredService(); + 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. 使用相对路径适配反向代理/网关前缀 diff --git a/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj b/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj index f7a4f0e..ddb24c5 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj +++ b/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/CacheInvalidationLog.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/CacheInvalidationLog.cs new file mode 100644 index 0000000..4fd903f --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/CacheInvalidationLog.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Entities; + +/// +/// 字典缓存失效日志。 +/// +public sealed class CacheInvalidationLog : MultiTenantEntityBase +{ + /// + /// 发生时间(UTC)。 + /// + public DateTime Timestamp { get; set; } + + /// + /// 字典编码。 + /// + public string DictionaryCode { get; set; } = string.Empty; + + /// + /// 字典作用域。 + /// + public DictionaryScope Scope { get; set; } + + /// + /// 影响的缓存键数量。 + /// + public int AffectedCacheKeyCount { get; set; } + + /// + /// 操作人用户标识。 + /// + public long OperatorId { get; set; } + + /// + /// 操作类型。 + /// + public CacheInvalidationOperation Operation { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs index bf48fca..b4430a8 100644 --- a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs @@ -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 /// /// 分组编码(唯一)。 /// - public string Code { get; set; } = string.Empty; + public DictionaryCode Code { get; set; } /// /// 分组名称。 @@ -23,6 +24,11 @@ public sealed class DictionaryGroup : MultiTenantEntityBase /// public DictionaryScope Scope { get; set; } = DictionaryScope.Business; + /// + /// 是否允许租户覆盖。 + /// + public bool AllowOverride { get; set; } + /// /// 描述信息。 /// @@ -33,6 +39,11 @@ public sealed class DictionaryGroup : MultiTenantEntityBase /// public bool IsEnabled { get; set; } = true; + /// + /// 并发控制字段。 + /// + public byte[] RowVersion { get; set; } = Array.Empty(); + /// /// 字典项集合。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryImportLog.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryImportLog.cs new file mode 100644 index 0000000..ce06860 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryImportLog.cs @@ -0,0 +1,65 @@ +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Entities; + +/// +/// 字典导入审计日志。 +/// +public sealed class DictionaryImportLog : MultiTenantEntityBase +{ + /// + /// 操作人用户标识。 + /// + public long OperatorId { get; set; } + + /// + /// 字典分组编码。 + /// + public string DictionaryGroupCode { get; set; } = string.Empty; + + /// + /// 导入文件名。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// 文件大小(字节)。 + /// + public long FileSize { get; set; } + + /// + /// 文件格式(CSV/JSON)。 + /// + public string Format { get; set; } = string.Empty; + + /// + /// 冲突处理模式。 + /// + public ConflictResolutionMode ConflictMode { get; set; } = ConflictResolutionMode.Skip; + + /// + /// 成功导入数量。 + /// + public int SuccessCount { get; set; } + + /// + /// 跳过数量。 + /// + public int SkipCount { get; set; } + + /// + /// 错误明细(JSON)。 + /// + public string? ErrorDetails { get; set; } + + /// + /// 处理时间(UTC)。 + /// + public DateTime ProcessedAt { get; set; } + + /// + /// 处理耗时。 + /// + public TimeSpan Duration { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs index 1a38aba..b2ccb74 100644 --- a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs @@ -42,6 +42,11 @@ public sealed class DictionaryItem : MultiTenantEntityBase /// public string? Description { get; set; } + /// + /// 并发控制字段。 + /// + public byte[] RowVersion { get; set; } = Array.Empty(); + /// /// 导航属性:所属分组。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/TenantDictionaryOverride.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/TenantDictionaryOverride.cs new file mode 100644 index 0000000..abe4ac1 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/TenantDictionaryOverride.cs @@ -0,0 +1,64 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Entities; + +/// +/// 租户字典覆盖配置。 +/// +public sealed class TenantDictionaryOverride : IMultiTenantEntity, IAuditableEntity +{ + /// + /// 所属租户 ID。 + /// + public long TenantId { get; set; } + + /// + /// 系统字典分组 ID。 + /// + public long SystemDictionaryGroupId { get; set; } + + /// + /// 是否启用覆盖。 + /// + public bool OverrideEnabled { get; set; } + + /// + /// 隐藏的系统字典项 ID 列表。 + /// + public long[] HiddenSystemItemIds { get; set; } = Array.Empty(); + + /// + /// 自定义排序映射(JSON)。 + /// + public string CustomSortOrder { get; set; } = "{}"; + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; set; } + + /// + /// 最近更新时间(UTC)。 + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// 删除时间(UTC)。 + /// + public DateTime? DeletedAt { get; set; } + + /// + /// 创建人用户标识。 + /// + public long? CreatedBy { get; set; } + + /// + /// 最后更新人用户标识。 + /// + public long? UpdatedBy { get; set; } + + /// + /// 删除人用户标识。 + /// + public long? DeletedBy { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/CacheInvalidationOperation.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/CacheInvalidationOperation.cs new file mode 100644 index 0000000..b144403 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/CacheInvalidationOperation.cs @@ -0,0 +1,11 @@ +namespace TakeoutSaaS.Domain.Dictionary.Enums; + +/// +/// 缓存失效操作类型。 +/// +public enum CacheInvalidationOperation +{ + Create = 1, + Update = 2, + Delete = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/ConflictResolutionMode.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/ConflictResolutionMode.cs new file mode 100644 index 0000000..51e611d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/ConflictResolutionMode.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Dictionary.Enums; + +/// +/// 字典导入冲突处理策略。 +/// +public enum ConflictResolutionMode +{ + /// + /// 跳过重复项。 + /// + Skip = 1, + + /// + /// 覆盖重复项。 + /// + Overwrite = 2, + + /// + /// 追加新项。 + /// + Append = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/ICacheInvalidationLogRepository.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/ICacheInvalidationLogRepository.cs new file mode 100644 index 0000000..90f8f12 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/ICacheInvalidationLogRepository.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Domain.Dictionary.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Repositories; + +/// +/// 缓存失效日志仓储。 +/// +public interface ICacheInvalidationLogRepository +{ + /// + /// 新增失效日志。 + /// + Task AddAsync(CacheInvalidationLog log, CancellationToken cancellationToken = default); + + /// + /// 分页查询失效日志。 + /// + Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( + int page, + int pageSize, + DateTime? startDate, + DateTime? endDate, + CancellationToken cancellationToken = default); + + /// + /// 保存变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryGroupRepository.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryGroupRepository.cs new file mode 100644 index 0000000..d7c5a76 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryGroupRepository.cs @@ -0,0 +1,104 @@ +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Domain.Dictionary.ValueObjects; + +namespace TakeoutSaaS.Domain.Dictionary.Repositories; + +/// +/// 字典分组仓储契约。 +/// +public interface IDictionaryGroupRepository +{ + /// + /// 按 ID 获取字典分组。 + /// + /// 分组 ID。 + /// 取消标记。 + /// 分组实体或 null。 + Task GetByIdAsync(long groupId, CancellationToken cancellationToken = default); + + /// + /// 按编码获取字典分组。 + /// + /// 租户 ID。 + /// 分组编码。 + /// 取消标记。 + /// 分组实体或 null。 + Task GetByCodeAsync(long tenantId, DictionaryCode code, CancellationToken cancellationToken = default); + + /// + /// 分页获取字典分组。 + /// + /// 租户 ID。 + /// 作用域过滤。 + /// 关键字过滤。 + /// 启用状态过滤。 + /// 页码。 + /// 页大小。 + /// 排序字段。 + /// 是否降序。 + /// 取消标记。 + /// 分组集合。 + Task> GetPagedAsync( + long tenantId, + DictionaryScope? scope, + string? keyword, + bool? isEnabled, + int page, + int pageSize, + string? sortBy, + bool sortDescending, + CancellationToken cancellationToken = default); + + /// + /// 获取满足条件的分组数量。 + /// + /// 租户 ID。 + /// 作用域过滤。 + /// 关键字过滤。 + /// 启用状态过滤。 + /// 取消标记。 + /// 分组数量。 + Task CountAsync( + long tenantId, + DictionaryScope? scope, + string? keyword, + bool? isEnabled, + CancellationToken cancellationToken = default); + + /// + /// 批量获取字典分组。 + /// + /// 分组 ID 列表。 + /// 取消标记。 + /// 分组集合。 + Task> GetByIdsAsync(IEnumerable groupIds, CancellationToken cancellationToken = default); + + /// + /// 新增分组。 + /// + /// 分组实体。 + /// 取消标记。 + Task AddAsync(DictionaryGroup group, CancellationToken cancellationToken = default); + + /// + /// 更新分组。 + /// + /// 分组实体。 + /// 取消标记。 + Task UpdateAsync(DictionaryGroup group, CancellationToken cancellationToken = default); + + /// + /// 删除分组。 + /// + /// 分组实体。 + /// 取消标记。 + Task RemoveAsync(DictionaryGroup group, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryImportLogRepository.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryImportLogRepository.cs new file mode 100644 index 0000000..cc659d1 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryImportLogRepository.cs @@ -0,0 +1,22 @@ +using TakeoutSaaS.Domain.Dictionary.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Repositories; + +/// +/// 字典导入日志仓储契约。 +/// +public interface IDictionaryImportLogRepository +{ + /// + /// 新增导入日志。 + /// + /// 导入日志。 + /// 取消标记。 + Task AddAsync(DictionaryImportLog log, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + /// 取消标记。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryItemRepository.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryItemRepository.cs new file mode 100644 index 0000000..8ada246 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryItemRepository.cs @@ -0,0 +1,64 @@ +using TakeoutSaaS.Domain.Dictionary.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Repositories; + +/// +/// 字典项仓储契约。 +/// +public interface IDictionaryItemRepository +{ + /// + /// 根据 ID 获取字典项。 + /// + /// 字典项 ID。 + /// 取消标记。 + /// 字典项或 null。 + Task GetByIdAsync(long itemId, CancellationToken cancellationToken = default); + + /// + /// 获取分组下字典项列表。 + /// + /// 租户 ID。 + /// 分组 ID。 + /// 取消标记。 + /// 字典项集合。 + Task> GetByGroupIdAsync(long tenantId, long groupId, CancellationToken cancellationToken = default); + + /// + /// 获取合并后的系统/租户字典项。 + /// + /// 租户 ID。 + /// 系统分组 ID。 + /// 是否包含租户覆盖。 + /// 取消标记。 + /// 字典项集合。 + Task> GetMergedItemsAsync(long tenantId, long systemGroupId, bool includeOverrides, CancellationToken cancellationToken = default); + + /// + /// 新增字典项。 + /// + /// 字典项实体。 + /// 取消标记。 + Task AddAsync(DictionaryItem item, CancellationToken cancellationToken = default); + + /// + /// 更新字典项。 + /// + /// 字典项实体。 + /// 取消标记。 + Task UpdateAsync(DictionaryItem item, CancellationToken cancellationToken = default); + + /// + /// 删除字典项。 + /// + /// 字典项实体。 + /// 取消标记。 + Task RemoveAsync(DictionaryItem item, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/ITenantDictionaryOverrideRepository.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/ITenantDictionaryOverrideRepository.cs new file mode 100644 index 0000000..72e5a1e --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/ITenantDictionaryOverrideRepository.cs @@ -0,0 +1,47 @@ +using TakeoutSaaS.Domain.Dictionary.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Repositories; + +/// +/// 租户字典覆盖仓储契约。 +/// +public interface ITenantDictionaryOverrideRepository +{ + /// + /// 获取租户覆盖配置。 + /// + /// 租户 ID。 + /// 系统字典分组 ID。 + /// 取消标记。 + /// 覆盖配置或 null。 + Task GetAsync(long tenantId, long systemGroupId, CancellationToken cancellationToken = default); + + /// + /// 获取租户全部覆盖配置。 + /// + /// 租户 ID。 + /// 取消标记。 + /// 覆盖配置列表。 + Task> ListAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增覆盖配置。 + /// + /// 覆盖配置。 + /// 取消标记。 + Task AddAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default); + + /// + /// 更新覆盖配置。 + /// + /// 覆盖配置。 + /// 取消标记。 + Task UpdateAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/ValueObjects/DictionaryCode.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/ValueObjects/DictionaryCode.cs new file mode 100644 index 0000000..039766d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/ValueObjects/DictionaryCode.cs @@ -0,0 +1,91 @@ +using System.Text.RegularExpressions; + +namespace TakeoutSaaS.Domain.Dictionary.ValueObjects; + +/// +/// 字典分组编码值对象。 +/// +public readonly struct DictionaryCode : IEquatable +{ + private static readonly Regex CodePattern = new("^[a-zA-Z0-9_]{2,64}$", RegexOptions.Compiled); + private readonly string? _value; + + /// + /// 初始化字典编码并进行规范化。 + /// + /// 原始编码。 + /// 编码非法时抛出。 + public DictionaryCode(string value) + { + _value = Normalize(value); + } + + /// + /// 规范化后的编码值。 + /// + public string Value => _value ?? string.Empty; + + /// + /// 判断编码是否符合规则。 + /// + public static bool IsValid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var trimmed = value.Trim(); + return CodePattern.IsMatch(trimmed); + } + + /// + /// 规范化编码(去空格、转小写并校验)。 + /// + /// 原始编码。 + /// 规范化后的编码。 + /// 编码非法时抛出。 + 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(); + } + + /// + public bool Equals(DictionaryCode other) + => StringComparer.Ordinal.Equals(Value, other.Value); + + /// + public override bool Equals(object? obj) + => obj is DictionaryCode other && Equals(other); + + /// + public override int GetHashCode() + => StringComparer.Ordinal.GetHashCode(Value); + + /// + 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); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/ValueObjects/I18nValue.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/ValueObjects/I18nValue.cs new file mode 100644 index 0000000..00ea1c2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/ValueObjects/I18nValue.cs @@ -0,0 +1,151 @@ +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace TakeoutSaaS.Domain.Dictionary.ValueObjects; + +/// +/// 多语言字典值对象,封装语言键值映射。 +/// +public sealed class I18nValue : IEquatable +{ + private static readonly string[] FallbackLocales = ["zh-CN", "en"]; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + private readonly Dictionary _values; + + /// + /// 初始化多语言值。 + /// + /// 语言键值映射。 + /// 传入值为空或无有效条目时抛出。 + public I18nValue(IDictionary values) + { + if (values == null) + { + throw new ArgumentNullException(nameof(values)); + } + + _values = new Dictionary(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)); + } + } + + /// + /// 语言键值只读视图。 + /// + public IReadOnlyDictionary Values => _values; + + /// + /// 获取指定语言值,支持回退策略。 + /// + /// 语言标识。 + /// 匹配语言值。 + 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(); + } + + /// + /// 转换为普通字典。 + /// + public Dictionary ToDictionary() + => new(_values, StringComparer.OrdinalIgnoreCase); + + /// + /// 转换为 JSON 字符串。 + /// + public string ToJson() + => JsonSerializer.Serialize(_values, JsonOptions); + + /// + /// 从 JSON 字符串解析多语言值。 + /// + public static I18nValue FromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + throw new ArgumentException("JSON payload is required.", nameof(json)); + } + + var values = JsonSerializer.Deserialize>(json, JsonOptions) ?? new Dictionary(); + return new I18nValue(values); + } + + /// + 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; + } + + /// + public override bool Equals(object? obj) + => obj is I18nValue other && Equals(other); + + /// + 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(); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheMetricsCollector.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheMetricsCollector.cs new file mode 100644 index 0000000..007ba4b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheMetricsCollector.cs @@ -0,0 +1,212 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Caching; + +/// +/// 缓存命中/耗时指标采集器。 +/// +public sealed class CacheMetricsCollector +{ + private const string MeterName = "TakeoutSaaS.DictionaryCache"; + private static readonly Meter Meter = new(MeterName, "1.0.0"); + + private readonly Counter _hitCounter; + private readonly Counter _missCounter; + private readonly Counter _invalidationCounter; + private readonly Histogram _durationHistogram; + private readonly ConcurrentQueue _queries = new(); + private readonly TimeSpan _retention = TimeSpan.FromDays(7); + + private long _hitTotal; + private long _missTotal; + + /// + /// 初始化指标采集器。 + /// + public CacheMetricsCollector() + { + _hitCounter = Meter.CreateCounter("cache_hit_count"); + _missCounter = Meter.CreateCounter("cache_miss_count"); + _invalidationCounter = Meter.CreateCounter("cache_invalidation_count"); + _durationHistogram = Meter.CreateHistogram("cache_query_duration_ms"); + + Meter.CreateObservableGauge( + "cache_hit_ratio", + () => new Measurement(CalculateHitRatio())); + } + + /// + /// 记录缓存命中。 + /// + public void RecordHit(string cacheLevel, string dictionaryCode) + { + Interlocked.Increment(ref _hitTotal); + _hitCounter.Add(1, new TagList + { + { "cache_level", cacheLevel }, + { "dictionary_code", NormalizeCode(dictionaryCode) } + }); + } + + /// + /// 记录缓存未命中。 + /// + public void RecordMiss(string cacheLevel, string dictionaryCode) + { + Interlocked.Increment(ref _missTotal); + _missCounter.Add(1, new TagList + { + { "cache_level", cacheLevel }, + { "dictionary_code", NormalizeCode(dictionaryCode) } + }); + } + + /// + /// 记录缓存查询耗时。 + /// + public void RecordDuration(string dictionaryCode, double durationMs) + { + _durationHistogram.Record(durationMs, new TagList + { + { "dictionary_code", NormalizeCode(dictionaryCode) } + }); + } + + /// + /// 记录查询详情,用于统计窗口分析。 + /// + 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(); + } + + /// + /// 记录缓存失效事件。 + /// + public void RecordInvalidation(string dictionaryCode) + { + _invalidationCounter.Add(1, new TagList + { + { "dictionary_code", NormalizeCode(dictionaryCode) } + }); + } + + /// + /// 获取指定时间范围内的统计快照。 + /// + 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); + } + + /// + /// 从缓存键解析字典编码。 + /// + 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); +} + +/// +/// 缓存统计快照。 +/// +public sealed record CacheStatsSnapshot( + long TotalHits, + long TotalMisses, + double HitRatio, + CacheLevelStats HitsByLevel, + CacheLevelStats MissesByLevel, + double AverageQueryDurationMs, + IReadOnlyList TopQueriedDictionaries); + +/// +/// 命中统计。 +/// +public sealed record CacheLevelStats(long L1, long L2); + +/// +/// 字典查询次数统计。 +/// +public sealed record DictionaryQueryCount(string Code, int QueryCount); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheWarmupService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheWarmupService.cs new file mode 100644 index 0000000..71af78e --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheWarmupService.cs @@ -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; + +/// +/// 字典缓存预热服务。 +/// +public sealed class CacheWarmupService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) : IHostedService +{ + private const int MaxWarmupCount = 10; + + /// + 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(); + + 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); + } + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/HybridCacheService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/HybridCacheService.cs new file mode 100644 index 0000000..5bdb292 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/HybridCacheService.cs @@ -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; + +/// +/// 两级缓存封装:L1 内存 + L2 Redis。 +/// +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? _logger; + private readonly CacheMetricsCollector? _metrics; + private readonly IServiceScopeFactory? _scopeFactory; + + /// + /// 初始化两级缓存服务。 + /// + public HybridCacheService( + MemoryCacheService memoryCache, + RedisCacheService redisCache, + IConnectionMultiplexer? multiplexer = null, + ILogger? 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); + } + }); + } + } + + /// + /// 获取缓存,如果不存在则创建并回填。 + /// + public async Task GetOrCreateAsync( + string key, + TimeSpan ttl, + Func> factory, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(key); + var l1Hit = false; + var l2Hit = false; + + var cached = await _memoryCache.GetAsync(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(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; + } + + /// + /// 失效指定前缀的缓存键。 + /// + 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(); + if (repo == null) + { + return; + } + + var currentUser = scope.ServiceProvider.GetService(); + 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; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/MemoryCacheService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/MemoryCacheService.cs new file mode 100644 index 0000000..ca9c7e0 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/MemoryCacheService.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.Caching.Memory; +using System.Collections.Concurrent; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Caching; + +/// +/// 本地内存缓存封装。 +/// +public sealed class MemoryCacheService(IMemoryCache cache) +{ + private readonly ConcurrentDictionary _keys = new(StringComparer.Ordinal); + + /// + /// 读取缓存。 + /// + public Task GetAsync(string key, CancellationToken cancellationToken = default) + { + return Task.FromResult(cache.TryGetValue(key, out T? value) ? value : default); + } + + /// + /// 写入缓存。 + /// + public Task SetAsync(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default) + { + cache.Set(key, value, new MemoryCacheEntryOptions + { + SlidingExpiration = ttl + }); + _keys.TryAdd(key, 0); + return Task.CompletedTask; + } + + /// + /// 删除缓存键。 + /// + public void Remove(string key) + { + cache.Remove(key); + _keys.TryRemove(key, out _); + } + + /// + /// 按前缀删除缓存键。 + /// + public void RemoveByPrefix(string prefix) + => RemoveByPrefixWithCount(prefix); + + /// + /// 按前缀删除缓存键并返回数量。 + /// + 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; + } + + /// + /// 清理所有缓存。 + /// + public void Clear() + { + foreach (var key in _keys.Keys) + { + Remove(key); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/RedisCacheService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/RedisCacheService.cs new file mode 100644 index 0000000..f28ff57 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/RedisCacheService.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; +using StackExchange.Redis; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Caching; + +/// +/// Redis 缓存访问封装。 +/// +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; + + /// + /// 读取缓存。 + /// + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + var payload = await cache.GetAsync(key, cancellationToken); + if (payload == null || payload.Length == 0) + { + return default; + } + + return JsonSerializer.Deserialize(payload, _serializerOptions); + } + + /// + /// 写入缓存。 + /// + public Task SetAsync(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); + } + + /// + /// 删除缓存键。 + /// + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + => cache.RemoveAsync(key, cancellationToken); + + /// + /// 按前缀删除缓存键。 + /// + public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) + => await RemoveByPrefixWithCountAsync(prefix, cancellationToken).ConfigureAwait(false); + + /// + /// 按前缀删除缓存键并返回数量。 + /// + public async Task 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; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs index 0055d45..3c53f2f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -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(DatabaseConstants.DictionaryDataSource); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + 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(_ => ConnectionMultiplexer.Connect(redisConnection)); + } + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new RedisCacheService( + sp.GetRequiredService(), + sp.GetService())); + services.AddSingleton(sp => new HybridCacheService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetService(), + sp.GetService>(), + sp.GetService(), + sp.GetService())); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddOptions() .Bind(configuration.GetSection("Dictionary:Cache")) .ValidateDataAnnotations(); + services.AddOptions() + .Bind(configuration.GetSection("CacheWarmup")) + .ValidateDataAnnotations(); + + services.AddHostedService(); + return services; } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/ImportExport/CsvDictionaryParser.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/ImportExport/CsvDictionaryParser.cs new file mode 100644 index 0000000..ed92bc4 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/ImportExport/CsvDictionaryParser.cs @@ -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; + +/// +/// CSV 字典导入解析器。 +/// +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 + }; + + /// + public async Task> ParseAsync(Stream stream, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + + if (stream.CanSeek) + { + stream.Position = 0; + } + + var rows = new List(); + 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; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/ImportExport/JsonDictionaryParser.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/ImportExport/JsonDictionaryParser.cs new file mode 100644 index 0000000..855105b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/ImportExport/JsonDictionaryParser.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using TakeoutSaaS.Application.Dictionary.Abstractions; +using TakeoutSaaS.Application.Dictionary.Models; + +namespace TakeoutSaaS.Infrastructure.Dictionary.ImportExport; + +/// +/// JSON 字典导入解析器。 +/// +public sealed class JsonDictionaryParser : IJsonDictionaryParser +{ + private static readonly JsonDocumentOptions DocumentOptions = new() + { + AllowTrailingCommas = true + }; + + /// + public async Task> 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(); + } + + var rows = new List(); + 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; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheWarmupOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheWarmupOptions.cs new file mode 100644 index 0000000..7c5c2f4 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheWarmupOptions.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Infrastructure.Dictionary.Options; + +/// +/// 字典缓存预热配置。 +/// +public sealed class DictionaryCacheWarmupOptions +{ + /// + /// 预热字典编码列表(最多前 10 个)。 + /// + public string[] DictionaryCodes { get; set; } = Array.Empty(); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs index 53e7e24..c840feb 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs @@ -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( /// public DbSet DictionaryItems => Set(); + /// + /// 租户字典覆盖集合。 + /// + public DbSet TenantDictionaryOverrides => Set(); + + /// + /// 字典导入日志集合。 + /// + public DbSet DictionaryImportLogs => Set(); + + /// + /// 缓存失效日志集合。 + /// + public DbSet CacheInvalidationLogs => Set(); + /// /// 系统参数集合。 /// @@ -41,8 +57,13 @@ public sealed class DictionaryDbContext( protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - ConfigureGroup(modelBuilder.Entity()); - ConfigureItem(modelBuilder.Entity()); + var provider = Database.ProviderName; + var isSqlite = provider != null && provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase); + ConfigureGroup(modelBuilder.Entity(), isSqlite); + ConfigureItem(modelBuilder.Entity(), isSqlite); + ConfigureOverride(modelBuilder.Entity()); + ConfigureImportLog(modelBuilder.Entity()); + ConfigureCacheInvalidationLog(modelBuilder.Entity()); ConfigureSystemParameter(modelBuilder.Entity()); ApplyTenantQueryFilters(modelBuilder); } @@ -51,48 +72,140 @@ public sealed class DictionaryDbContext( /// 配置字典分组。 /// /// 实体构建器。 - private static void ConfigureGroup(EntityTypeBuilder builder) + private static void ConfigureGroup(EntityTypeBuilder 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().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 }); } /// /// 配置字典项。 /// /// 实体构建器。 - private static void ConfigureItem(EntityTypeBuilder builder) + private static void ConfigureItem(EntityTypeBuilder 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 }); + } + + /// + /// 配置租户字典覆盖。 + /// + /// 实体构建器。 + private static void ConfigureOverride(EntityTypeBuilder 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"); + } + + /// + /// 配置字典导入日志。 + /// + /// 实体构建器。 + private static void ConfigureImportLog(EntityTypeBuilder 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 }); + } + + /// + /// 配置缓存失效日志。 + /// + /// 实体构建器。 + private static void ConfigureCacheInvalidationLog(EntityTypeBuilder 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().IsRequired(); + builder.Property(x => x.Operation).HasConversion().IsRequired(); + builder.Property(x => x.Timestamp).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + + builder.HasIndex(x => new { x.TenantId, x.Timestamp }); } /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/Seeds/InitialDictionaries.sql b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/Seeds/InitialDictionaries.sql new file mode 100644 index 0000000..10dd21d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/Seeds/InitialDictionaries.sql @@ -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; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/CacheInvalidationLogRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/CacheInvalidationLogRepository.cs new file mode 100644 index 0000000..f0dcbad --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/CacheInvalidationLogRepository.cs @@ -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; + +/// +/// 缓存失效日志仓储实现。 +/// +public sealed class CacheInvalidationLogRepository(DictionaryDbContext context) : ICacheInvalidationLogRepository +{ + /// + /// 新增失效日志。 + /// + public Task AddAsync(CacheInvalidationLog log, CancellationToken cancellationToken = default) + { + context.CacheInvalidationLogs.Add(log); + return Task.CompletedTask; + } + + /// + /// 分页查询失效日志。 + /// + public async Task<(IReadOnlyList 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); + } + + /// + /// 保存变更。 + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => context.SaveChangesAsync(cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs new file mode 100644 index 0000000..df38382 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs @@ -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; + +/// +/// 字典分组仓储实现。 +/// +public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDictionaryGroupRepository +{ + private static readonly Func> GetByCodeQuery = + EF.CompileAsyncQuery((DictionaryDbContext db, long tenantId, DictionaryCode code) => + db.DictionaryGroups + .AsNoTracking() + .IgnoreQueryFilters() + .FirstOrDefault(group => group.TenantId == tenantId && group.DeletedAt == null && group.Code == code)); + + /// + /// 按 ID 获取字典分组。 + /// + public Task GetByIdAsync(long groupId, CancellationToken cancellationToken = default) + { + return context.DictionaryGroups + .IgnoreQueryFilters() + .FirstOrDefaultAsync(group => group.Id == groupId && group.DeletedAt == null, cancellationToken); + } + + /// + /// 按编码获取字典分组。 + /// + public Task GetByCodeAsync(long tenantId, DictionaryCode code, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + return GetByCodeQuery(context, tenantId, code); + } + + /// + /// 分页获取字典分组。 + /// + public async Task> 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); + } + + /// + /// 获取满足条件的分组数量。 + /// + public Task CountAsync( + long tenantId, + DictionaryScope? scope, + string? keyword, + bool? isEnabled, + CancellationToken cancellationToken = default) + { + return BuildQuery(tenantId, scope, keyword, isEnabled) + .CountAsync(cancellationToken); + } + + /// + /// 批量获取字典分组。 + /// + public async Task> GetByIdsAsync(IEnumerable groupIds, CancellationToken cancellationToken = default) + { + var ids = groupIds?.Distinct().ToArray() ?? Array.Empty(); + if (ids.Length == 0) + { + return Array.Empty(); + } + + return await context.DictionaryGroups + .AsNoTracking() + .IgnoreQueryFilters() + .Where(group => ids.Contains(group.Id) && group.DeletedAt == null) + .ToListAsync(cancellationToken); + } + + private static IQueryable ApplyOrdering(IQueryable 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) + }; + } + + /// + /// 新增分组。 + /// + public Task AddAsync(DictionaryGroup group, CancellationToken cancellationToken = default) + { + context.DictionaryGroups.Add(group); + return Task.CompletedTask; + } + + /// + /// 更新分组。 + /// + public Task UpdateAsync(DictionaryGroup group, CancellationToken cancellationToken = default) + { + context.DictionaryGroups.Update(group); + return Task.CompletedTask; + } + + /// + /// 删除分组。 + /// + public Task RemoveAsync(DictionaryGroup group, CancellationToken cancellationToken = default) + { + context.DictionaryGroups.Remove(group); + return Task.CompletedTask; + } + + /// + /// 持久化更改。 + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => context.SaveChangesAsync(cancellationToken); + + private IQueryable 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(group, "Code").Contains(trimmed) || + group.Name.Contains(trimmed)); + } + + if (isEnabled.HasValue) + { + query = query.Where(group => group.IsEnabled == isEnabled.Value); + } + + return query; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryImportLogRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryImportLogRepository.cs new file mode 100644 index 0000000..39cc005 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryImportLogRepository.cs @@ -0,0 +1,26 @@ +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories; + +/// +/// 字典导入日志仓储实现。 +/// +public sealed class DictionaryImportLogRepository(DictionaryDbContext context) : IDictionaryImportLogRepository +{ + /// + /// 新增导入日志。 + /// + public Task AddAsync(DictionaryImportLog log, CancellationToken cancellationToken = default) + { + context.DictionaryImportLogs.Add(log); + return Task.CompletedTask; + } + + /// + /// 持久化更改。 + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => context.SaveChangesAsync(cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs new file mode 100644 index 0000000..7c543ee --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs @@ -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; + +/// +/// 字典项仓储实现。 +/// +public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDictionaryItemRepository +{ + private static readonly Func> GetByGroupQuery = + EF.CompileQuery((DictionaryDbContext db, long tenantId, long groupId) => + (IEnumerable)db.DictionaryItems + .AsNoTracking() + .IgnoreQueryFilters() + .Where(item => item.GroupId == groupId && item.TenantId == tenantId && item.DeletedAt == null) + .OrderBy(item => item.SortOrder)); + + /// + /// 根据 ID 获取字典项。 + /// + public Task GetByIdAsync(long itemId, CancellationToken cancellationToken = default) + { + return context.DictionaryItems + .IgnoreQueryFilters() + .FirstOrDefaultAsync(item => item.Id == itemId && item.DeletedAt == null, cancellationToken); + } + + /// + /// 获取分组下字典项列表。 + /// + public Task> GetByGroupIdAsync( + long tenantId, + long groupId, + CancellationToken cancellationToken = default) + { + _ = cancellationToken; + return Task.FromResult>( + GetByGroupQuery(context, tenantId, groupId).ToList()); + } + + /// + /// 获取系统与租户合并的字典项列表。 + /// + public async Task> 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(); + } + + var result = new List(); + 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; + } + + /// + /// 新增字典项。 + /// + public Task AddAsync(DictionaryItem item, CancellationToken cancellationToken = default) + { + context.DictionaryItems.Add(item); + return Task.CompletedTask; + } + + /// + /// 更新字典项。 + /// + 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; + } + + /// + /// 删除字典项。 + /// + public Task RemoveAsync(DictionaryItem item, CancellationToken cancellationToken = default) + { + context.DictionaryItems.Remove(item); + return Task.CompletedTask; + } + + /// + /// 持久化更改。 + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => context.SaveChangesAsync(cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs index 18e7bbe..50aec15 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs @@ -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 /// 取消标记。 /// 匹配分组或 null。 public Task FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default) - => context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == code, cancellationToken); + => context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == new DictionaryCode(code), cancellationToken); /// /// 搜索分组列表。 @@ -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)); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/TenantDictionaryOverrideRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/TenantDictionaryOverrideRepository.cs new file mode 100644 index 0000000..84b7ebe --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/TenantDictionaryOverrideRepository.cs @@ -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; + +/// +/// 租户字典覆盖仓储实现。 +/// +public sealed class TenantDictionaryOverrideRepository(DictionaryDbContext context) : ITenantDictionaryOverrideRepository +{ + /// + /// 获取租户覆盖配置。 + /// + public Task GetAsync(long tenantId, long systemGroupId, CancellationToken cancellationToken = default) + { + return context.TenantDictionaryOverrides + .IgnoreQueryFilters() + .FirstOrDefaultAsync(config => + config.TenantId == tenantId && + config.SystemDictionaryGroupId == systemGroupId && + config.DeletedAt == null, + cancellationToken); + } + + /// + /// 获取租户全部覆盖配置。 + /// + public async Task> ListAsync(long tenantId, CancellationToken cancellationToken = default) + { + return await context.TenantDictionaryOverrides + .AsNoTracking() + .IgnoreQueryFilters() + .Where(config => config.TenantId == tenantId && config.DeletedAt == null) + .ToListAsync(cancellationToken); + } + + /// + /// 新增覆盖配置。 + /// + public Task AddAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default) + { + context.TenantDictionaryOverrides.Add(overrideConfig); + return Task.CompletedTask; + } + + /// + /// 更新覆盖配置。 + /// + public Task UpdateAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default) + { + context.TenantDictionaryOverrides.Update(overrideConfig); + return Task.CompletedTask; + } + + /// + /// 持久化更改。 + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => context.SaveChangesAsync(cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.Designer.cs new file mode 100644 index 0000000..7a43210 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.Designer.cs @@ -0,0 +1,471 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowOverride") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否允许租户覆盖。"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("分组名称。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("Scope") + .HasColumnType("integer") + .HasComment("分组作用域:系统/业务。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictMode") + .HasColumnType("integer") + .HasComment("冲突处理模式。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DictionaryGroupCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典分组编码。"); + + b.Property("Duration") + .HasColumnType("interval") + .HasComment("处理耗时。"); + + b.Property("ErrorDetails") + .HasColumnType("jsonb") + .HasComment("错误明细(JSON)。"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("导入文件名。"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasComment("文件大小(字节)。"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("文件格式(CSV/JSON)。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人用户标识。"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasComment("处理时间(UTC)。"); + + b.Property("SkipCount") + .HasColumnType("integer") + .HasComment("跳过数量。"); + + b.Property("SuccessCount") + .HasColumnType("integer") + .HasComment("成功导入数量。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("GroupId") + .HasColumnType("bigint") + .HasComment("关联分组 ID。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认项。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("字典项键。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("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("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("SystemDictionaryGroupId") + .HasColumnType("bigint") + .HasComment("系统字典分组 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识。"); + + b.Property("CustomSortOrder") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("自定义排序映射(JSON)。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("删除时间(UTC)。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识。"); + + b.PrimitiveCollection("HiddenSystemItemIds") + .IsRequired() + .HasColumnType("bigint[]") + .HasComment("隐藏的系统字典项 ID 列表。"); + + b.Property("OverrideEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否启用覆盖。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近更新时间(UTC)。"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("参数键,租户内唯一。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("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 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.cs new file mode 100644 index 0000000..1c15b5b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.cs @@ -0,0 +1,256 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + /// + public partial class UpdateDictionarySchema : Migration + { + /// + 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( + name: "Value", + table: "dictionary_items", + type: "jsonb", + nullable: false, + comment: "字典项值。", + oldClrType: typeof(string), + oldType: "character varying(256)", + oldMaxLength: 256, + oldComment: "字典项值。"); + + migrationBuilder.AlterColumn( + 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( + name: "RowVersion", + table: "dictionary_items", + type: "bytea", + rowVersion: true, + nullable: false, + defaultValue: new byte[0], + comment: "并发控制字段。"); + + migrationBuilder.AlterColumn( + 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( + name: "AllowOverride", + table: "dictionary_groups", + type: "boolean", + nullable: false, + defaultValue: false, + comment: "是否允许租户覆盖。"); + + migrationBuilder.AddColumn( + 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(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OperatorId = table.Column(type: "bigint", nullable: false, comment: "操作人用户标识。"), + DictionaryGroupCode = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "字典分组编码。"), + FileName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, comment: "导入文件名。"), + FileSize = table.Column(type: "bigint", nullable: false, comment: "文件大小(字节)。"), + Format = table.Column(type: "character varying(16)", maxLength: 16, nullable: false, comment: "文件格式(CSV/JSON)。"), + ConflictMode = table.Column(type: "integer", nullable: false, comment: "冲突处理模式。"), + SuccessCount = table.Column(type: "integer", nullable: false, comment: "成功导入数量。"), + SkipCount = table.Column(type: "integer", nullable: false, comment: "跳过数量。"), + ErrorDetails = table.Column(type: "jsonb", nullable: true, comment: "错误明细(JSON)。"), + ProcessedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "处理时间(UTC)。"), + Duration = table.Column(type: "interval", nullable: false, comment: "处理耗时。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(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(type: "bigint", nullable: false, comment: "所属租户 ID。"), + SystemDictionaryGroupId = table.Column(type: "bigint", nullable: false, comment: "系统字典分组 ID。"), + OverrideEnabled = table.Column(type: "boolean", nullable: false, defaultValue: false, comment: "是否启用覆盖。"), + HiddenSystemItemIds = table.Column(type: "bigint[]", nullable: false, comment: "隐藏的系统字典项 ID 列表。"), + CustomSortOrder = table.Column(type: "jsonb", nullable: false, comment: "自定义排序映射(JSON)。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近更新时间(UTC)。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "删除时间(UTC)。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识。"), + DeletedBy = table.Column(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"); + } + + /// + 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( + name: "Value", + table: "dictionary_items", + type: "character varying(256)", + maxLength: 256, + nullable: false, + comment: "字典项值。", + oldClrType: typeof(string), + oldType: "jsonb", + oldComment: "字典项值。"); + + migrationBuilder.AlterColumn( + 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( + 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); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230162000_AddCacheInvalidationLogs.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230162000_AddCacheInvalidationLogs.cs new file mode 100644 index 0000000..c0d3556 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230162000_AddCacheInvalidationLogs.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + /// + public partial class AddCacheInvalidationLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "dictionary_cache_invalidation_logs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false, comment: "发生时间(UTC)。"), + DictionaryCode = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "字典编码。"), + Scope = table.Column(type: "integer", nullable: false, comment: "字典作用域。"), + AffectedCacheKeyCount = table.Column(type: "integer", nullable: false, comment: "影响的缓存键数量。"), + OperatorId = table.Column(type: "bigint", nullable: false, comment: "操作人用户标识。"), + Operation = table.Column(type: "integer", nullable: false, comment: "操作类型。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(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" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "dictionary_cache_invalidation_logs"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs index 35585f3..59c7f42 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs @@ -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("Id")); + b.Property("AllowOverride") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否允许租户覆盖。"); + b.Property("Code") .IsRequired() .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasComment("分组编码(唯一)。"); + .HasColumnType("character varying(64)"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone") @@ -70,6 +75,13 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb .HasColumnType("character varying(128)") .HasComment("分组名称。"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictMode") + .HasColumnType("integer") + .HasComment("冲突处理模式。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DictionaryGroupCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典分组编码。"); + + b.Property("Duration") + .HasColumnType("interval") + .HasComment("处理耗时。"); + + b.Property("ErrorDetails") + .HasColumnType("jsonb") + .HasComment("错误明细(JSON)。"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("导入文件名。"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasComment("文件大小(字节)。"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("文件格式(CSV/JSON)。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人用户标识。"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasComment("处理时间(UTC)。"); + + b.Property("SkipCount") + .HasColumnType("integer") + .HasComment("跳过数量。"); + + b.Property("SuccessCount") + .HasColumnType("integer") + .HasComment("成功导入数量。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffectedCacheKeyCount") + .HasColumnType("integer") + .HasComment("影响的缓存键数量。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DictionaryCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典编码。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Operation") + .HasColumnType("integer") + .HasComment("操作类型。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人用户标识。"); + + b.Property("Scope") + .HasColumnType("integer") + .HasComment("字典作用域。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间(UTC)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("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("Id") @@ -145,10 +330,17 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb b.Property("Key") .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") + .HasMaxLength(128) + .HasColumnType("character varying(128)") .HasComment("字典项键。"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + b.Property("SortOrder") .ValueGeneratedOnAdd() .HasColumnType("integer") @@ -169,16 +361,18 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb b.Property("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("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("SystemDictionaryGroupId") + .HasColumnType("bigint") + .HasComment("系统字典分组 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识。"); + + b.Property("CustomSortOrder") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("自定义排序映射(JSON)。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("删除时间(UTC)。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识。"); + + b.PrimitiveCollection("HiddenSystemItemIds") + .IsRequired() + .HasColumnType("bigint[]") + .HasComment("隐藏的系统字典项 ID 列表。"); + + b.Property("OverrideEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否启用覆盖。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近更新时间(UTC)。"); + + b.Property("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("Id") diff --git a/tests/TakeoutSaaS.Integration.Tests/App/Dictionary/DictionaryApiTests.cs b/tests/TakeoutSaaS.Integration.Tests/App/Dictionary/DictionaryApiTests.cs new file mode 100644 index 0000000..176317a --- /dev/null +++ b/tests/TakeoutSaaS.Integration.Tests/App/Dictionary/DictionaryApiTests.cs @@ -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 act = async () => await service.UpdateGroupAsync(created.Id, request); + + var exception = await act.Should().ThrowAsync(); + 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 + { + [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.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.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 + { + ["zh-CN"] = zh, + ["en"] = en + }); + return value.ToJson(); + } + + private static string BuildCsv(string[] headers, IEnumerable 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; + } +} diff --git a/tests/TakeoutSaaS.Integration.Tests/Fixtures/DictionarySqliteTestDatabase.cs b/tests/TakeoutSaaS.Integration.Tests/Fixtures/DictionarySqliteTestDatabase.cs new file mode 100644 index 0000000..ccc9201 --- /dev/null +++ b/tests/TakeoutSaaS.Integration.Tests/Fixtures/DictionarySqliteTestDatabase.cs @@ -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() + .UseSqlite(_connection) + .EnableSensitiveDataLogging() + .Options; + } + + public DbContextOptions 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(); + } +} diff --git a/tests/TakeoutSaaS.Integration.Tests/Fixtures/TestDictionaryHybridCache.cs b/tests/TakeoutSaaS.Integration.Tests/Fixtures/TestDictionaryHybridCache.cs new file mode 100644 index 0000000..9bd13c4 --- /dev/null +++ b/tests/TakeoutSaaS.Integration.Tests/Fixtures/TestDictionaryHybridCache.cs @@ -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 _cache = new(StringComparer.OrdinalIgnoreCase); + + public async Task GetOrCreateAsync( + string key, + TimeSpan ttl, + Func> 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; + } +}