feat: 商户冻结/解冻功能及字典缓存重构

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MSuMshk
2026-02-04 10:46:32 +08:00
parent 754dd788ea
commit f69904e195
54 changed files with 753 additions and 1385 deletions

View File

@@ -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;
/// <summary>
/// 缓存监控指标接口。
/// </summary>
[ApiVersion("1.0")]
[Authorize(Roles = "PlatformAdmin")]
[Route("api/admin/v{version:apiVersion}/dictionary/metrics")]
public sealed class CacheMetricsController(
CacheMetricsCollector metricsCollector,
ICacheInvalidationLogRepository invalidationLogRepository)
: BaseApiController
{
/// <summary>
/// 获取缓存统计信息。
/// </summary>
[HttpGet("cache-stats")]
[ProducesResponseType(typeof(ApiResponse<CacheStatsSnapshot>), StatusCodes.Status200OK)]
public ApiResponse<CacheStatsSnapshot> GetCacheStats([FromQuery] string? timeRange = "1h")
{
var window = timeRange?.ToLowerInvariant() switch
{
"24h" => TimeSpan.FromHours(24),
"7d" => TimeSpan.FromDays(7),
_ => TimeSpan.FromHours(1)
};
var snapshot = metricsCollector.GetSnapshot(window);
return ApiResponse<CacheStatsSnapshot>.Ok(snapshot);
}
/// <summary>
/// 获取缓存失效事件列表。
/// </summary>
[HttpGet("invalidation-events")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<CacheInvalidationLog>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<CacheInvalidationLog>>> GetInvalidationEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] DateTime? startDate = null,
[FromQuery] DateTime? endDate = null,
CancellationToken cancellationToken = default)
{
var safePage = page <= 0 ? 1 : page;
var safePageSize = pageSize <= 0 ? 20 : pageSize;
var (items, total) = await invalidationLogRepository.GetPagedAsync(
safePage,
safePageSize,
startDate,
endDate,
cancellationToken);
var result = new PagedResult<CacheInvalidationLog>(items, safePage, safePageSize, total);
return ApiResponse<PagedResult<CacheInvalidationLog>>.Ok(result);
}
}

View File

@@ -90,7 +90,8 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
return ApiResponse<UpdateMerchantResultDto>.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<IReadOnlyList<string>>.Ok(result);
}
/// <summary>
/// 更新商户经营模式。
/// </summary>
/// <param name="merchantId">商户 ID。</param>
/// <param name="request">请求体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新结果。</returns>
[HttpPut("{merchantId:long}/operating-mode")]
[PermissionAuthorize("merchant:update")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> 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<object>.Ok(null)
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
}
/// <summary>
/// 冻结商户。
/// </summary>
/// <param name="merchantId">商户 ID。</param>
/// <param name="request">请求体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>冻结结果。</returns>
[HttpPut("{merchantId:long}/freeze")]
[PermissionAuthorize("merchant:freeze")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> 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<object>.Ok(null)
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
}
/// <summary>
/// 解冻商户。
/// </summary>
/// <param name="merchantId">商户 ID。</param>
/// <param name="request">请求体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>解冻结果。</returns>
[HttpPut("{merchantId:long}/unfreeze")]
[PermissionAuthorize("merchant:unfreeze")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> 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<object>.Ok(null)
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,24 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 冻结商户命令。
/// </summary>
public sealed record FreezeMerchantCommand : IRequest<bool>
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
/// <summary>
/// 冻结原因。
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// 并发控制版本PostgreSQL xmin
/// </summary>
public uint RowVersion { get; init; }
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 解冻商户命令。
/// </summary>
public sealed record UnfreezeMerchantCommand : IRequest<bool>
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
/// <summary>
/// 并发控制版本PostgreSQL xmin
/// </summary>
public uint RowVersion { get; init; }
}

View File

@@ -18,21 +18,36 @@ public sealed record UpdateMerchantCommand : IRequest<UpdateMerchantResultDto?>
/// </summary>
public string? Name { get; init; }
/// <summary>
/// 品牌简称。
/// </summary>
public string? BrandAlias { get; init; }
/// <summary>
/// Logo URL。
/// </summary>
public string? LogoUrl { get; init; }
/// <summary>
/// 品类。
/// </summary>
public string? Category { get; init; }
/// <summary>
/// 营业执照号。
/// </summary>
public string? LicenseNumber { get; init; }
/// <summary>
/// 税号。
/// </summary>
public string? TaxNumber { get; init; }
/// <summary>
/// 法人或负责人。
/// </summary>
public string? LegalRepresentative { get; init; }
/// <summary>
/// 注册地址。
/// </summary>
public string? RegisteredAddress { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
@@ -44,7 +59,42 @@ public sealed record UpdateMerchantCommand : IRequest<UpdateMerchantResultDto?>
public string? ContactEmail { get; init; }
/// <summary>
/// 并发控制版本
/// 客服电话
/// </summary>
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
public string? ServicePhone { get; init; }
/// <summary>
/// 客服邮箱。
/// </summary>
public string? SupportEmail { get; init; }
/// <summary>
/// 省份。
/// </summary>
public string? Province { get; init; }
/// <summary>
/// 城市。
/// </summary>
public string? City { get; init; }
/// <summary>
/// 区县。
/// </summary>
public string? District { get; init; }
/// <summary>
/// 详细地址。
/// </summary>
public string? Address { get; init; }
/// <summary>
/// 并发控制版本PostgreSQL xmin
/// </summary>
public uint RowVersion { get; init; }
/// <summary>
/// 是否跳过敏感字段审核Admin 端设置为 true
/// </summary>
public bool SkipReview { get; init; }
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Domain.Common.Enums;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 更新商户经营模式命令。
/// </summary>
public sealed record UpdateMerchantOperatingModeCommand : IRequest<bool>
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
/// <summary>
/// 经营模式。
/// </summary>
public OperatingMode OperatingMode { get; init; }
/// <summary>
/// 并发控制版本PostgreSQL xmin
/// </summary>
public uint RowVersion { get; init; }
}

