feat: 提交后端其余改动

This commit is contained in:
2026-01-01 07:41:57 +08:00
parent fc55003d3d
commit aa42a635e4
34 changed files with 2426 additions and 1208 deletions

View File

@@ -19,6 +19,7 @@ public static class DictionaryServiceCollectionExtensions
services.AddScoped<DictionaryQueryService>();
services.AddScoped<DictionaryMergeService>();
services.AddScoped<DictionaryOverrideService>();
services.AddScoped<DictionaryLabelOverrideService>();
services.AddScoped<DictionaryImportExportService>();
return services;
}

View File

@@ -0,0 +1,119 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.Dictionary.Models;
/// <summary>
/// 字典标签覆盖 DTO。
/// </summary>
public sealed class LabelOverrideDto
{
/// <summary>
/// 覆盖记录 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 被覆盖的字典项 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long DictionaryItemId { get; init; }
/// <summary>
/// 字典项 Key。
/// </summary>
public string DictionaryItemKey { get; init; } = string.Empty;
/// <summary>
/// 原始显示值(多语言)。
/// </summary>
public Dictionary<string, string> OriginalValue { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 覆盖后的显示值(多语言)。
/// </summary>
public Dictionary<string, string> OverrideValue { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 覆盖类型。
/// </summary>
public OverrideType OverrideType { get; init; }
/// <summary>
/// 覆盖类型名称。
/// </summary>
public string OverrideTypeName => OverrideType switch
{
OverrideType.TenantCustomization => "租户定制",
OverrideType.PlatformEnforcement => "平台强制",
_ => "未知"
};
/// <summary>
/// 覆盖原因/备注。
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
/// <summary>
/// 创建人 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long? CreatedBy { get; init; }
/// <summary>
/// 更新人 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long? UpdatedBy { get; init; }
}
/// <summary>
/// 创建/更新标签覆盖请求。
/// </summary>
public sealed class UpsertLabelOverrideRequest
{
/// <summary>
/// 被覆盖的字典项 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long DictionaryItemId { get; init; }
/// <summary>
/// 覆盖后的显示值(多语言)。
/// </summary>
public Dictionary<string, string> OverrideValue { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 覆盖原因/备注(平台强制覆盖时建议填写)。
/// </summary>
public string? Reason { get; init; }
}
/// <summary>
/// 批量覆盖请求。
/// </summary>
public sealed class BatchLabelOverrideRequest
{
/// <summary>
/// 覆盖项列表。
/// </summary>
public List<UpsertLabelOverrideRequest> Items { get; init; } = new();
}

View File

@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Http;
namespace TakeoutSaaS.Application.Dictionary.Services;
internal static class DictionaryAccessHelper
{
internal static bool IsPlatformAdmin(IHttpContextAccessor httpContextAccessor)
{
var user = httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
{
return false;
}
return user.IsInRole("PlatformAdmin") ||
user.IsInRole("platform-admin") ||
user.IsInRole("super-admin") ||
user.IsInRole("SUPER_ADMIN");
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Http;
using System.Security.Cryptography;
using TakeoutSaaS.Application.Dictionary.Abstractions;
using TakeoutSaaS.Application.Dictionary.Contracts;
@@ -19,6 +20,7 @@ public sealed class DictionaryAppService(
IDictionaryRepository repository,
IDictionaryCache cache,
ITenantProvider tenantProvider,
IHttpContextAccessor httpContextAccessor,
ILogger<DictionaryAppService> logger) : IDictionaryAppService
{
/// <summary>
@@ -354,7 +356,7 @@ public sealed class DictionaryAppService(
private void EnsureScopePermission(DictionaryScope scope)
{
var tenantId = tenantProvider.GetCurrentTenantId();
if (scope == DictionaryScope.System && tenantId != 0)
if (scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
{
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
}
@@ -362,7 +364,7 @@ public sealed class DictionaryAppService(
private void EnsurePlatformTenant(long tenantId)
{
if (tenantId != 0)
if (tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
{
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
}

View File

@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Http;
using TakeoutSaaS.Application.Dictionary.Abstractions;
using TakeoutSaaS.Application.Dictionary.Contracts;
using TakeoutSaaS.Application.Dictionary.Models;
@@ -21,6 +22,7 @@ public sealed class DictionaryCommandService(
IDictionaryItemRepository itemRepository,
IDictionaryHybridCache cache,
ITenantProvider tenantProvider,
IHttpContextAccessor httpContextAccessor,
ILogger<DictionaryCommandService> logger)
{
/// <summary>
@@ -229,7 +231,7 @@ public sealed class DictionaryCommandService(
var tenantId = tenantProvider.GetCurrentTenantId();
if (scope == DictionaryScope.System)
{
if (tenantId != 0)
if (tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
{
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可创建系统字典");
}
@@ -248,7 +250,7 @@ public sealed class DictionaryCommandService(
private void EnsureGroupAccess(DictionaryGroup group)
{
var tenantId = tenantProvider.GetCurrentTenantId();
if (group.Scope == DictionaryScope.System && tenantId != 0)
if (group.Scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
{
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
}

View File

@@ -14,6 +14,7 @@ using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Http;
namespace TakeoutSaaS.Application.Dictionary.Services;
@@ -29,6 +30,7 @@ public sealed class DictionaryImportExportService(
IDictionaryHybridCache cache,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUser,
IHttpContextAccessor httpContextAccessor,
ILogger<DictionaryImportExportService> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -424,7 +426,7 @@ public sealed class DictionaryImportExportService(
private void EnsureGroupAccess(DictionaryGroup group)
{
var tenantId = tenantProvider.GetCurrentTenantId();
if (group.Scope == DictionaryScope.System && tenantId != 0)
if (group.Scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
{
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
}

View File

@@ -0,0 +1,234 @@
using TakeoutSaaS.Application.Dictionary.Models;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 字典标签覆盖服务。
/// </summary>
public sealed class DictionaryLabelOverrideService(
IDictionaryLabelOverrideRepository overrideRepository,
IDictionaryItemRepository itemRepository)
{
/// <summary>
/// 获取租户的所有标签覆盖。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="overrideType">可选的覆盖类型过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
public async Task<IReadOnlyList<LabelOverrideDto>> GetOverridesAsync(
long tenantId,
OverrideType? overrideType = null,
CancellationToken cancellationToken = default)
{
var overrides = await overrideRepository.ListByTenantAsync(tenantId, overrideType, cancellationToken);
return overrides.Select(MapToDto).ToList();
}
/// <summary>
/// 获取指定字典项的覆盖配置。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="dictionaryItemId">字典项 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
public async Task<LabelOverrideDto?> GetOverrideByItemIdAsync(
long tenantId,
long dictionaryItemId,
CancellationToken cancellationToken = default)
{
var entity = await overrideRepository.GetByItemIdAsync(tenantId, dictionaryItemId, cancellationToken);
return entity == null ? null : MapToDto(entity);
}
/// <summary>
/// 创建或更新租户对系统字典的标签覆盖(租户定制)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="request">覆盖请求。</param>
/// <param name="operatorId">操作人 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
public async Task<LabelOverrideDto> UpsertTenantOverrideAsync(
long tenantId,
UpsertLabelOverrideRequest request,
long operatorId,
CancellationToken cancellationToken = default)
{
// 1. 验证字典项存在且为系统字典
var item = await itemRepository.GetByIdAsync(request.DictionaryItemId, cancellationToken);
if (item == null)
{
throw new BusinessException(ErrorCodes.NotFound, "字典项不存在");
}
if (item.TenantId != 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "租户只能覆盖系统字典项");
}
// 2. 查找现有覆盖或创建新记录
var existing = await overrideRepository.GetByItemIdAsync(tenantId, request.DictionaryItemId, cancellationToken);
var now = DateTime.UtcNow;
if (existing != null)
{
if (existing.OverrideType == OverrideType.PlatformEnforcement)
{
throw new BusinessException(ErrorCodes.Forbidden, "平台强制覆盖不可由租户修改");
}
existing.OverrideValue = DictionaryValueConverter.Serialize(request.OverrideValue);
existing.Reason = request.Reason;
existing.UpdatedAt = now;
existing.UpdatedBy = operatorId;
await overrideRepository.UpdateAsync(existing, cancellationToken);
}
else
{
existing = new DictionaryLabelOverride
{
TenantId = tenantId,
DictionaryItemId = request.DictionaryItemId,
OriginalValue = item.Value,
OverrideValue = DictionaryValueConverter.Serialize(request.OverrideValue),
OverrideType = OverrideType.TenantCustomization,
Reason = request.Reason,
CreatedAt = now,
CreatedBy = operatorId
};
await overrideRepository.AddAsync(existing, cancellationToken);
}
await overrideRepository.SaveChangesAsync(cancellationToken);
// 重新加载以获取完整信息
existing.DictionaryItem = item;
return MapToDto(existing);
}
/// <summary>
/// 创建或更新平台对租户字典的强制覆盖(平台强制)。
/// </summary>
/// <param name="targetTenantId">目标租户 ID。</param>
/// <param name="request">覆盖请求。</param>
/// <param name="operatorId">操作人 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
public async Task<LabelOverrideDto> UpsertPlatformOverrideAsync(
long targetTenantId,
UpsertLabelOverrideRequest request,
long operatorId,
CancellationToken cancellationToken = default)
{
// 1. 验证字典项存在
var item = await itemRepository.GetByIdAsync(request.DictionaryItemId, cancellationToken);
if (item == null)
{
throw new BusinessException(ErrorCodes.NotFound, "字典项不存在");
}
// 2. 查找现有覆盖或创建新记录
var existing = await overrideRepository.GetByItemIdAsync(targetTenantId, request.DictionaryItemId, cancellationToken);
var now = DateTime.UtcNow;
if (existing != null)
{
existing.OverrideValue = DictionaryValueConverter.Serialize(request.OverrideValue);
existing.OverrideType = OverrideType.PlatformEnforcement;
existing.Reason = request.Reason;
existing.UpdatedAt = now;
existing.UpdatedBy = operatorId;
await overrideRepository.UpdateAsync(existing, cancellationToken);
}
else
{
existing = new DictionaryLabelOverride
{
TenantId = targetTenantId,
DictionaryItemId = request.DictionaryItemId,
OriginalValue = item.Value,
OverrideValue = DictionaryValueConverter.Serialize(request.OverrideValue),
OverrideType = OverrideType.PlatformEnforcement,
Reason = request.Reason,
CreatedAt = now,
CreatedBy = operatorId
};
await overrideRepository.AddAsync(existing, cancellationToken);
}
await overrideRepository.SaveChangesAsync(cancellationToken);
existing.DictionaryItem = item;
return MapToDto(existing);
}
/// <summary>
/// 删除覆盖配置。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="dictionaryItemId">字典项 ID。</param>
/// <param name="operatorId">操作人 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
public async Task<bool> DeleteOverrideAsync(
long tenantId,
long dictionaryItemId,
long operatorId,
bool allowPlatformEnforcement = true,
CancellationToken cancellationToken = default)
{
var existing = await overrideRepository.GetByItemIdAsync(tenantId, dictionaryItemId, cancellationToken);
if (existing == null)
{
return false;
}
if (!allowPlatformEnforcement && existing.OverrideType == OverrideType.PlatformEnforcement)
{
throw new BusinessException(ErrorCodes.Forbidden, "平台强制覆盖不可由租户删除");
}
existing.DeletedBy = operatorId;
await overrideRepository.DeleteAsync(existing, cancellationToken);
await overrideRepository.SaveChangesAsync(cancellationToken);
return true;
}
/// <summary>
/// 批量获取字典项的覆盖值映射。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="dictionaryItemIds">字典项 ID 列表。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>字典项 ID 到覆盖值的映射。</returns>
public async Task<Dictionary<long, Dictionary<string, string>>> GetOverrideValuesMapAsync(
long tenantId,
IEnumerable<long> dictionaryItemIds,
CancellationToken cancellationToken = default)
{
var overrides = await overrideRepository.GetByItemIdsAsync(tenantId, dictionaryItemIds, cancellationToken);
return overrides.ToDictionary(
x => x.DictionaryItemId,
x => DictionaryValueConverter.Deserialize(x.OverrideValue));
}
private static LabelOverrideDto MapToDto(DictionaryLabelOverride entity)
{
return new LabelOverrideDto
{
Id = entity.Id,
TenantId = entity.TenantId,
DictionaryItemId = entity.DictionaryItemId,
DictionaryItemKey = entity.DictionaryItem?.Key ?? string.Empty,
OriginalValue = DictionaryValueConverter.Deserialize(entity.OriginalValue),
OverrideValue = DictionaryValueConverter.Deserialize(entity.OverrideValue),
OverrideType = entity.OverrideType,
Reason = entity.Reason,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
CreatedBy = entity.CreatedBy,
UpdatedBy = entity.UpdatedBy
};
}
}

View File

@@ -422,6 +422,12 @@ public sealed class AdminAuthService(
// 1.3 可见性
var required = node.RequiredPermissions ?? [];
if (required.Length == 0 && node.Meta.Permissions.Length > 0)
{
// Fall back to meta permissions when explicit required permissions are missing.
required = node.Meta.Permissions;
}
var visible = required.Length == 0 || required.Any(permissionSet.Contains);
// 1.4 收集