diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs index 657849a..de7aefd 160000 --- a/TakeoutSaaS.Docs +++ b/TakeoutSaaS.Docs @@ -1 +1 @@ -Subproject commit 657849a5f7efa354d1a3e750f4707245dc8a0aaf +Subproject commit de7aefd0ffe5c3ab842207c62aaa736edf7b8dfb diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/CacheMetricsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/CacheMetricsController.cs deleted file mode 100644 index 31e66d1..0000000 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/CacheMetricsController.cs +++ /dev/null @@ -1,65 +0,0 @@ -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/MerchantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs index 8f756d2..5cec074 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs @@ -90,7 +90,8 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController return ApiResponse.Error(StatusCodes.Status400BadRequest, "路由 merchantId 与请求体 merchantId 不一致"); } - command = command with { MerchantId = merchantId }; + // 1. Admin 端调用,跳过敏感字段审核 + command = command with { MerchantId = merchantId, SkipReview = true }; // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); @@ -447,4 +448,88 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController // 2. 返回类目列表 return ApiResponse>.Ok(result); } + + /// + /// 更新商户经营模式。 + /// + /// 商户 ID。 + /// 请求体。 + /// 取消标记。 + /// 更新结果。 + [HttpPut("{merchantId:long}/operating-mode")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateOperatingMode( + long merchantId, + [FromBody] UpdateMerchantOperatingModeCommand request, + CancellationToken cancellationToken) + { + // 1. 绑定商户标识 + var command = request with { MerchantId = merchantId }; + + // 2. 执行更新 + var success = await mediator.Send(command, cancellationToken); + + // 3. 返回结果 + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "商户不存在"); + } + + /// + /// 冻结商户。 + /// + /// 商户 ID。 + /// 请求体。 + /// 取消标记。 + /// 冻结结果。 + [HttpPut("{merchantId:long}/freeze")] + [PermissionAuthorize("merchant:freeze")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Freeze( + long merchantId, + [FromBody] FreezeMerchantCommand request, + CancellationToken cancellationToken) + { + // 1. 绑定商户标识 + var command = request with { MerchantId = merchantId }; + + // 2. 执行冻结 + var success = await mediator.Send(command, cancellationToken); + + // 3. 返回结果 + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "商户不存在"); + } + + /// + /// 解冻商户。 + /// + /// 商户 ID。 + /// 请求体。 + /// 取消标记。 + /// 解冻结果。 + [HttpPut("{merchantId:long}/unfreeze")] + [PermissionAuthorize("merchant:unfreeze")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Unfreeze( + long merchantId, + [FromBody] UnfreezeMerchantCommand request, + CancellationToken cancellationToken) + { + // 1. 绑定商户标识 + var command = request with { MerchantId = merchantId }; + + // 2. 执行解冻 + var success = await mediator.Send(command, cancellationToken); + + // 3. 返回结果 + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "商户不存在"); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json index d01d88e..747a4f5 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json @@ -61,20 +61,6 @@ "Users": [] } }, - "Dictionary": { - "Cache": { - "SlidingExpiration": "00:30:00" - } - }, - "CacheWarmup": { - "DictionaryCodes": [ - "order_status", - "payment_method", - "shipping_method", - "product_category", - "user_role" - ] - }, "Storage": { "Provider": "TencentCos", "CdnBaseUrl": "https://image-admin.laosankeji.com", diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json index 3a6f830..33f53ea 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json @@ -61,20 +61,6 @@ "Users": [] } }, - "Dictionary": { - "Cache": { - "SlidingExpiration": "00:30:00" - } - }, - "CacheWarmup": { - "DictionaryCodes": [ - "order_status", - "payment_method", - "shipping_method", - "product_category", - "user_role" - ] - }, "Storage": { "Provider": "TencentCos", "CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com", diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/FreezeMerchantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/FreezeMerchantCommand.cs new file mode 100644 index 0000000..c271bc6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/FreezeMerchantCommand.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 冻结商户命令。 +/// +public sealed record FreezeMerchantCommand : IRequest +{ + /// + /// 商户 ID。 + /// + public long MerchantId { get; init; } + + /// + /// 冻结原因。 + /// + public string? Reason { get; init; } + + /// + /// 并发控制版本(PostgreSQL xmin)。 + /// + public uint RowVersion { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UnfreezeMerchantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UnfreezeMerchantCommand.cs new file mode 100644 index 0000000..39d75bc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UnfreezeMerchantCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 解冻商户命令。 +/// +public sealed record UnfreezeMerchantCommand : IRequest +{ + /// + /// 商户 ID。 + /// + public long MerchantId { get; init; } + + /// + /// 并发控制版本(PostgreSQL xmin)。 + /// + public uint RowVersion { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantCommand.cs index 034a6f8..177be6d 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantCommand.cs @@ -18,21 +18,36 @@ public sealed record UpdateMerchantCommand : IRequest /// public string? Name { get; init; } + /// + /// 品牌简称。 + /// + public string? BrandAlias { get; init; } + + /// + /// Logo URL。 + /// + public string? LogoUrl { get; init; } + + /// + /// 品类。 + /// + public string? Category { get; init; } + /// /// 营业执照号。 /// public string? LicenseNumber { get; init; } + /// + /// 税号。 + /// + public string? TaxNumber { get; init; } + /// /// 法人或负责人。 /// public string? LegalRepresentative { get; init; } - /// - /// 注册地址。 - /// - public string? RegisteredAddress { get; init; } - /// /// 联系电话。 /// @@ -44,7 +59,42 @@ public sealed record UpdateMerchantCommand : IRequest public string? ContactEmail { get; init; } /// - /// 并发控制版本。 + /// 客服电话。 /// - public byte[] RowVersion { get; init; } = Array.Empty(); + public string? ServicePhone { get; init; } + + /// + /// 客服邮箱。 + /// + public string? SupportEmail { get; init; } + + /// + /// 省份。 + /// + public string? Province { get; init; } + + /// + /// 城市。 + /// + public string? City { get; init; } + + /// + /// 区县。 + /// + public string? District { get; init; } + + /// + /// 详细地址。 + /// + public string? Address { get; init; } + + /// + /// 并发控制版本(PostgreSQL xmin)。 + /// + public uint RowVersion { get; init; } + + /// + /// 是否跳过敏感字段审核(Admin 端设置为 true)。 + /// + public bool SkipReview { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantOperatingModeCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantOperatingModeCommand.cs new file mode 100644 index 0000000..ffc5229 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantOperatingModeCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Domain.Common.Enums; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 更新商户经营模式命令。 +/// +public sealed record UpdateMerchantOperatingModeCommand : IRequest +{ + /// + /// 商户 ID。 + /// + public long MerchantId { get; init; } + + /// + /// 经营模式。 + /// + public OperatingMode OperatingMode { get; init; } + + /// + /// 并发控制版本(PostgreSQL xmin)。 + /// + public uint RowVersion { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs index 1a10909..647e911 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs @@ -28,10 +28,25 @@ public sealed class MerchantDetailDto public string? TenantName { get; init; } /// - /// 商户名称。 + /// 商户名称(品牌名)。 /// public string Name { get; init; } = string.Empty; + /// + /// 品牌简称。 + /// + public string? BrandAlias { get; init; } + + /// + /// Logo 地址。 + /// + public string? LogoUrl { get; init; } + + /// + /// 品类。 + /// + public string? Category { get; init; } + /// /// 经营模式。 /// @@ -48,9 +63,29 @@ public sealed class MerchantDetailDto public string? LegalRepresentative { get; init; } /// - /// 注册地址。 + /// 税号。 /// - public string? RegisteredAddress { get; init; } + public string? TaxNumber { get; init; } + + /// + /// 省份。 + /// + public string? Province { get; init; } + + /// + /// 城市。 + /// + public string? City { get; init; } + + /// + /// 区县。 + /// + public string? District { get; init; } + + /// + /// 详细地址。 + /// + public string? Address { get; init; } /// /// 联系电话。 @@ -62,6 +97,16 @@ public sealed class MerchantDetailDto /// public string? ContactEmail { get; init; } + /// + /// 客服电话。 + /// + public string? ServicePhone { get; init; } + + /// + /// 客服邮箱。 + /// + public string? SupportEmail { get; init; } + /// /// 审核状态。 /// @@ -99,9 +144,9 @@ public sealed class MerchantDetailDto public IReadOnlyList Stores { get; init; } = []; /// - /// 并发控制版本。 + /// 并发控制版本(PostgreSQL xmin)。 /// - public byte[] RowVersion { get; init; } = Array.Empty(); + public uint RowVersion { get; init; } /// /// 创建时间。 diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs index fe5eeb4..a86c9fe 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs @@ -1,23 +1,27 @@ using MediatR; using Microsoft.Extensions.Logging; -using System.Security.Cryptography; using TakeoutSaaS.Application.App.Merchants.Commands; using TakeoutSaaS.Application.App.Merchants.Dto; using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Security; namespace TakeoutSaaS.Application.App.Merchants.Handlers; /// /// 创建商户命令处理器。 /// -public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger logger) +public sealed class CreateMerchantCommandHandler( + IMerchantRepository merchantRepository, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) : IRequestHandler { /// public async Task Handle(CreateMerchantCommand request, CancellationToken cancellationToken) { - // 1. 构建商户实体 + // 1. 构建商户实体(RowVersion 由 PostgreSQL xmin 自动管理) var merchant = new Merchant { TenantId = request.TenantId, @@ -28,16 +32,39 @@ public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRep ContactPhone = request.ContactPhone.Trim(), ContactEmail = request.ContactEmail?.Trim(), Status = request.Status, - RowVersion = RandomNumberGenerator.GetBytes(16), JoinedAt = DateTime.UtcNow }; - // 2. 持久化 + // 2. 如果状态为已通过,设置审核通过时间和审核人 + if (request.Status == MerchantStatus.Approved) + { + merchant.ApprovedAt = DateTime.UtcNow; + merchant.ApprovedBy = currentUserAccessor.UserId; + } + + // 3. 持久化商户 await merchantRepository.AddMerchantAsync(merchant, cancellationToken); await merchantRepository.SaveChangesAsync(cancellationToken); - // 3. 记录日志 - logger.LogInformation("创建商户 {MerchantId} - {BrandName}", merchant.Id, merchant.BrandName); + // 4. 如果状态为已通过,添加默认审核通过记录 + if (request.Status == MerchantStatus.Approved) + { + var auditLog = new MerchantAuditLog + { + TenantId = merchant.TenantId, + MerchantId = merchant.Id, + Action = MerchantAuditAction.ReviewApproved, + Title = "商户创建时直接通过审核", + Description = "平台管理员创建商户时选择无需审核,系统自动通过", + OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId, + OperatorName = currentUserAccessor.UserId == 0 ? "system" : $"user:{currentUserAccessor.UserId}" + }; + await merchantRepository.AddAuditLogAsync(auditLog, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + } + + // 5. 记录日志 + logger.LogInformation("创建商户 {MerchantId} - {BrandName},状态:{Status}", merchant.Id, merchant.BrandName, merchant.Status); return MapToDto(merchant); } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/FreezeMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/FreezeMerchantCommandHandler.cs new file mode 100644 index 0000000..6aab28d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/FreezeMerchantCommandHandler.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 冻结商户命令处理器。 +/// +public sealed class FreezeMerchantCommandHandler( + IMerchantRepository merchantRepository, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(FreezeMerchantCommand request, CancellationToken cancellationToken) + { + // 1. 验证 RowVersion + if (request.RowVersion == 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空"); + } + + // 2. 读取商户信息 + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken); + if (merchant == null) + { + return false; + } + + // 3. 检查是否已冻结 + if (merchant.IsFrozen) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "商户已处于冻结状态"); + } + + // 4. 执行冻结 + var now = DateTime.UtcNow; + var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId; + var actorName = currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system"; + + merchant.IsFrozen = true; + merchant.FrozenReason = request.Reason?.Trim(); + merchant.FrozenAt = now; + merchant.RowVersion = request.RowVersion; + + // 5. 记录审核日志 + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = merchant.TenantId, + MerchantId = merchant.Id, + Action = MerchantAuditAction.Frozen, + Title = "商户冻结", + Description = string.IsNullOrWhiteSpace(request.Reason) ? "管理员冻结商户" : $"冻结原因:{request.Reason.Trim()}", + OperatorId = actorId, + OperatorName = actorName + }, cancellationToken); + + // 6. 持久化 + await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken); + + try + { + await merchantRepository.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) when (IsConcurrencyException(exception)) + { + throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试"); + } + + logger.LogInformation("冻结商户 {MerchantId},原因:{Reason}", merchant.Id, request.Reason); + return true; + } + + private static bool IsConcurrencyException(Exception exception) + => string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UnfreezeMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UnfreezeMerchantCommandHandler.cs new file mode 100644 index 0000000..64935db --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UnfreezeMerchantCommandHandler.cs @@ -0,0 +1,83 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 解冻商户命令处理器。 +/// +public sealed class UnfreezeMerchantCommandHandler( + IMerchantRepository merchantRepository, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UnfreezeMerchantCommand request, CancellationToken cancellationToken) + { + // 1. 验证 RowVersion + if (request.RowVersion == 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空"); + } + + // 2. 读取商户信息 + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken); + if (merchant == null) + { + return false; + } + + // 3. 检查是否已解冻 + if (!merchant.IsFrozen) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "商户未处于冻结状态"); + } + + // 4. 执行解冻 + var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId; + var actorName = currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system"; + + merchant.IsFrozen = false; + merchant.FrozenReason = null; + merchant.FrozenAt = null; + merchant.RowVersion = request.RowVersion; + + // 5. 记录审核日志 + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = merchant.TenantId, + MerchantId = merchant.Id, + Action = MerchantAuditAction.Unfrozen, + Title = "商户解冻", + Description = "管理员解冻商户", + OperatorId = actorId, + OperatorName = actorName + }, cancellationToken); + + // 6. 持久化 + await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken); + + try + { + await merchantRepository.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) when (IsConcurrencyException(exception)) + { + throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试"); + } + + logger.LogInformation("解冻商户 {MerchantId}", merchant.Id); + return true; + } + + private static bool IsConcurrencyException(Exception exception) + => string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs index 3657c69..06172cd 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs @@ -27,7 +27,7 @@ public sealed class UpdateMerchantCommandHandler( /// public async Task Handle(UpdateMerchantCommand request, CancellationToken cancellationToken) { - if (request.RowVersion == null || request.RowVersion.Length == 0) + if (request.RowVersion == 0) { throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空"); } @@ -43,10 +43,19 @@ public sealed class UpdateMerchantCommandHandler( // 2. (空行后) 规范化输入 var name = NormalizeRequired(request.Name, "商户名称"); var contactPhone = NormalizeRequired(request.ContactPhone, "联系电话"); + var brandAlias = NormalizeOptional(request.BrandAlias); + var logoUrl = NormalizeOptional(request.LogoUrl); + var category = NormalizeOptional(request.Category); var licenseNumber = NormalizeOptional(request.LicenseNumber); + var taxNumber = NormalizeOptional(request.TaxNumber); var legalRepresentative = NormalizeOptional(request.LegalRepresentative); - var registeredAddress = NormalizeOptional(request.RegisteredAddress); var contactEmail = NormalizeOptional(request.ContactEmail); + var servicePhone = NormalizeOptional(request.ServicePhone); + var supportEmail = NormalizeOptional(request.SupportEmail); + var province = NormalizeOptional(request.Province); + var city = NormalizeOptional(request.City); + var district = NormalizeOptional(request.District); + var address = NormalizeOptional(request.Address); var now = DateTime.UtcNow; var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId; @@ -54,23 +63,44 @@ public sealed class UpdateMerchantCommandHandler( var changes = new List(); var criticalChanged = false; + // 判断是否为管理员操作(Admin 端修改不触发审核) + var isAdminOperation = request.SkipReview; + TrackChange("name", merchant.BrandName, name, isCritical: true); + TrackChange("brandAlias", merchant.BrandAlias, brandAlias, isCritical: false); + TrackChange("logoUrl", merchant.LogoUrl, logoUrl, isCritical: false); + TrackChange("category", merchant.Category, category, isCritical: false); TrackChange("licenseNumber", merchant.BusinessLicenseNumber, licenseNumber, isCritical: true); + TrackChange("taxNumber", merchant.TaxNumber, taxNumber, isCritical: false); TrackChange("legalRepresentative", merchant.LegalPerson, legalRepresentative, isCritical: true); - TrackChange("registeredAddress", merchant.Address, registeredAddress, isCritical: true); TrackChange("contactPhone", merchant.ContactPhone, contactPhone, isCritical: false); TrackChange("contactEmail", merchant.ContactEmail, contactEmail, isCritical: false); + TrackChange("servicePhone", merchant.ServicePhone, servicePhone, isCritical: false); + TrackChange("supportEmail", merchant.SupportEmail, supportEmail, isCritical: false); + TrackChange("province", merchant.Province, province, isCritical: false); + TrackChange("city", merchant.City, city, isCritical: false); + TrackChange("district", merchant.District, district, isCritical: false); + TrackChange("address", merchant.Address, address, isCritical: true); // 3. (空行后) 写入字段 merchant.BrandName = name; + merchant.BrandAlias = brandAlias; + merchant.LogoUrl = logoUrl; + merchant.Category = category; merchant.BusinessLicenseNumber = licenseNumber; + merchant.TaxNumber = taxNumber; merchant.LegalPerson = legalRepresentative; - merchant.Address = registeredAddress; merchant.ContactPhone = contactPhone; merchant.ContactEmail = contactEmail; + merchant.ServicePhone = servicePhone; + merchant.SupportEmail = supportEmail; + merchant.Province = province; + merchant.City = city; + merchant.District = district; + merchant.Address = address; merchant.RowVersion = request.RowVersion; - var requiresReview = merchant.Status == MerchantStatus.Approved && criticalChanged; + var requiresReview = !isAdminOperation && merchant.Status == MerchantStatus.Approved && criticalChanged; if (requiresReview) { merchant.Status = MerchantStatus.Pending; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantOperatingModeCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantOperatingModeCommandHandler.cs new file mode 100644 index 0000000..233d12a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantOperatingModeCommandHandler.cs @@ -0,0 +1,80 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 更新商户经营模式命令处理器。 +/// +public sealed class UpdateMerchantOperatingModeCommandHandler( + IMerchantRepository merchantRepository, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateMerchantOperatingModeCommand request, CancellationToken cancellationToken) + { + // 1. 验证 RowVersion + if (request.RowVersion == 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空"); + } + + // 2. 读取商户信息 + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken); + if (merchant == null) + { + return false; + } + + // 3. 记录变更日志 + var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId; + var actorName = currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system"; + var oldValue = merchant.OperatingMode?.ToString(); + var newValue = request.OperatingMode.ToString(); + + if (!string.Equals(oldValue, newValue, StringComparison.Ordinal)) + { + await merchantRepository.AddChangeLogAsync(new MerchantChangeLog + { + TenantId = merchant.TenantId, + MerchantId = merchant.Id, + FieldName = "operatingMode", + OldValue = oldValue, + NewValue = newValue, + ChangedBy = actorId, + ChangedByName = actorName, + ChangeType = "Update" + }, cancellationToken); + } + + // 4. 更新经营模式 + merchant.OperatingMode = request.OperatingMode; + merchant.RowVersion = request.RowVersion; + + // 5. 持久化 + await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken); + + try + { + await merchantRepository.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) when (IsConcurrencyException(exception)) + { + throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试"); + } + + logger.LogInformation("更新商户 {MerchantId} 经营模式为 {OperatingMode}", merchant.Id, request.OperatingMode); + return true; + } + + private static bool IsConcurrencyException(Exception exception) + => string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs index 41298a8..aaf57ed 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs @@ -56,12 +56,21 @@ internal static class MerchantMapping TenantId = merchant.TenantId, TenantName = tenantName, Name = merchant.BrandName, + BrandAlias = merchant.BrandAlias, + LogoUrl = merchant.LogoUrl, + Category = merchant.Category, OperatingMode = merchant.OperatingMode, LicenseNumber = merchant.BusinessLicenseNumber, LegalRepresentative = merchant.LegalPerson, - RegisteredAddress = merchant.Address, + TaxNumber = merchant.TaxNumber, + Province = merchant.Province, + City = merchant.City, + District = merchant.District, + Address = merchant.Address, ContactPhone = merchant.ContactPhone, ContactEmail = merchant.ContactEmail, + ServicePhone = merchant.ServicePhone, + SupportEmail = merchant.SupportEmail, Status = merchant.Status, IsFrozen = merchant.IsFrozen, FrozenReason = merchant.FrozenReason, diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/UpdateMerchantCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/UpdateMerchantCommandValidator.cs index 248f60c..5b4eeca 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/UpdateMerchantCommandValidator.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/UpdateMerchantCommandValidator.cs @@ -15,12 +15,22 @@ public sealed class UpdateMerchantCommandValidator : AbstractValidator x.MerchantId).GreaterThan(0); RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + RuleFor(x => x.BrandAlias).MaximumLength(64); + RuleFor(x => x.LogoUrl).MaximumLength(512); + RuleFor(x => x.Category).MaximumLength(64); RuleFor(x => x.LicenseNumber).MaximumLength(64); + RuleFor(x => x.TaxNumber).MaximumLength(64); RuleFor(x => x.LegalRepresentative).MaximumLength(64); - RuleFor(x => x.RegisteredAddress).MaximumLength(256); RuleFor(x => x.ContactPhone).NotEmpty().MaximumLength(32); RuleFor(x => x.ContactEmail).EmailAddress().MaximumLength(128) .When(x => !string.IsNullOrWhiteSpace(x.ContactEmail)); + RuleFor(x => x.ServicePhone).MaximumLength(32); + RuleFor(x => x.SupportEmail).EmailAddress().MaximumLength(128) + .When(x => !string.IsNullOrWhiteSpace(x.SupportEmail)); + RuleFor(x => x.Province).MaximumLength(64); + RuleFor(x => x.City).MaximumLength(64); + RuleFor(x => x.District).MaximumLength(64); + RuleFor(x => x.Address).MaximumLength(256); RuleFor(x => x.RowVersion).NotEmpty(); } } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs deleted file mode 100644 index f9ea54b..0000000 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs +++ /dev/null @@ -1,24 +0,0 @@ -using TakeoutSaaS.Application.Dictionary.Models; - -namespace TakeoutSaaS.Application.Dictionary.Abstractions; - -/// -/// 字典缓存读写接口。 -/// -public interface IDictionaryCache -{ - /// - /// 获取缓存。 - /// - Task?> GetAsync(long tenantId, string code, CancellationToken cancellationToken = default); - - /// - /// 写入缓存。 - /// - Task SetAsync(long tenantId, string code, IReadOnlyList items, CancellationToken cancellationToken = default); - - /// - /// 移除缓存。 - /// - Task RemoveAsync(long tenantId, string code, CancellationToken cancellationToken = default); -} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryHybridCache.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryHybridCache.cs deleted file mode 100644 index 8a4c736..0000000 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryHybridCache.cs +++ /dev/null @@ -1,26 +0,0 @@ -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/Contracts/UpdateDictionaryGroupRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs index 8c66b3a..6909436 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs @@ -32,5 +32,5 @@ public sealed class UpdateDictionaryGroupRequest /// /// 行版本,用于并发控制。 /// - public byte[]? RowVersion { get; set; } + public uint RowVersion { get; set; } } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs index b2d00f5..c3a9cfd 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs @@ -43,5 +43,5 @@ public sealed class UpdateDictionaryItemRequest /// /// 行版本,用于并发控制。 /// - public byte[]? RowVersion { get; set; } + public uint RowVersion { get; set; } } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs index 8fb64fc..69222a6 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs @@ -64,7 +64,7 @@ public sealed class DictionaryGroupDto /// /// 并发控制字段。 /// - public byte[] RowVersion { get; init; } = Array.Empty(); + public uint RowVersion { get; init; } /// /// 字典项集合。 diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs index 36a56c8..1b7843f 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs @@ -59,5 +59,5 @@ public sealed class DictionaryItemDto /// /// 并发控制字段。 /// - public byte[] RowVersion { get; init; } = Array.Empty(); + public uint RowVersion { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs index 8d080d2..7b02671 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Logging; using Microsoft.AspNetCore.Http; -using System.Security.Cryptography; using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Application.Dictionary.Contracts; using TakeoutSaaS.Application.Dictionary.Models; @@ -17,7 +16,6 @@ namespace TakeoutSaaS.Application.Dictionary.Services; /// public sealed class DictionaryAppService( IDictionaryRepository repository, - IDictionaryCache cache, ILogger logger) : IDictionaryAppService { /// @@ -49,8 +47,7 @@ public sealed class DictionaryAppService( Scope = request.Scope, AllowOverride = request.AllowOverride, Description = request.Description?.Trim(), - IsEnabled = true, - RowVersion = RandomNumberGenerator.GetBytes(16) + IsEnabled = true }; // 4. 持久化并返回 @@ -72,12 +69,12 @@ public sealed class DictionaryAppService( // 1. 读取分组并校验权限 var group = await RequireGroupAsync(groupId, cancellationToken); - if (request.RowVersion == null || request.RowVersion.Length == 0) + if (request.RowVersion == 0) { throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空"); } - if (!request.RowVersion.SequenceEqual(group.RowVersion)) + if (request.RowVersion != group.RowVersion) { throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试"); } @@ -87,9 +84,8 @@ public sealed class DictionaryAppService( group.Description = request.Description?.Trim(); group.IsEnabled = request.IsEnabled; group.AllowOverride = request.AllowOverride; - group.RowVersion = RandomNumberGenerator.GetBytes(16); - // 3. 持久化并失效缓存 + // 3. 持久化 try { await repository.SaveChangesAsync(cancellationToken); @@ -98,7 +94,6 @@ public sealed class DictionaryAppService( { throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试"); } - await InvalidateCacheAsync(group, cancellationToken); logger.LogInformation("更新字典分组:{GroupId}", group.Id); return MapGroup(group, includeItems: false); } @@ -113,10 +108,9 @@ public sealed class DictionaryAppService( // 1. 读取分组并校验权限 var group = await RequireGroupAsync(groupId, cancellationToken); - // 2. 删除并失效缓存 + // 2. 删除 await repository.RemoveGroupAsync(group, cancellationToken); await repository.SaveChangesAsync(cancellationToken); - await InvalidateCacheAsync(group, cancellationToken); logger.LogInformation("删除字典分组:{GroupId}", group.Id); } @@ -175,14 +169,12 @@ public sealed class DictionaryAppService( Description = request.Description?.Trim(), SortOrder = request.SortOrder, IsDefault = request.IsDefault, - IsEnabled = request.IsEnabled, - RowVersion = RandomNumberGenerator.GetBytes(16) + IsEnabled = request.IsEnabled }; - // 3. 持久化并失效缓存 + // 3. 持久化 await repository.AddItemAsync(item, cancellationToken); await repository.SaveChangesAsync(cancellationToken); - await InvalidateCacheAsync(group, cancellationToken); logger.LogInformation("新增字典项:{ItemId}", item.Id); return MapItem(item); } @@ -200,12 +192,12 @@ public sealed class DictionaryAppService( var item = await RequireItemAsync(itemId, cancellationToken); var group = await RequireGroupAsync(item.GroupId, cancellationToken); - if (request.RowVersion == null || request.RowVersion.Length == 0) + if (request.RowVersion == 0) { throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空"); } - if (!request.RowVersion.SequenceEqual(item.RowVersion)) + if (request.RowVersion != item.RowVersion) { throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试"); } @@ -217,9 +209,8 @@ public sealed class DictionaryAppService( item.SortOrder = request.SortOrder; item.IsDefault = request.IsDefault; item.IsEnabled = request.IsEnabled; - item.RowVersion = RandomNumberGenerator.GetBytes(16); - // 3. 持久化并失效缓存 + // 3. 持久化 try { await repository.SaveChangesAsync(cancellationToken); @@ -228,7 +219,6 @@ public sealed class DictionaryAppService( { throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试"); } - await InvalidateCacheAsync(group, cancellationToken); logger.LogInformation("更新字典项:{ItemId}", item.Id); return MapItem(item); } @@ -244,15 +234,14 @@ public sealed class DictionaryAppService( var item = await RequireItemAsync(itemId, cancellationToken); var group = await RequireGroupAsync(item.GroupId, cancellationToken); - // 2. 删除并失效缓存 + // 2. 删除 await repository.RemoveItemAsync(item, cancellationToken); await repository.SaveChangesAsync(cancellationToken); - await InvalidateCacheAsync(group, cancellationToken); logger.LogInformation("删除字典项:{ItemId}", item.Id); } /// - /// 批量获取缓存中的字典项。 + /// 批量获取字典项。 /// /// 批量查询请求。 /// 取消标记。 @@ -277,14 +266,14 @@ public sealed class DictionaryAppService( foreach (var code in normalizedCodes) { - var systemItems = await GetOrLoadCacheAsync(0, code, cancellationToken); + var systemItems = await LoadItemsAsync(0, code, cancellationToken); if (tenantId == 0) { result[code] = systemItems; continue; } - var tenantItems = await GetOrLoadCacheAsync(tenantId, code, cancellationToken); + var tenantItems = await LoadItemsAsync(tenantId, code, cancellationToken); result[code] = MergeItems(systemItems, tenantItems); } @@ -342,36 +331,15 @@ public sealed class DictionaryAppService( return tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business; } - private async Task InvalidateCacheAsync(DictionaryGroup group, CancellationToken cancellationToken) + private async Task> LoadItemsAsync(long tenantId, string code, CancellationToken cancellationToken) { - await cache.RemoveAsync(group.TenantId, group.Code, cancellationToken); - if (group.Scope == DictionaryScope.Business) - { - return; - } - - // 系统参数更新需要逐租户重新合并,由调用方在下一次请求时重新加载 - } - - private async Task> GetOrLoadCacheAsync(long tenantId, string code, CancellationToken cancellationToken) - { - // 1. 先查缓存 - var cached = await cache.GetAsync(tenantId, code, cancellationToken); - if (cached != null) - { - return cached; - } - - // 2. 从仓储加载并写入缓存 + // 从仓储加载 var entities = await repository.GetItemsByCodesAsync(new[] { code }, tenantId, includeSystem: false, cancellationToken); - var items = entities + return entities .Where(item => item.IsEnabled && (item.Group?.IsEnabled ?? true)) .Select(MapItem) .OrderBy(item => item.SortOrder) .ToList(); - - await cache.SetAsync(tenantId, code, items, cancellationToken); - return items; } private static IReadOnlyList MergeItems(IReadOnlyList systemItems, IReadOnlyList tenantItems) diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCacheKeys.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCacheKeys.cs deleted file mode 100644 index 9a45af5..0000000 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCacheKeys.cs +++ /dev/null @@ -1,44 +0,0 @@ -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 index ddfc24b..ef68b32 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCommandService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCommandService.cs @@ -1,6 +1,4 @@ -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; @@ -18,7 +16,6 @@ namespace TakeoutSaaS.Application.Dictionary.Services; public sealed class DictionaryCommandService( IDictionaryGroupRepository groupRepository, IDictionaryItemRepository itemRepository, - IDictionaryHybridCache cache, ILogger logger) { /// @@ -43,16 +40,11 @@ public sealed class DictionaryCommandService( Scope = request.Scope, AllowOverride = request.AllowOverride, Description = request.Description?.Trim(), - IsEnabled = true, - RowVersion = RandomNumberGenerator.GetBytes(16) + IsEnabled = true }; 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); @@ -71,7 +63,6 @@ public sealed class DictionaryCommandService( group.Description = request.Description?.Trim(); group.IsEnabled = request.IsEnabled; group.AllowOverride = request.AllowOverride; - group.RowVersion = RandomNumberGenerator.GetBytes(16); try { @@ -82,7 +73,6 @@ public sealed class DictionaryCommandService( throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试"); } - await InvalidateGroupCacheAsync(group, CacheInvalidationOperation.Update, cancellationToken); logger.LogInformation("更新字典分组 {GroupId}", group.Id); return DictionaryMapper.ToGroupDto(group); } @@ -106,7 +96,6 @@ public sealed class DictionaryCommandService( await groupRepository.RemoveAsync(group, cancellationToken); await groupRepository.SaveChangesAsync(cancellationToken); - await InvalidateGroupCacheAsync(group, CacheInvalidationOperation.Delete, cancellationToken); logger.LogInformation("删除字典分组 {GroupId}", group.Id); return true; @@ -141,13 +130,11 @@ public sealed class DictionaryCommandService( Description = request.Description?.Trim(), SortOrder = sortOrder, IsDefault = request.IsDefault, - IsEnabled = request.IsEnabled, - RowVersion = RandomNumberGenerator.GetBytes(16) + IsEnabled = request.IsEnabled }; await itemRepository.AddAsync(item, cancellationToken); await groupRepository.SaveChangesAsync(cancellationToken); - await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Create, cancellationToken); logger.LogInformation("新增字典项 {ItemId}", item.Id); return DictionaryMapper.ToItemDto(item); @@ -179,7 +166,6 @@ public sealed class DictionaryCommandService( item.SortOrder = request.SortOrder; item.IsDefault = request.IsDefault; item.IsEnabled = request.IsEnabled; - item.RowVersion = RandomNumberGenerator.GetBytes(16); try { @@ -190,7 +176,6 @@ public sealed class DictionaryCommandService( throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试"); } - await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Update, cancellationToken); logger.LogInformation("更新字典项 {ItemId}", item.Id); return DictionaryMapper.ToItemDto(item); } @@ -206,11 +191,8 @@ public sealed class DictionaryCommandService( return false; } - var group = await RequireGroupAsync(item.GroupId, cancellationToken); - await itemRepository.RemoveAsync(item, cancellationToken); await groupRepository.SaveChangesAsync(cancellationToken); - await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Delete, cancellationToken); logger.LogInformation("删除字典项 {ItemId}", item.Id); return true; @@ -231,14 +213,14 @@ public sealed class DictionaryCommandService( return tenantId.Value; } - private static void EnsureRowVersion(byte[]? requestVersion, byte[] entityVersion, string resourceName) + private static void EnsureRowVersion(uint requestVersion, uint entityVersion, string resourceName) { - if (requestVersion == null || requestVersion.Length == 0) + if (requestVersion == 0) { throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空"); } - if (!requestVersion.SequenceEqual(entityVersion)) + if (requestVersion != entityVersion) { throw new BusinessException(ErrorCodes.Conflict, $"{resourceName}已被修改,请刷新后重试"); } @@ -266,45 +248,6 @@ public sealed class DictionaryCommandService( 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 index 72d520c..2cadaba 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using TakeoutSaaS.Application.Dictionary.Abstractions; @@ -25,7 +24,6 @@ public sealed class DictionaryImportExportService( IDictionaryGroupRepository groupRepository, IDictionaryItemRepository itemRepository, IDictionaryImportLogRepository importLogRepository, - IDictionaryHybridCache cache, ICurrentUserAccessor currentUser, ILogger logger) { @@ -163,8 +161,7 @@ public sealed class DictionaryImportExportService( SortOrder = sortOrder, IsEnabled = row.IsEnabled ?? true, IsDefault = false, - Description = row.Description, - RowVersion = RandomNumberGenerator.GetBytes(16) + Description = row.Description }; await itemRepository.AddAsync(item, cancellationToken); @@ -173,7 +170,6 @@ public sealed class DictionaryImportExportService( } 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); @@ -380,23 +376,6 @@ public sealed class DictionaryImportExportService( } } - 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); diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryOverrideService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryOverrideService.cs index 325500a..94d8733 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryOverrideService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryOverrideService.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Application.Dictionary.Models; using TakeoutSaaS.Domain.Dictionary.Entities; using TakeoutSaaS.Domain.Dictionary.Enums; @@ -16,8 +15,7 @@ namespace TakeoutSaaS.Application.Dictionary.Services; public sealed class DictionaryOverrideService( IDictionaryGroupRepository groupRepository, IDictionaryItemRepository itemRepository, - ITenantDictionaryOverrideRepository overrideRepository, - IDictionaryHybridCache cache) + ITenantDictionaryOverrideRepository overrideRepository) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -106,10 +104,6 @@ public sealed class DictionaryOverrideService( } await overrideRepository.SaveChangesAsync(cancellationToken); - await cache.InvalidateAsync( - DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code), - CacheInvalidationOperation.Update, - cancellationToken); return MapOverrideDto(config, systemGroup.Code); } @@ -134,10 +128,6 @@ public sealed class DictionaryOverrideService( 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; } @@ -191,10 +181,6 @@ public sealed class DictionaryOverrideService( } await overrideRepository.SaveChangesAsync(cancellationToken); - await cache.InvalidateAsync( - DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code), - CacheInvalidationOperation.Update, - cancellationToken); return MapOverrideDto(config, systemGroup.Code); } @@ -245,10 +231,6 @@ public sealed class DictionaryOverrideService( } await overrideRepository.SaveChangesAsync(cancellationToken); - await cache.InvalidateAsync( - DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code), - CacheInvalidationOperation.Update, - cancellationToken); return MapOverrideDto(config, systemGroup.Code); } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryQueryService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryQueryService.cs index a338b56..79f5dc1 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryQueryService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryQueryService.cs @@ -1,7 +1,5 @@ -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; @@ -16,11 +14,8 @@ namespace TakeoutSaaS.Application.Dictionary.Services; /// public sealed class DictionaryQueryService( IDictionaryGroupRepository groupRepository, - IDictionaryItemRepository itemRepository, - IDictionaryHybridCache cache) + IDictionaryItemRepository itemRepository) { - private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30); - /// /// 获取字典分组分页数据。 /// @@ -34,87 +29,55 @@ public sealed class DictionaryQueryService( { throw new BusinessException(ErrorCodes.ValidationFailed, "Scope=Business 时必须指定 TenantId"); } - - // 2. (空行后) 确定作用域与目标租户 + + // 2. 确定作用域与目标租户 var scope = query.Scope ?? (tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business); if (scope == DictionaryScope.System) { tenantId = 0; } - - // 3. (空行后) 构建缓存键并加载分页数据 + + // 3. 查询分页数据 var sortDescending = string.Equals(query.SortOrder, "desc", StringComparison.OrdinalIgnoreCase); var targetTenant = scope == DictionaryScope.System ? 0 : tenantId; - var cacheKey = DictionaryCacheKeys.BuildGroupKey( + var groups = await groupRepository.GetPagedAsync( targetTenant, scope, - query.Page, - query.PageSize, query.Keyword, query.IsEnabled, + query.Page, + query.PageSize, 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 - }; - }, + sortDescending, cancellationToken); - var page = cached ?? new DictionaryGroupPage - { - Items = Array.Empty(), - Page = query.Page, - PageSize = query.PageSize, - TotalCount = 0 - }; + var total = await groupRepository.CountAsync( + targetTenant, + scope, + query.Keyword, + query.IsEnabled, + cancellationToken); - return new PagedResult(page.Items, page.Page, page.PageSize, page.TotalCount); + // 4. 转换为 DTO + 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, cancellationToken); + groupItems = groupItemEntities + .Where(item => item.IsEnabled) + .OrderBy(item => item.SortOrder) + .Select(DictionaryMapper.ToItemDto) + .ToList(); + } + + items.Add(DictionaryMapper.ToGroupDto(group, groupItems)); + } + + return new PagedResult(items, query.Page, query.PageSize, total); } /// @@ -136,28 +99,18 @@ public sealed class DictionaryQueryService( /// 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, "字典分组不存在"); - } + var group = await groupRepository.GetByIdAsync(groupId, cancellationToken); + if (group == null) + { + throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在"); + } - 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(); + var items = await itemRepository.GetByGroupIdAsync(group.TenantId, groupId, cancellationToken); + return items + .Where(item => item.IsEnabled) + .OrderBy(item => item.SortOrder) + .Select(DictionaryMapper.ToItemDto) + .ToList(); } /// @@ -171,31 +124,20 @@ public sealed class DictionaryQueryService( } // 1. 管理端默认读取系统字典(TenantId=0) - var tenantId = 0; 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(); - } + var systemGroup = await groupRepository.GetByCodeAsync(0, normalized, cancellationToken); + if (systemGroup == null || !systemGroup.IsEnabled) + { + return Array.Empty(); + } - var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, token); - return systemItems - .Where(item => item.IsEnabled) - .OrderBy(item => item.SortOrder) - .Select(DictionaryMapper.ToItemDto) - .ToList(); - }, - cancellationToken); - - return cached ?? Array.Empty(); + var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, cancellationToken); + return systemItems + .Where(item => item.IsEnabled) + .OrderBy(item => item.SortOrder) + .Select(DictionaryMapper.ToItemDto) + .ToList(); } /// @@ -230,12 +172,4 @@ public sealed class DictionaryQueryService( return result; } - - 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/Identity/Commands/UpdateIdentityUserCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateIdentityUserCommand.cs index 6a7fec7..0055761 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateIdentityUserCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateIdentityUserCommand.cs @@ -48,6 +48,5 @@ public sealed record UpdateIdentityUserCommand : IRequest /// 并发控制版本。 /// [Required] - [MinLength(1)] - public byte[] RowVersion { get; init; } = Array.Empty(); + public uint RowVersion { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserDetailDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserDetailDto.cs index 86a2c62..0f468f5 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserDetailDto.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserDetailDto.cs @@ -95,5 +95,5 @@ public sealed record UserDetailDto /// /// 并发控制版本。 /// - public byte[] RowVersion { get; init; } = Array.Empty(); + public uint RowVersion { get; init; } } diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs index b4430a8..a17264c 100644 --- a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs @@ -40,9 +40,9 @@ public sealed class DictionaryGroup : MultiTenantEntityBase public bool IsEnabled { get; set; } = true; /// - /// 并发控制字段。 + /// 并发控制字段(映射到 PostgreSQL xmin)。 /// - public byte[] RowVersion { get; set; } = Array.Empty(); + public uint RowVersion { get; set; } /// /// 字典项集合。 diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs index b2ccb74..df3b392 100644 --- a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs @@ -43,9 +43,9 @@ public sealed class DictionaryItem : MultiTenantEntityBase public string? Description { get; set; } /// - /// 并发控制字段。 + /// 并发控制字段(映射到 PostgreSQL xmin)。 /// - public byte[] RowVersion { get; set; } = Array.Empty(); + public uint RowVersion { get; set; } /// /// 导航属性:所属分组。 diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs index 400e9eb..65cc8c6 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs @@ -79,7 +79,7 @@ public sealed class IdentityUser : AuditableEntityBase public string? Avatar { get; set; } /// - /// 并发控制字段。 + /// 并发控制字段(映射到 PostgreSQL xmin)。 /// - public byte[] RowVersion { get; set; } = Array.Empty(); + public uint RowVersion { get; set; } } diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs index 7f7b12f..69448fd 100644 --- a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs @@ -45,8 +45,7 @@ public sealed class InventoryBatch : MultiTenantEntityBase public int RemainingQuantity { get; set; } /// - /// 并发控制字段。 + /// 并发控制字段(映射到 PostgreSQL xmin)。 /// - [Timestamp] - public byte[] RowVersion { get; set; } = Array.Empty(); + public uint RowVersion { get; set; } } diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs index 32390c0..31c8919 100644 --- a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs @@ -91,8 +91,7 @@ public sealed class InventoryItem : MultiTenantEntityBase public InventoryBatchConsumeStrategy BatchConsumeStrategy { get; set; } = InventoryBatchConsumeStrategy.Fifo; /// - /// 并发控制字段。 + /// 并发控制字段(映射到 PostgreSQL xmin)。 /// - [Timestamp] - public byte[] RowVersion { get; set; } = Array.Empty(); + public uint RowVersion { get; set; } } diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs index 89e6304..8d9a7dd 100644 --- a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs @@ -45,8 +45,7 @@ public sealed class InventoryLockRecord : MultiTenantEntityBase public InventoryLockStatus Status { get; set; } = InventoryLockStatus.Locked; /// - /// 并发控制字段。 + /// 并发控制字段(映射到 PostgreSQL xmin)。 /// - [Timestamp] - public byte[] RowVersion { get; set; } = Array.Empty(); + public uint RowVersion { get; set; } } diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs index e6b0f73..e93c137 100644 --- a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs @@ -175,7 +175,7 @@ public sealed class Merchant : MultiTenantEntityBase public DateTime? ClaimExpiresAt { get; set; } /// - /// 并发控制版本。 + /// 并发控制版本(映射到 PostgreSQL xmin)。 /// - public byte[] RowVersion { get; set; } = Array.Empty(); + public uint RowVersion { get; set; } } diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs index 865b1ec..31d178a 100644 --- a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs @@ -68,5 +68,15 @@ public enum MerchantAuditAction /// /// 强制接管审核。 /// - ReviewForceClaimed = 12 + ReviewForceClaimed = 12, + + /// + /// 商户冻结。 + /// + Frozen = 13, + + /// + /// 商户解冻。 + /// + Unfrozen = 14 } diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs index d47984c..9a81990 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs @@ -34,8 +34,7 @@ public sealed class StorePickupSetting : MultiTenantEntityBase public int? MaxQuantityPerOrder { get; set; } /// - /// 并发控制字段。 + /// 并发控制字段(映射到 PostgreSQL xmin)。 /// - [Timestamp] - public byte[] RowVersion { get; set; } = Array.Empty(); + public uint RowVersion { get; set; } } diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs index 8ab3f67..c0c3421 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs @@ -54,8 +54,7 @@ public sealed class StorePickupSlot : MultiTenantEntityBase public bool IsEnabled { get; set; } = true; /// - /// 并发控制字段。 + /// 并发控制字段(映射到 PostgreSQL xmin)。 /// - [Timestamp] - public byte[] RowVersion { get; set; } = Array.Empty(); + public uint RowVersion { get; set; } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 122de84..e5be6ca 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -646,9 +646,10 @@ public class TakeoutAppDbContext( builder.Property(x => x.FrozenReason).HasMaxLength(500); builder.Property(x => x.ClaimedByName).HasMaxLength(100); builder.Property(x => x.RowVersion) - .IsRowVersion() - .IsConcurrencyToken() - .HasColumnType("bytea"); + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() + .IsConcurrencyToken(); builder.HasIndex(x => x.TenantId); builder.HasIndex(x => new { x.TenantId, x.Status }); builder.HasIndex(x => x.ClaimedBy); @@ -978,6 +979,9 @@ public class TakeoutAppDbContext( builder.Property(x => x.StoreId).IsRequired(); builder.Property(x => x.DefaultCutoffMinutes).HasDefaultValue(30); builder.Property(x => x.RowVersion) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() .IsConcurrencyToken(); builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique(); } @@ -991,6 +995,9 @@ public class TakeoutAppDbContext( builder.Property(x => x.Weekdays).HasMaxLength(32).IsRequired(); builder.Property(x => x.CutoffMinutes).HasDefaultValue(30); builder.Property(x => x.RowVersion) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() .IsConcurrencyToken(); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }); } @@ -1079,6 +1086,9 @@ public class TakeoutAppDbContext( builder.Property(x => x.BatchNumber).HasMaxLength(64); builder.Property(x => x.Location).HasMaxLength(64); builder.Property(x => x.RowVersion) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() .IsConcurrencyToken(); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }); } @@ -1101,6 +1111,9 @@ public class TakeoutAppDbContext( builder.Property(x => x.ProductSkuId).IsRequired(); builder.Property(x => x.BatchNumber).HasMaxLength(64).IsRequired(); builder.Property(x => x.RowVersion) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() .IsConcurrencyToken(); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }).IsUnique(); } @@ -1115,6 +1128,9 @@ public class TakeoutAppDbContext( builder.Property(x => x.IdempotencyKey).HasMaxLength(128).IsRequired(); builder.Property(x => x.Status).HasConversion(); builder.Property(x => x.RowVersion) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() .IsConcurrencyToken(); builder.HasIndex(x => new { x.TenantId, x.IdempotencyKey }).IsUnique(); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.Status }); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheMetricsCollector.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheMetricsCollector.cs deleted file mode 100644 index 007ba4b..0000000 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheMetricsCollector.cs +++ /dev/null @@ -1,212 +0,0 @@ -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 deleted file mode 100644 index 71af78e..0000000 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/CacheWarmupService.cs +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 5bdb292..0000000 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/HybridCacheService.cs +++ /dev/null @@ -1,229 +0,0 @@ -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 deleted file mode 100644 index ca9c7e0..0000000 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/MemoryCacheService.cs +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index f28ff57..0000000 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/RedisCacheService.cs +++ /dev/null @@ -1,79 +0,0 @@ -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 1db973a..e10830f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -1,19 +1,12 @@ -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; -using TakeoutSaaS.Infrastructure.Dictionary.Services; using TakeoutSaaS.Shared.Abstractions.Constants; namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions; @@ -32,9 +25,11 @@ public static class DictionaryServiceCollectionExtensions /// 缺少数据库配置时抛出。 public static IServiceCollection AddDictionaryInfrastructure(this IServiceCollection services, IConfiguration configuration) { + // 1. 注册数据库上下文 services.AddDatabaseInfrastructure(configuration); services.AddPostgresDbContext(DatabaseConstants.DictionaryDataSource); + // 2. 注册仓储 services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -43,69 +38,11 @@ public static class DictionaryServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + + // 3. 注册导入导出解析器 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; } - - /// - /// 确保数据库连接已配置(Database 节或 ConnectionStrings)。 - /// - /// 配置源。 - /// 数据源名称。 - /// 未配置时抛出。 - private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName) - { - // 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。 - } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheOptions.cs deleted file mode 100644 index c5df7a0..0000000 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace TakeoutSaaS.Infrastructure.Dictionary.Options; - -/// -/// 字典缓存配置。 -/// -public sealed class DictionaryCacheOptions -{ - /// - /// 缓存滑动过期时间。 - /// - public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(30); -} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheWarmupOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheWarmupOptions.cs deleted file mode 100644 index 7c5c2f4..0000000 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheWarmupOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -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 4526454..8a7dc86 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs @@ -60,10 +60,8 @@ public sealed class DictionaryDbContext( protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - var provider = Database.ProviderName; - var isSqlite = provider != null && provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase); - ConfigureGroup(modelBuilder.Entity(), isSqlite); - ConfigureItem(modelBuilder.Entity(), isSqlite); + ConfigureGroup(modelBuilder.Entity()); + ConfigureItem(modelBuilder.Entity()); ConfigureOverride(modelBuilder.Entity()); ConfigureLabelOverride(modelBuilder.Entity()); ConfigureImportLog(modelBuilder.Entity()); @@ -75,7 +73,7 @@ public sealed class DictionaryDbContext( /// 配置字典分组。 /// /// 实体构建器。 - private static void ConfigureGroup(EntityTypeBuilder builder, bool isSqlite) + private static void ConfigureGroup(EntityTypeBuilder builder) { builder.ToTable("dictionary_groups"); builder.HasKey(x => x.Id); @@ -92,19 +90,12 @@ public sealed class DictionaryDbContext( ConfigureAuditableEntity(builder); ConfigureSoftDeleteEntity(builder); - var rowVersion = builder.Property(x => x.RowVersion) + builder.Property(x => x.RowVersion) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() .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() @@ -116,7 +107,7 @@ public sealed class DictionaryDbContext( /// 配置字典项。 /// /// 实体构建器。 - private static void ConfigureItem(EntityTypeBuilder builder, bool isSqlite) + private static void ConfigureItem(EntityTypeBuilder builder) { builder.ToTable("dictionary_items"); builder.HasKey(x => x.Id); @@ -130,19 +121,12 @@ public sealed class DictionaryDbContext( ConfigureAuditableEntity(builder); ConfigureSoftDeleteEntity(builder); - var rowVersion = builder.Property(x => x.RowVersion) + builder.Property(x => x.RowVersion) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() .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) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs index 7c543ee..381a50f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs @@ -1,4 +1,3 @@ -using System.Security.Cryptography; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Dictionary.Entities; using TakeoutSaaS.Domain.Dictionary.Repositories; @@ -123,11 +122,6 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic } 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; } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs deleted file mode 100644 index 800af5e..0000000 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Options; -using System.Text.Json; -using TakeoutSaaS.Application.Dictionary.Abstractions; -using TakeoutSaaS.Application.Dictionary.Models; -using TakeoutSaaS.Infrastructure.Dictionary.Options; - -namespace TakeoutSaaS.Infrastructure.Dictionary.Services; - -/// -/// 基于 IDistributedCache 的字典缓存实现。 -/// -public sealed class DistributedDictionaryCache(IDistributedCache cache, IOptions options) : IDictionaryCache -{ - private readonly DictionaryCacheOptions _options = options.Value; - private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); - - /// - /// 读取指定租户与编码的字典缓存。 - /// - /// 租户 ID。 - /// 字典编码。 - /// 取消标记。 - /// 字典项集合或 null。 - public async Task?> GetAsync(long tenantId, string code, CancellationToken cancellationToken = default) - { - // 1. 拼装缓存键 - var cacheKey = BuildKey(tenantId, code); - var payload = await cache.GetAsync(cacheKey, cancellationToken); - if (payload == null || payload.Length == 0) - { - return null; - } - - // 2. 反序列化 - return JsonSerializer.Deserialize>(payload, _serializerOptions); - } - - /// - /// 设置指定租户与编码的字典缓存。 - /// - /// 租户 ID。 - /// 字典编码。 - /// 字典项集合。 - /// 取消标记。 - /// 异步任务。 - public Task SetAsync(long tenantId, string code, IReadOnlyList items, CancellationToken cancellationToken = default) - { - // 1. 序列化并写入缓存 - var cacheKey = BuildKey(tenantId, code); - var payload = JsonSerializer.SerializeToUtf8Bytes(items, _serializerOptions); - var options = new DistributedCacheEntryOptions - { - SlidingExpiration = _options.SlidingExpiration - }; - return cache.SetAsync(cacheKey, payload, options, cancellationToken); - } - - /// - /// 移除指定租户与编码的缓存。 - /// - /// 租户 ID。 - /// 字典编码。 - /// 取消标记。 - /// 异步任务。 - public Task RemoveAsync(long tenantId, string code, CancellationToken cancellationToken = default) - { - // 1. 删除缓存键 - var cacheKey = BuildKey(tenantId, code); - return cache.RemoveAsync(cacheKey, cancellationToken); - } - - private static string BuildKey(long tenantId, string code) - => $"dictionary:{tenantId}:{code.ToLowerInvariant()}"; -} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index 5107789..0cc0b5f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -108,9 +108,10 @@ public sealed class IdentityDbContext( builder.Property(x => x.MustChangePassword).IsRequired(); builder.Property(x => x.Avatar).HasColumnType("text"); builder.Property(x => x.RowVersion) - .IsRowVersion() - .IsConcurrencyToken() - .HasColumnType("bytea"); + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() + .IsConcurrencyToken(); builder.Property(x => x.Portal).HasConversion().IsRequired(); builder.Property(x => x.TenantId); ConfigureAuditableEntity(builder);