View File

@@ -28,10 +28,25 @@ public sealed class MerchantDetailDto
public string? TenantName { get; init; }
/// <summary>
/// 商户名称。
/// 商户名称(品牌名)
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 品牌简称。
/// </summary>
public string? BrandAlias { get; init; }
/// <summary>
/// Logo 地址。
/// </summary>
public string? LogoUrl { get; init; }
/// <summary>
/// 品类。
/// </summary>
public string? Category { get; init; }
/// <summary>
/// 经营模式。
/// </summary>
@@ -48,9 +63,29 @@ public sealed class MerchantDetailDto
public string? LegalRepresentative { get; init; }
/// <summary>
/// 注册地址
/// 税号
/// </summary>
public string? RegisteredAddress { get; init; }
public string? TaxNumber { get; init; }
/// <summary>
/// 省份。
/// </summary>
public string? Province { get; init; }
/// <summary>
/// 城市。
/// </summary>
public string? City { get; init; }
/// <summary>
/// 区县。
/// </summary>
public string? District { get; init; }
/// <summary>
/// 详细地址。
/// </summary>
public string? Address { get; init; }
/// <summary>
/// 联系电话。
@@ -62,6 +97,16 @@ public sealed class MerchantDetailDto
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 客服电话。
/// </summary>
public string? ServicePhone { get; init; }
/// <summary>
/// 客服邮箱。
/// </summary>
public string? SupportEmail { get; init; }
/// <summary>
/// 审核状态。
/// </summary>
@@ -99,9 +144,9 @@ public sealed class MerchantDetailDto
public IReadOnlyList<MerchantStoreDto> Stores { get; init; } = [];
/// <summary>
/// 并发控制版本。
/// 并发控制版本PostgreSQL xmin
/// </summary>
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
public uint RowVersion { get; init; }
/// <summary>
/// 创建时间。

View File

@@ -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;
/// <summary>
/// 创建商户命令处理器。
/// </summary>
public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger<CreateMerchantCommandHandler> logger)
public sealed class CreateMerchantCommandHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor,
ILogger<CreateMerchantCommandHandler> logger)
: IRequestHandler<CreateMerchantCommand, MerchantDto>
{
/// <inheritdoc />
public async Task<MerchantDto> 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);
}

View File

@@ -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;
/// <summary>
/// 冻结商户命令处理器。
/// </summary>
public sealed class FreezeMerchantCommandHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor,
ILogger<FreezeMerchantCommandHandler> logger)
: IRequestHandler<FreezeMerchantCommand, bool>
{
/// <inheritdoc />
public async Task<bool> 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);
}

View File

@@ -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;
/// <summary>
/// 解冻商户命令处理器。
/// </summary>
public sealed class UnfreezeMerchantCommandHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor,
ILogger<UnfreezeMerchantCommandHandler> logger)
: IRequestHandler<UnfreezeMerchantCommand, bool>
{
/// <inheritdoc />
public async Task<bool> 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);
}

View File

@@ -27,7 +27,7 @@ public sealed class UpdateMerchantCommandHandler(
/// <inheritdoc />
public async Task<UpdateMerchantResultDto?> 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<MerchantChangeLog>();
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;

View File

@@ -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;
/// <summary>
/// 更新商户经营模式命令处理器。
/// </summary>
public sealed class UpdateMerchantOperatingModeCommandHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor,
ILogger<UpdateMerchantOperatingModeCommandHandler> logger)
: IRequestHandler<UpdateMerchantOperatingModeCommand, bool>
{
/// <inheritdoc />
public async Task<bool> 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);
}

View File

@@ -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,

View File

@@ -15,12 +15,22 @@ public sealed class UpdateMerchantCommandValidator : AbstractValidator<UpdateMer
{
RuleFor(x => 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();
}
}

View File

@@ -1,24 +0,0 @@
using TakeoutSaaS.Application.Dictionary.Models;
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
/// <summary>
/// 字典缓存读写接口。
/// </summary>
public interface IDictionaryCache
{
/// <summary>
/// 获取缓存。
/// </summary>
Task<IReadOnlyList<DictionaryItemDto>?> GetAsync(long tenantId, string code, CancellationToken cancellationToken = default);
/// <summary>
/// 写入缓存。
/// </summary>
Task SetAsync(long tenantId, string code, IReadOnlyList<DictionaryItemDto> items, CancellationToken cancellationToken = default);
/// <summary>
/// 移除缓存。
/// </summary>
Task RemoveAsync(long tenantId, string code, CancellationToken cancellationToken = default);
}

View File

@@ -1,26 +0,0 @@
using TakeoutSaaS.Domain.Dictionary.Enums;
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
/// <summary>
/// 字典两级缓存访问接口。
/// </summary>
public interface IDictionaryHybridCache
{
/// <summary>
/// 读取缓存,不存在时通过工厂生成并回填。
/// </summary>
Task<T?> GetOrCreateAsync<T>(
string key,
TimeSpan ttl,
Func<CancellationToken, Task<T?>> factory,
CancellationToken cancellationToken = default);
/// <summary>
/// 按前缀失效缓存。
/// </summary>
Task InvalidateAsync(
string prefix,
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
CancellationToken cancellationToken = default);
}

View File

@@ -32,5 +32,5 @@ public sealed class UpdateDictionaryGroupRequest
/// <summary>
/// 行版本,用于并发控制。
/// </summary>
public byte[]? RowVersion { get; set; }
public uint RowVersion { get; set; }
}

View File

@@ -43,5 +43,5 @@ public sealed class UpdateDictionaryItemRequest
/// <summary>
/// 行版本,用于并发控制。
/// </summary>
public byte[]? RowVersion { get; set; }
public uint RowVersion { get; set; }
}

View File

@@ -64,7 +64,7 @@ public sealed class DictionaryGroupDto
/// <summary>
/// 并发控制字段。
/// </summary>
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
public uint RowVersion { get; init; }
/// <summary>
/// 字典项集合。

View File

@@ -59,5 +59,5 @@ public sealed class DictionaryItemDto
/// <summary>
/// 并发控制字段。
/// </summary>
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
public uint RowVersion { get; init; }
}

View File

@@ -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;
/// </summary>
public sealed class DictionaryAppService(
IDictionaryRepository repository,
IDictionaryCache cache,
ILogger<DictionaryAppService> logger) : IDictionaryAppService
{
/// <summary>
@@ -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);
}
/// <summary>
/// 批量获取缓存中的字典项。
/// 批量获取字典项。
/// </summary>
/// <param name="request">批量查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
@@ -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<IReadOnlyList<DictionaryItemDto>> LoadItemsAsync(long tenantId, string code, CancellationToken cancellationToken)
{
await cache.RemoveAsync(group.TenantId, group.Code, cancellationToken);
if (group.Scope == DictionaryScope.Business)
{
return;
}
// 系统参数更新需要逐租户重新合并,由调用方在下一次请求时重新加载
}
private async Task<IReadOnlyList<DictionaryItemDto>> 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<DictionaryItemDto> MergeItems(IReadOnlyList<DictionaryItemDto> systemItems, IReadOnlyList<DictionaryItemDto> tenantItems)

View File

@@ -1,44 +0,0 @@
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 字典缓存键生成器。
/// </summary>
internal static class DictionaryCacheKeys
{
internal const string DictionaryPrefix = "dict:";
internal const string GroupPrefix = "dict:groups:";
internal const string ItemPrefix = "dict:items:";
internal static string BuildDictionaryKey(long tenantId, DictionaryCode code)
=> $"{DictionaryPrefix}{tenantId}:{code.Value}";
internal static string BuildGroupKey(
long tenantId,
DictionaryScope scope,
int page,
int pageSize,
string? keyword,
bool? isEnabled,
string? sortBy,
bool sortDescending)
{
return $"{GroupPrefix}{tenantId}:{scope}:{page}:{pageSize}:{Normalize(keyword)}:{Normalize(isEnabled)}:{Normalize(sortBy)}:{(sortDescending ? "desc" : "asc")}";
}
internal static string BuildGroupPrefix(long tenantId)
=> $"{GroupPrefix}{tenantId}:";
internal static string BuildItemKey(long groupId)
=> $"{ItemPrefix}{groupId}";
private static string Normalize(string? value)
=> string.IsNullOrWhiteSpace(value)
? "all"
: value.Trim().ToLowerInvariant().Replace(":", "_", StringComparison.Ordinal);
private static string Normalize(bool? value)
=> value.HasValue ? (value.Value ? "1" : "0") : "all";
}

View File

@@ -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<DictionaryCommandService> logger)
{
/// <summary>
@@ -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<Task>
{
cache.InvalidateAsync(DictionaryCacheKeys.BuildGroupPrefix(group.TenantId), operation, cancellationToken),
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), operation, cancellationToken),
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), operation, cancellationToken)
};
if (group.Scope == DictionaryScope.System)
{
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, operation, cancellationToken));
}
return Task.WhenAll(tasks);
}
private Task InvalidateItemCacheAsync(
DictionaryGroup group,
CacheInvalidationOperation operation,
CancellationToken cancellationToken)
{
var tasks = new List<Task>
{
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), operation, cancellationToken),
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), operation, cancellationToken)
};
if (group.Scope == DictionaryScope.System)
{
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, operation, cancellationToken));
}
return Task.WhenAll(tasks);
}
private static bool IsConcurrencyException(Exception exception)
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
}

View File

@@ -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<DictionaryImportExportService> 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<Task>
{
cache.InvalidateAsync(DictionaryCacheKeys.BuildGroupPrefix(group.TenantId), CacheInvalidationOperation.Update, cancellationToken),
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), CacheInvalidationOperation.Update, cancellationToken),
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), CacheInvalidationOperation.Update, cancellationToken)
};
if (group.Scope == DictionaryScope.System)
{
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, CacheInvalidationOperation.Update, cancellationToken));
}
await Task.WhenAll(tasks);
}
private async Task<DictionaryGroup> RequireGroupAsync(long groupId, CancellationToken cancellationToken)
{
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);

View File

@@ -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);
}

View File

@@ -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;
/// </summary>
public sealed class DictionaryQueryService(
IDictionaryGroupRepository groupRepository,
IDictionaryItemRepository itemRepository,
IDictionaryHybridCache cache)
IDictionaryItemRepository itemRepository)
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30);
/// <summary>
/// 获取字典分组分页数据。
/// </summary>
@@ -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<DictionaryGroupPage>(
cacheKey,
CacheTtl,
async token =>
{
var groups = await groupRepository.GetPagedAsync(
targetTenant,
scope,
query.Keyword,
query.IsEnabled,
query.Page,
query.PageSize,
query.SortBy,
sortDescending,
token);
var total = await groupRepository.CountAsync(
targetTenant,
scope,
query.Keyword,
query.IsEnabled,
token);
var items = new List<DictionaryGroupDto>(groups.Count);
foreach (var group in groups)
{
IReadOnlyList<DictionaryItemDto>? groupItems = null;
if (query.IncludeItems)
{
var groupItemEntities = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, token);
groupItems = groupItemEntities
.Where(item => item.IsEnabled)
.OrderBy(item => item.SortOrder)
.Select(DictionaryMapper.ToItemDto)
.ToList();
}
items.Add(DictionaryMapper.ToGroupDto(group, groupItems));
}
return new DictionaryGroupPage
{
Items = items,
Page = query.Page,
PageSize = query.PageSize,
TotalCount = total
};
},
sortDescending,
cancellationToken);
var page = cached ?? new DictionaryGroupPage
{
Items = Array.Empty<DictionaryGroupDto>(),
Page = query.Page,
PageSize = query.PageSize,
TotalCount = 0
};
var total = await groupRepository.CountAsync(
targetTenant,
scope,
query.Keyword,
query.IsEnabled,
cancellationToken);
return new PagedResult<DictionaryGroupDto>(page.Items, page.Page, page.PageSize, page.TotalCount);
// 4. 转换为 DTO
var items = new List<DictionaryGroupDto>(groups.Count);
foreach (var group in groups)
{
IReadOnlyList<DictionaryItemDto>? groupItems = null;
if (query.IncludeItems)
{
var groupItemEntities = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
groupItems = groupItemEntities
.Where(item => item.IsEnabled)
.OrderBy(item => item.SortOrder)
.Select(DictionaryMapper.ToItemDto)
.ToList();
}
items.Add(DictionaryMapper.ToGroupDto(group, groupItems));
}
return new PagedResult<DictionaryGroupDto>(items, query.Page, query.PageSize, total);
}
/// <summary>
@@ -136,28 +99,18 @@ public sealed class DictionaryQueryService(
/// </summary>
public async Task<IReadOnlyList<DictionaryItemDto>> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default)
{
var cacheKey = DictionaryCacheKeys.BuildItemKey(groupId);
var cached = await cache.GetOrCreateAsync<IReadOnlyList<DictionaryItemDto>>(
cacheKey,
CacheTtl,
async token =>
{
var group = await groupRepository.GetByIdAsync(groupId, token);
if (group == null)
{
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
}
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<DictionaryItemDto>();
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, groupId, cancellationToken);
return items
.Where(item => item.IsEnabled)
.OrderBy(item => item.SortOrder)
.Select(DictionaryMapper.ToItemDto)
.ToList();
}
/// <summary>
@@ -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<IReadOnlyList<DictionaryItemDto>>(
cacheKey,
CacheTtl,
async token =>
{
var systemGroup = await groupRepository.GetByCodeAsync(0, normalized, token);
if (systemGroup == null || !systemGroup.IsEnabled)
{
return Array.Empty<DictionaryItemDto>();
}
var systemGroup = await groupRepository.GetByCodeAsync(0, normalized, cancellationToken);
if (systemGroup == null || !systemGroup.IsEnabled)
{
return Array.Empty<DictionaryItemDto>();
}
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<DictionaryItemDto>();
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, cancellationToken);
return systemItems
.Where(item => item.IsEnabled)
.OrderBy(item => item.SortOrder)
.Select(DictionaryMapper.ToItemDto)
.ToList();
}
/// <summary>
@@ -230,12 +172,4 @@ public sealed class DictionaryQueryService(
return result;
}
private sealed class DictionaryGroupPage
{
public IReadOnlyList<DictionaryGroupDto> Items { get; init; } = Array.Empty<DictionaryGroupDto>();
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalCount { get; init; }
}
}

View File

@@ -48,6 +48,5 @@ public sealed record UpdateIdentityUserCommand : IRequest<UserDetailDto?>
/// 并发控制版本。
/// </summary>
[Required]
[MinLength(1)]
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
public uint RowVersion { get; init; }
}

View File

@@ -95,5 +95,5 @@ public sealed record UserDetailDto
/// <summary>
/// 并发控制版本。
/// </summary>
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
public uint RowVersion { get; init; }
}

View File

@@ -40,9 +40,9 @@ public sealed class DictionaryGroup : MultiTenantEntityBase
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 并发控制字段。
/// 并发控制字段(映射到 PostgreSQL xmin
/// </summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public uint RowVersion { get; set; }
/// <summary>
/// 字典项集合。

View File

@@ -43,9 +43,9 @@ public sealed class DictionaryItem : MultiTenantEntityBase
public string? Description { get; set; }
/// <summary>
/// 并发控制字段。
/// 并发控制字段(映射到 PostgreSQL xmin
/// </summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public uint RowVersion { get; set; }
/// <summary>
/// 导航属性:所属分组。

View File

@@ -79,7 +79,7 @@ public sealed class IdentityUser : AuditableEntityBase
public string? Avatar { get; set; }
/// <summary>
/// 并发控制字段。
/// 并发控制字段(映射到 PostgreSQL xmin
/// </summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public uint RowVersion { get; set; }
}

View File

@@ -45,8 +45,7 @@ public sealed class InventoryBatch : MultiTenantEntityBase
public int RemainingQuantity { get; set; }
/// <summary>
/// 并发控制字段。
/// 并发控制字段(映射到 PostgreSQL xmin
/// </summary>
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public uint RowVersion { get; set; }
}

View File

@@ -91,8 +91,7 @@ public sealed class InventoryItem : MultiTenantEntityBase
public InventoryBatchConsumeStrategy BatchConsumeStrategy { get; set; } = InventoryBatchConsumeStrategy.Fifo;
/// <summary>
/// 并发控制字段。
/// 并发控制字段(映射到 PostgreSQL xmin
/// </summary>
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public uint RowVersion { get; set; }
}

View File

@@ -45,8 +45,7 @@ public sealed class InventoryLockRecord : MultiTenantEntityBase
public InventoryLockStatus Status { get; set; } = InventoryLockStatus.Locked;
/// <summary>
/// 并发控制字段。
/// 并发控制字段(映射到 PostgreSQL xmin
/// </summary>
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public uint RowVersion { get; set; }
}

View File

@@ -175,7 +175,7 @@ public sealed class Merchant : MultiTenantEntityBase
public DateTime? ClaimExpiresAt { get; set; }
/// <summary>
/// 并发控制版本。
/// 并发控制版本(映射到 PostgreSQL xmin
/// </summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public uint RowVersion { get; set; }
}

View File

@@ -68,5 +68,15 @@ public enum MerchantAuditAction
/// <summary>
/// 强制接管审核。
/// </summary>
ReviewForceClaimed = 12
ReviewForceClaimed = 12,
/// <summary>
/// 商户冻结。
/// </summary>
Frozen = 13,
/// <summary>
/// 商户解冻。
/// </summary>
Unfrozen = 14
}

View File

@@ -34,8 +34,7 @@ public sealed class StorePickupSetting : MultiTenantEntityBase
public int? MaxQuantityPerOrder { get; set; }
/// <summary>
/// 并发控制字段。
/// 并发控制字段(映射到 PostgreSQL xmin
/// </summary>
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public uint RowVersion { get; set; }
}

View File

@@ -54,8 +54,7 @@ public sealed class StorePickupSlot : MultiTenantEntityBase
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 并发控制字段。
/// 并发控制字段(映射到 PostgreSQL xmin
/// </summary>
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public uint RowVersion { get; set; }
}

View File

@@ -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<int>();
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 });

View File

@@ -1,212 +0,0 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
/// <summary>
/// 缓存命中/耗时指标采集器。
/// </summary>
public sealed class CacheMetricsCollector
{
private const string MeterName = "TakeoutSaaS.DictionaryCache";
private static readonly Meter Meter = new(MeterName, "1.0.0");
private readonly Counter<long> _hitCounter;
private readonly Counter<long> _missCounter;
private readonly Counter<long> _invalidationCounter;
private readonly Histogram<double> _durationHistogram;
private readonly ConcurrentQueue<CacheQueryRecord> _queries = new();
private readonly TimeSpan _retention = TimeSpan.FromDays(7);
private long _hitTotal;
private long _missTotal;
/// <summary>
/// 初始化指标采集器。
/// </summary>
public CacheMetricsCollector()
{
_hitCounter = Meter.CreateCounter<long>("cache_hit_count");
_missCounter = Meter.CreateCounter<long>("cache_miss_count");
_invalidationCounter = Meter.CreateCounter<long>("cache_invalidation_count");
_durationHistogram = Meter.CreateHistogram<double>("cache_query_duration_ms");
Meter.CreateObservableGauge(
"cache_hit_ratio",
() => new Measurement<double>(CalculateHitRatio()));
}
/// <summary>
/// 记录缓存命中。
/// </summary>
public void RecordHit(string cacheLevel, string dictionaryCode)
{
Interlocked.Increment(ref _hitTotal);
_hitCounter.Add(1, new TagList
{
{ "cache_level", cacheLevel },
{ "dictionary_code", NormalizeCode(dictionaryCode) }
});
}
/// <summary>
/// 记录缓存未命中。
/// </summary>
public void RecordMiss(string cacheLevel, string dictionaryCode)
{
Interlocked.Increment(ref _missTotal);
_missCounter.Add(1, new TagList
{
{ "cache_level", cacheLevel },
{ "dictionary_code", NormalizeCode(dictionaryCode) }
});
}
/// <summary>
/// 记录缓存查询耗时。
/// </summary>
public void RecordDuration(string dictionaryCode, double durationMs)
{
_durationHistogram.Record(durationMs, new TagList
{
{ "dictionary_code", NormalizeCode(dictionaryCode) }
});
}
/// <summary>
/// 记录查询详情,用于统计窗口分析。
/// </summary>
public void RecordQuery(string dictionaryCode, bool l1Hit, bool l2Hit, double durationMs)
{
var record = new CacheQueryRecord(DateTime.UtcNow, NormalizeCode(dictionaryCode), l1Hit, l2Hit, durationMs);
_queries.Enqueue(record);
PruneOldRecords();
}
/// <summary>
/// 记录缓存失效事件。
/// </summary>
public void RecordInvalidation(string dictionaryCode)
{
_invalidationCounter.Add(1, new TagList
{
{ "dictionary_code", NormalizeCode(dictionaryCode) }
});
}
/// <summary>
/// 获取指定时间范围内的统计快照。
/// </summary>
public CacheStatsSnapshot GetSnapshot(TimeSpan window)
{
var since = DateTime.UtcNow.Subtract(window);
var records = _queries.Where(record => record.Timestamp >= since).ToList();
var l1Hits = records.Count(record => record.L1Hit);
var l1Misses = records.Count(record => !record.L1Hit);
var l2Hits = records.Count(record => record.L2Hit);
var l2Misses = records.Count(record => !record.L1Hit && !record.L2Hit);
var totalHits = l1Hits + l2Hits;
var totalMisses = l1Misses + l2Misses;
var hitRatio = totalHits + totalMisses == 0 ? 0 : totalHits / (double)(totalHits + totalMisses);
var averageDuration = records.Count == 0 ? 0 : records.Average(record => record.DurationMs);
var topQueried = records
.GroupBy(record => record.DictionaryCode)
.Select(group => new DictionaryQueryCount(group.Key, group.Count()))
.OrderByDescending(item => item.QueryCount)
.Take(5)
.ToList();
return new CacheStatsSnapshot(
totalHits,
totalMisses,
hitRatio,
new CacheLevelStats(l1Hits, l2Hits),
new CacheLevelStats(l1Misses, l2Misses),
averageDuration,
topQueried);
}
/// <summary>
/// 从缓存键解析字典编码。
/// </summary>
public static string ExtractDictionaryCode(string cacheKey)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
return "unknown";
}
if (cacheKey.StartsWith("dict:groups:", StringComparison.Ordinal))
{
return "groups";
}
if (cacheKey.StartsWith("dict:items:", StringComparison.Ordinal))
{
return "items";
}
if (cacheKey.StartsWith("dict:", StringComparison.Ordinal))
{
var parts = cacheKey.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 3)
{
return parts[2];
}
}
return "unknown";
}
private static string NormalizeCode(string? code)
=> string.IsNullOrWhiteSpace(code) ? "unknown" : code.Trim().ToLowerInvariant();
private double CalculateHitRatio()
{
var hits = Interlocked.Read(ref _hitTotal);
var misses = Interlocked.Read(ref _missTotal);
return hits + misses == 0 ? 0 : hits / (double)(hits + misses);
}
private void PruneOldRecords()
{
var cutoff = DateTime.UtcNow.Subtract(_retention);
while (_queries.TryPeek(out var record) && record.Timestamp < cutoff)
{
_queries.TryDequeue(out _);
}
}
private sealed record CacheQueryRecord(
DateTime Timestamp,
string DictionaryCode,
bool L1Hit,
bool L2Hit,
double DurationMs);
}
/// <summary>
/// 缓存统计快照。
/// </summary>
public sealed record CacheStatsSnapshot(
long TotalHits,
long TotalMisses,
double HitRatio,
CacheLevelStats HitsByLevel,
CacheLevelStats MissesByLevel,
double AverageQueryDurationMs,
IReadOnlyList<DictionaryQueryCount> TopQueriedDictionaries);
/// <summary>
/// 命中统计。
/// </summary>
public sealed record CacheLevelStats(long L1, long L2);
/// <summary>
/// 字典查询次数统计。
/// </summary>
public sealed record DictionaryQueryCount(string Code, int QueryCount);

View File

@@ -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;
/// <summary>
/// 字典缓存预热服务。
/// </summary>
public sealed class CacheWarmupService(
IServiceScopeFactory scopeFactory,
IOptions<DictionaryCacheWarmupOptions> options,
ILogger<CacheWarmupService> logger) : IHostedService
{
private const int MaxWarmupCount = 10;
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
var codes = options.Value.DictionaryCodes
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxWarmupCount)
.ToArray();
if (codes.Length == 0)
{
logger.LogInformation("未配置字典缓存预热列表。");
return;
}
using var scope = scopeFactory.CreateScope();
var queryService = scope.ServiceProvider.GetRequiredService<DictionaryQueryService>();
foreach (var code in codes)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await queryService.GetMergedDictionaryAsync(code, cancellationToken);
logger.LogInformation("字典缓存预热完成: {DictionaryCode}", code);
}
catch (Exception ex)
{
logger.LogWarning(ex, "字典缓存预热失败: {DictionaryCode}", code);
}
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -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;
/// <summary>
/// 两级缓存封装L1 内存 + L2 Redis。
/// </summary>
public sealed class HybridCacheService : IDictionaryHybridCache
{
private static readonly RedisChannel InvalidationChannel = RedisChannel.Literal("dictionary:cache:invalidate");
private readonly MemoryCacheService _memoryCache;
private readonly RedisCacheService _redisCache;
private readonly ISubscriber? _subscriber;
private readonly ILogger<HybridCacheService>? _logger;
private readonly CacheMetricsCollector? _metrics;
private readonly IServiceScopeFactory? _scopeFactory;
/// <summary>
/// 初始化两级缓存服务。
/// </summary>
public HybridCacheService(
MemoryCacheService memoryCache,
RedisCacheService redisCache,
IConnectionMultiplexer? multiplexer = null,
ILogger<HybridCacheService>? logger = null,
CacheMetricsCollector? metrics = null,
IServiceScopeFactory? scopeFactory = null)
{
_memoryCache = memoryCache;
_redisCache = redisCache;
_logger = logger;
_subscriber = multiplexer?.GetSubscriber();
_metrics = metrics;
_scopeFactory = scopeFactory;
if (_subscriber != null)
{
_subscriber.Subscribe(InvalidationChannel, (_, value) =>
{
var prefix = value.ToString();
if (!string.IsNullOrWhiteSpace(prefix))
{
_memoryCache.RemoveByPrefix(prefix);
}
});
}
}
/// <summary>
/// 获取缓存,如果不存在则创建并回填。
/// </summary>
public async Task<T?> GetOrCreateAsync<T>(
string key,
TimeSpan ttl,
Func<CancellationToken, Task<T?>> factory,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(key);
var l1Hit = false;
var l2Hit = false;
var cached = await _memoryCache.GetAsync<T>(key, cancellationToken);
if (cached != null)
{
l1Hit = true;
_metrics?.RecordHit("L1", dictionaryCode);
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
return cached;
}
_metrics?.RecordMiss("L1", dictionaryCode);
try
{
cached = await _redisCache.GetAsync<T>(key, cancellationToken);
if (cached != null)
{
l2Hit = true;
_metrics?.RecordHit("L2", dictionaryCode);
await _memoryCache.SetAsync(key, cached, ttl, cancellationToken);
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
return cached;
}
_metrics?.RecordMiss("L2", dictionaryCode);
}
catch (Exception ex)
{
_metrics?.RecordMiss("L2", dictionaryCode);
_logger?.LogWarning(ex, "读取 Redis 缓存失败,降级为数据库查询。");
}
var created = await factory(cancellationToken);
if (created == null)
{
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
return default;
}
await _memoryCache.SetAsync(key, created, ttl, cancellationToken);
try
{
await _redisCache.SetAsync(key, created, ttl, cancellationToken);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "写入 Redis 缓存失败。");
}
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
return created;
}
/// <summary>
/// 失效指定前缀的缓存键。
/// </summary>
public async Task InvalidateAsync(
string prefix,
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
CancellationToken cancellationToken = default)
{
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(prefix);
_metrics?.RecordInvalidation(dictionaryCode);
var removedCount = _memoryCache.RemoveByPrefixWithCount(prefix);
long redisRemoved = 0;
try
{
redisRemoved = await _redisCache.RemoveByPrefixWithCountAsync(prefix, cancellationToken);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "删除 Redis 缓存失败。");
}
var totalRemoved = removedCount + (int)Math.Min(redisRemoved, int.MaxValue);
if (_subscriber != null && !string.IsNullOrWhiteSpace(prefix))
{
await _subscriber.PublishAsync(InvalidationChannel, prefix);
}
_ = WriteInvalidationLogAsync(prefix, dictionaryCode, totalRemoved, operation);
}
private async Task WriteInvalidationLogAsync(
string prefix,
string dictionaryCode,
int removedCount,
CacheInvalidationOperation operation)
{
if (_scopeFactory == null)
{
return;
}
try
{
using var scope = _scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetService<ICacheInvalidationLogRepository>();
if (repo == null)
{
return;
}
var currentUser = scope.ServiceProvider.GetService<ICurrentUserAccessor>();
var tenantId = TryExtractTenantId(prefix) ?? 0;
var scopeType = tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business;
var log = new CacheInvalidationLog
{
TenantId = tenantId,
Timestamp = DateTime.UtcNow,
DictionaryCode = dictionaryCode,
Scope = scopeType,
AffectedCacheKeyCount = removedCount,
OperatorId = currentUser?.IsAuthenticated == true ? currentUser.UserId : 0,
Operation = operation
};
await repo.AddAsync(log);
await repo.SaveChangesAsync();
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "写入缓存失效日志失败。");
}
}
private static long? TryExtractTenantId(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
{
return null;
}
if (prefix.StartsWith("dict:groups:", StringComparison.Ordinal))
{
var token = prefix.Replace("dict:groups:", string.Empty, StringComparison.Ordinal).Trim(':');
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
? tenantId
: null;
}
if (prefix.StartsWith("dict:", StringComparison.Ordinal) && !prefix.StartsWith("dict:items:", StringComparison.Ordinal))
{
var token = prefix.Replace("dict:", string.Empty, StringComparison.Ordinal);
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
? tenantId
: null;
}
return null;
}
}

View File

@@ -1,82 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using System.Collections.Concurrent;
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
/// <summary>
/// 本地内存缓存封装。
/// </summary>
public sealed class MemoryCacheService(IMemoryCache cache)
{
private readonly ConcurrentDictionary<string, byte> _keys = new(StringComparer.Ordinal);
/// <summary>
/// 读取缓存。
/// </summary>
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
return Task.FromResult(cache.TryGetValue(key, out T? value) ? value : default);
}
/// <summary>
/// 写入缓存。
/// </summary>
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
{
cache.Set(key, value, new MemoryCacheEntryOptions
{
SlidingExpiration = ttl
});
_keys.TryAdd(key, 0);
return Task.CompletedTask;
}
/// <summary>
/// 删除缓存键。
/// </summary>
public void Remove(string key)
{
cache.Remove(key);
_keys.TryRemove(key, out _);
}
/// <summary>
/// 按前缀删除缓存键。
/// </summary>
public void RemoveByPrefix(string prefix)
=> RemoveByPrefixWithCount(prefix);
/// <summary>
/// 按前缀删除缓存键并返回数量。
/// </summary>
public int RemoveByPrefixWithCount(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
{
return 0;
}
var removed = 0;
foreach (var key in _keys.Keys)
{
if (key.StartsWith(prefix, StringComparison.Ordinal))
{
Remove(key);
removed += 1;
}
}
return removed;
}
/// <summary>
/// 清理所有缓存。
/// </summary>
public void Clear()
{
foreach (var key in _keys.Keys)
{
Remove(key);
}
}
}

View File

@@ -1,79 +0,0 @@
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
using StackExchange.Redis;
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
/// <summary>
/// Redis 缓存访问封装。
/// </summary>
public sealed class RedisCacheService(IDistributedCache cache, IConnectionMultiplexer? multiplexer = null)
{
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
private readonly IDatabase? _database = multiplexer?.GetDatabase();
private readonly IConnectionMultiplexer? _multiplexer = multiplexer;
/// <summary>
/// 读取缓存。
/// </summary>
public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
var payload = await cache.GetAsync(key, cancellationToken);
if (payload == null || payload.Length == 0)
{
return default;
}
return JsonSerializer.Deserialize<T>(payload, _serializerOptions);
}
/// <summary>
/// 写入缓存。
/// </summary>
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(value, _serializerOptions);
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl
};
return cache.SetAsync(key, payload, options, cancellationToken);
}
/// <summary>
/// 删除缓存键。
/// </summary>
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
=> cache.RemoveAsync(key, cancellationToken);
/// <summary>
/// 按前缀删除缓存键。
/// </summary>
public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default)
=> await RemoveByPrefixWithCountAsync(prefix, cancellationToken).ConfigureAwait(false);
/// <summary>
/// 按前缀删除缓存键并返回数量。
/// </summary>
public async Task<long> RemoveByPrefixWithCountAsync(string prefix, CancellationToken cancellationToken = default)
{
if (_multiplexer == null || _database == null || string.IsNullOrWhiteSpace(prefix))
{
return 0;
}
var pattern = prefix.EndsWith('*') ? prefix : $"{prefix}*";
long removed = 0;
foreach (var endpoint in _multiplexer.GetEndPoints())
{
var server = _multiplexer.GetServer(endpoint);
foreach (var key in server.Keys(pattern: pattern))
{
await _database.KeyDeleteAsync(key).ConfigureAwait(false);
removed += 1;
}
}
return removed;
}
}

View File

@@ -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
/// <exception cref="InvalidOperationException">缺少数据库配置时抛出。</exception>
public static IServiceCollection AddDictionaryInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
// 1. 注册数据库上下文
services.AddDatabaseInfrastructure(configuration);
services.AddPostgresDbContext<DictionaryDbContext>(DatabaseConstants.DictionaryDataSource);
// 2. 注册仓储
services.AddScoped<IDictionaryRepository, EfDictionaryRepository>();
services.AddScoped<IDictionaryGroupRepository, DictionaryGroupRepository>();
services.AddScoped<IDictionaryItemRepository, DictionaryItemRepository>();
@@ -43,69 +38,11 @@ public static class DictionaryServiceCollectionExtensions
services.AddScoped<IDictionaryImportLogRepository, DictionaryImportLogRepository>();
services.AddScoped<ICacheInvalidationLogRepository, CacheInvalidationLogRepository>();
services.AddScoped<ISystemParameterRepository, EfSystemParameterRepository>();
services.AddScoped<IDictionaryCache, DistributedDictionaryCache>();
// 3. 注册导入导出解析器
services.AddScoped<ICsvDictionaryParser, CsvDictionaryParser>();
services.AddScoped<IJsonDictionaryParser, JsonDictionaryParser>();
services.AddMemoryCache();
var redisConnection = configuration.GetConnectionString("Redis");
var hasDistributedCache = services.Any(descriptor => descriptor.ServiceType == typeof(IDistributedCache));
if (!hasDistributedCache)
{
if (!string.IsNullOrWhiteSpace(redisConnection))
{
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = redisConnection;
});
}
else
{
services.AddDistributedMemoryCache();
}
}
if (!string.IsNullOrWhiteSpace(redisConnection) && !services.Any(descriptor => descriptor.ServiceType == typeof(IConnectionMultiplexer)))
{
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(redisConnection));
}
services.AddSingleton<MemoryCacheService>();
services.AddSingleton<CacheMetricsCollector>();
services.AddSingleton(sp => new RedisCacheService(
sp.GetRequiredService<IDistributedCache>(),
sp.GetService<IConnectionMultiplexer>()));
services.AddSingleton(sp => new HybridCacheService(
sp.GetRequiredService<MemoryCacheService>(),
sp.GetRequiredService<RedisCacheService>(),
sp.GetService<IConnectionMultiplexer>(),
sp.GetService<ILogger<HybridCacheService>>(),
sp.GetService<CacheMetricsCollector>(),
sp.GetService<IServiceScopeFactory>()));
services.AddSingleton<IDictionaryHybridCache>(sp => sp.GetRequiredService<HybridCacheService>());
services.AddOptions<DictionaryCacheOptions>()
.Bind(configuration.GetSection("Dictionary:Cache"))
.ValidateDataAnnotations();
services.AddOptions<DictionaryCacheWarmupOptions>()
.Bind(configuration.GetSection("CacheWarmup"))
.ValidateDataAnnotations();
services.AddHostedService<CacheWarmupService>();
return services;
}
/// <summary>
/// 确保数据库连接已配置Database 节或 ConnectionStrings
/// </summary>
/// <param name="configuration">配置源。</param>
/// <param name="dataSourceName">数据源名称。</param>
/// <exception cref="InvalidOperationException">未配置时抛出。</exception>
private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName)
{
// 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。
}
}

View File

@@ -1,12 +0,0 @@
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
/// <summary>
/// 字典缓存配置。
/// </summary>
public sealed class DictionaryCacheOptions
{
/// <summary>
/// 缓存滑动过期时间。
/// </summary>
public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(30);
}

View File

@@ -1,12 +0,0 @@
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
/// <summary>
/// 字典缓存预热配置。
/// </summary>
public sealed class DictionaryCacheWarmupOptions
{
/// <summary>
/// 预热字典编码列表(最多前 10 个)。
/// </summary>
public string[] DictionaryCodes { get; set; } = Array.Empty<string>();
}

View File

@@ -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<DictionaryGroup>(), isSqlite);
ConfigureItem(modelBuilder.Entity<DictionaryItem>(), isSqlite);
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>());
ConfigureItem(modelBuilder.Entity<DictionaryItem>());
ConfigureOverride(modelBuilder.Entity<TenantDictionaryOverride>());
ConfigureLabelOverride(modelBuilder.Entity<DictionaryLabelOverride>());
ConfigureImportLog(modelBuilder.Entity<DictionaryImportLog>());
@@ -75,7 +73,7 @@ public sealed class DictionaryDbContext(
/// 配置字典分组。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder, bool isSqlite)
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> 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(
/// 配置字典项。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder, bool isSqlite)
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> 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)

View File

@@ -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;
}

View File

@@ -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;
/// <summary>
/// 基于 IDistributedCache 的字典缓存实现。
/// </summary>
public sealed class DistributedDictionaryCache(IDistributedCache cache, IOptions<DictionaryCacheOptions> options) : IDictionaryCache
{
private readonly DictionaryCacheOptions _options = options.Value;
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// 读取指定租户与编码的字典缓存。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="code">字典编码。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>字典项集合或 null。</returns>
public async Task<IReadOnlyList<DictionaryItemDto>?> 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<List<DictionaryItemDto>>(payload, _serializerOptions);
}
/// <summary>
/// 设置指定租户与编码的字典缓存。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="code">字典编码。</param>
/// <param name="items">字典项集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public Task SetAsync(long tenantId, string code, IReadOnlyList<DictionaryItemDto> 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);
}
/// <summary>
/// 移除指定租户与编码的缓存。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="code">字典编码。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
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()}";
}

View File

@@ -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<int>().IsRequired();
builder.Property(x => x.TenantId);
ConfigureAuditableEntity(builder);