using Dpz.Core.Public.Entity.Seo;
namespace Dpz.Core.Service.RepositoryServiceImpl;
public class PageMetadataService(
IRepository<PageMetadata> repository,
IMapper mapper,
IPageRouteCatalogService pageRouteCatalogService
) : IPageMetadataService
{
private static List<string> NormalizeRelations(IEnumerable<string>? relations)
{
return relations
?.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim().ToLowerInvariant())
.Distinct()
.OrderBy(x => x)
.ToList()
?? [];
}
private static string BuildRelationPath(IEnumerable<string>? relations)
{
return string.Join("/", NormalizeRelations(relations));
}
private static bool IsLegacyRelationMatch(
IReadOnlyCollection<string> left,
IReadOnlyCollection<string> right
)
{
var leftPath = BuildRelationPath(left);
var rightPath = BuildRelationPath(right);
return string.Equals(leftPath, rightPath, StringComparison.Ordinal);
}
private async Task<IList<PageMetadata>> GetAllEntitiesAsync(
CancellationToken cancellationToken = default
)
{
return await repository.SearchFor(x => true).ToListAsync(cancellationToken);
}
private async Task<PageMetadata?> FindLegacyAsync(
IReadOnlyCollection<string> relations,
CancellationToken cancellationToken = default
)
{
var normalizedRelations = NormalizeRelations(relations);
if (normalizedRelations.Count == 0)
{
return null;
}
var exactMatch = await repository
.SearchFor(x => x.Relations == normalizedRelations)
.FirstOrDefaultAsync(cancellationToken);
if (exactMatch != null)
{
return exactMatch;
}
var list = await GetAllEntitiesAsync(cancellationToken);
return list.FirstOrDefault(x => IsLegacyRelationMatch(x.Relations, relations));
}
public async Task<VmPageMetadata?> GetPageMetadataAsync(
IReadOnlyCollection<string> relations,
CancellationToken cancellationToken = default
)
{
var entity = await FindLegacyAsync(relations, cancellationToken);
return entity == null ? null : mapper.Map<VmPageMetadata>(entity);
}
public Task<VmPageMetadata?> GetPageMetadataByRouteAsync(
string? controller,
string? action,
string? routeId = null,
CancellationToken cancellationToken = default
)
{
var route = new PageMetadataRoute
{
Controller = controller,
Action = action,
Parameters = string.IsNullOrWhiteSpace(routeId)
? []
: new Dictionary<string, string> { ["id"] = routeId },
};
return GetPageMetadataByRouteAsync(route, cancellationToken);
}
public async Task<VmPageMetadata?> GetPageMetadataByRouteAsync(
PageMetadataRoute route,
CancellationToken cancellationToken = default
)
{
var normalizedRoute = route.Normalize();
if (
string.IsNullOrWhiteSpace(normalizedRoute.Controller)
|| string.IsNullOrWhiteSpace(normalizedRoute.Action)
)
{
return null;
}
var list = await GetPageMetadataAsync(cancellationToken);
return FindRouteMetadata(list, normalizedRoute)
?? FindLegacyRouteMetadata(list, normalizedRoute);
}
public async Task<VmPageMetadata?> ResolvePageMetadataAsync(
PageMetadataRoute route,
VmPageMetadata? explicitMetadata = null,
CancellationToken cancellationToken = default
)
{
var publicMetadata = await GetPublicMetadataAsync(cancellationToken);
var routeMetadata = await GetPageMetadataByRouteAsync(route, cancellationToken);
var shouldInheritPublic = ShouldInheritPublic(
publicMetadata,
routeMetadata,
explicitMetadata
);
var mergedSources = new List<VmPageMetadata>();
if (shouldInheritPublic && publicMetadata != null)
{
mergedSources.Add(publicMetadata);
}
if (routeMetadata != null)
{
mergedSources.Add(routeMetadata);
}
if (explicitMetadata != null)
{
mergedSources.Add(explicitMetadata);
}
return Merge(mergedSources);
}
[Cache(ExpirationSeconds = ExpirationTime.Day)]
public async Task<VmPageMetadata?> GetPublicMetadataAsync(
CancellationToken cancellationToken = default
)
{
var entity = await repository
.SearchFor(x => x.IsPublicMetadata)
.FirstOrDefaultAsync(cancellationToken);
return entity == null ? null : mapper.Map<VmPageMetadata>(entity);
}
[Cache(ExpirationSeconds = ExpirationTime.Day)]
public async Task<VmPageMetadata?> GetByIdAsync(
string id,
CancellationToken cancellationToken = default
)
{
var entity = await repository.TryGetAsync(id, cancellationToken);
return entity == null ? null : mapper.Map<VmPageMetadata>(entity);
}
[Cache(ExpirationSeconds = ExpirationTime.Day)]
public async Task<IPagedList<VmPageMetadata>> GetPageAsync(
string? searchText = null,
int pageIndex = 1,
int pageSize = 20,
CancellationToken cancellationToken = default
)
{
var filter = Builders<PageMetadata>.Filter.Empty;
if (!string.IsNullOrWhiteSpace(searchText))
{
var regex = new BsonRegularExpression(searchText, "i");
filter = Builders<PageMetadata>.Filter.Or(
Builders<PageMetadata>.Filter.Regex(x => x.RouteKey, regex),
Builders<PageMetadata>.Filter.Regex(x => x.Relations, regex),
Builders<PageMetadata>.Filter.Regex(x => x.Title, regex),
Builders<PageMetadata>.Filter.Regex(x => x.Description, regex)
);
}
return await repository
.SearchFor(filter)
.ToPagedListAsync<PageMetadata, VmPageMetadata>(pageIndex, pageSize, cancellationToken);
}
[Cache(ExpirationSeconds = ExpirationTime.Day)]
public async Task<IList<VmPageMetadata>> GetPageMetadataAsync(
CancellationToken cancellationToken = default
)
{
var list = await GetAllEntitiesAsync(cancellationToken);
return mapper.Map<List<VmPageMetadata>>(list);
}
[InvalidateCache(
Methods = [
nameof(GetByIdAsync),
nameof(GetPageAsync),
nameof(GetPageMetadataAsync),
nameof(GetPublicMetadataAsync),
]
)]
public async Task SavePageMetadataAsync(
VmPageMetadata pageMetadata,
CancellationToken cancellationToken = default
)
{
if (
pageMetadata.IsPublicMetadata
&& (pageMetadata.Route != null || pageMetadata.Relations.Count > 0)
)
{
throw new ArgumentException("public metadata cannot contain route or relations");
}
NormalizePageMetadata(pageMetadata);
await ValidatePageMetadataAsync(pageMetadata, cancellationToken);
var entity = await FindSaveTargetAsync(pageMetadata, cancellationToken);
if (!string.IsNullOrWhiteSpace(pageMetadata.Id) && entity == null)
{
return;
}
if (entity != null)
{
await UpdateAsync(entity, pageMetadata, cancellationToken);
return;
}
var insertEntity = mapper.Map<PageMetadata>(pageMetadata);
await repository.InsertAsync(insertEntity, cancellationToken);
}
[InvalidateCache(
Methods = [
nameof(GetByIdAsync),
nameof(GetPageAsync),
nameof(GetPageMetadataAsync),
nameof(GetPublicMetadataAsync),
]
)]
public Task RefreshCacheAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
[InvalidateCache(
Methods = [
nameof(GetByIdAsync),
nameof(GetPageAsync),
nameof(GetPageMetadataAsync),
nameof(GetPublicMetadataAsync),
]
)]
public async Task DeleteAsync(string id, CancellationToken cancellationToken = default)
{
await repository.DeleteAsync(id, cancellationToken);
}
private async Task<PageMetadata?> FindSaveTargetAsync(
VmPageMetadata pageMetadata,
CancellationToken cancellationToken
)
{
if (!string.IsNullOrWhiteSpace(pageMetadata.Id))
{
return await repository.TryGetAsync(pageMetadata.Id, cancellationToken);
}
if (pageMetadata.IsPublicMetadata)
{
return await repository
.SearchFor(x => x.IsPublicMetadata)
.FirstOrDefaultAsync(cancellationToken);
}
return await repository
.SearchFor(x => x.RouteKey == pageMetadata.RouteKey && !x.IsPublicMetadata)
.FirstOrDefaultAsync(cancellationToken);
}
private async Task ValidatePageMetadataAsync(
VmPageMetadata pageMetadata,
CancellationToken cancellationToken
)
{
if (pageMetadata.IsPublicMetadata)
{
if (pageMetadata.Route != null || pageMetadata.Relations.Count > 0)
{
throw new ArgumentException("public metadata cannot contain route or relations");
}
if (!string.IsNullOrWhiteSpace(pageMetadata.Id))
{
var publicEntity = await repository
.SearchFor(x => x.IsPublicMetadata)
.FirstOrDefaultAsync(cancellationToken);
if (
publicEntity != null
&& !string.Equals(
publicEntity.Id.ToString(),
pageMetadata.Id,
StringComparison.Ordinal
)
)
{
throw new ArgumentException("public metadata already exists");
}
}
return;
}
if (pageMetadata.Route == null)
{
throw new ArgumentException("route is required for page metadata");
}
var validation = await pageRouteCatalogService.ValidateRouteAsync(
pageMetadata.Route,
cancellationToken
);
if (!validation.IsValid)
{
throw new ArgumentException(validation.ErrorMessage ?? "route is invalid");
}
}
private async Task UpdateAsync(
PageMetadata entity,
VmPageMetadata pageMetadata,
CancellationToken cancellationToken
)
{
var update = Builders<PageMetadata>
.Update.Set(x => x.IsPublicMetadata, pageMetadata.IsPublicMetadata)
.Set(x => x.ApplyToUnconfiguredPages, pageMetadata.ApplyToUnconfiguredPages)
.Set(x => x.InheritanceMode, pageMetadata.InheritanceMode)
.Set(x => x.Route, pageMetadata.Route)
.Set(x => x.RouteKey, pageMetadata.RouteKey)
.Set(x => x.Title, pageMetadata.Title)
.Set(x => x.Keywords, pageMetadata.Keywords)
.Set(x => x.Description, pageMetadata.Description)
.Set(x => x.Metas, pageMetadata.Metas)
.Set(x => x.Relations, pageMetadata.Relations);
await repository.UpdateAsync(x => x.Id == entity.Id, update, cancellationToken);
}
private static void NormalizePageMetadata(VmPageMetadata pageMetadata)
{
pageMetadata.Keywords = pageMetadata
.Keywords.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
pageMetadata.Metas = pageMetadata
.Metas.Where(x => !string.IsNullOrWhiteSpace(x.Key))
.ToDictionary(x => x.Key.Trim(), x => x.Value?.Trim() ?? "", StringComparer.Ordinal);
if (pageMetadata.IsPublicMetadata)
{
pageMetadata.InheritanceMode = PageMetadataInheritanceMode.Inherit;
pageMetadata.Route = null;
pageMetadata.RouteKey = null;
pageMetadata.Relations = [];
return;
}
pageMetadata.ApplyToUnconfiguredPages = false;
pageMetadata.Relations = [];
pageMetadata.Route = pageMetadata.Route?.Normalize();
pageMetadata.RouteKey = pageMetadata.Route?.BuildRouteKey();
}
private static VmPageMetadata? FindRouteMetadata(
IList<VmPageMetadata> metadataList,
PageMetadataRoute route
)
{
var requestActionKey = route.BuildActionKey();
var requestRouteKey = route.BuildRouteKey();
var requestParameters = route.Parameters;
return metadataList
.Where(x => !x.IsPublicMetadata && x.Route != null)
.Select(x => new { Metadata = x, Route = x.Route!.Normalize() })
.Where(x =>
string.Equals(x.Route.BuildActionKey(), requestActionKey, StringComparison.Ordinal)
)
.Where(x => IsParameterSubset(x.Route.Parameters, requestParameters))
.OrderByDescending(x => x.Route.Parameters.Count)
.ThenByDescending(x =>
string.Equals(x.Metadata.RouteKey, requestRouteKey, StringComparison.Ordinal)
)
.Select(x => x.Metadata)
.FirstOrDefault();
}
private static VmPageMetadata? FindLegacyRouteMetadata(
IList<VmPageMetadata> metadataList,
PageMetadataRoute route
)
{
var relations = new List<string>();
if (!string.IsNullOrWhiteSpace(route.Controller))
{
relations.Add(route.Controller);
}
if (!string.IsNullOrWhiteSpace(route.Action))
{
relations.Add(route.Action);
}
if (route.Parameters.TryGetValue("id", out var id) && !string.IsNullOrWhiteSpace(id))
{
relations.Add(id);
}
if (relations.Count < 2)
{
return null;
}
return metadataList
.Where(x => !x.IsPublicMetadata && x.Route == null && x.Relations.Count > 0)
.OrderByDescending(x => x.Relations.Count)
.FirstOrDefault(x => IsLegacyRelationMatch(x.Relations, relations));
}
private static bool IsParameterSubset(
IReadOnlyDictionary<string, string> candidate,
IReadOnlyDictionary<string, string> request
)
{
foreach (var (key, value) in candidate)
{
if (!request.TryGetValue(key, out var requestValue))
{
return false;
}
if (!string.Equals(value, requestValue, StringComparison.Ordinal))
{
return false;
}
}
return true;
}
private static bool ShouldInheritPublic(
VmPageMetadata? publicMetadata,
VmPageMetadata? routeMetadata,
VmPageMetadata? explicitMetadata
)
{
if (publicMetadata == null)
{
return false;
}
if (explicitMetadata?.InheritanceMode == PageMetadataInheritanceMode.Ignore)
{
return false;
}
if (routeMetadata != null)
{
return routeMetadata.InheritanceMode == PageMetadataInheritanceMode.Inherit;
}
return explicitMetadata != null || publicMetadata.ApplyToUnconfiguredPages;
}
private static VmPageMetadata? Merge(IReadOnlyCollection<VmPageMetadata> sources)
{
if (sources.Count == 0)
{
return null;
}
var result = new VmPageMetadata();
var metas = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var keywords = new List<string>();
foreach (var source in sources)
{
foreach (var (key, value) in source.Metas)
{
metas[key] = value;
}
foreach (var keyword in source.Keywords)
{
if (keywords.Contains(keyword, StringComparer.OrdinalIgnoreCase))
{
continue;
}
keywords.Add(keyword);
}
if (!string.IsNullOrWhiteSpace(source.Title))
{
result.Title = source.Title;
}
if (!string.IsNullOrWhiteSpace(source.Description))
{
result.Description = source.Description;
}
result.Route ??= source.Route;
result.RouteKey ??= source.RouteKey;
result.Relations = source.Relations.Count > 0 ? source.Relations : result.Relations;
}
result.Metas = metas.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal);
result.Keywords = keywords;
result.InheritanceMode = PageMetadataInheritanceMode.Inherit;
return result;
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
代码解释
这是一个页面元数据(SEO元数据)服务的实现类 PageMetadataService,主要负责管理和查询页面的SEO相关信息(如标题、关键词、描述等)。
核心功能
1. 构造函数依赖注入
public PageMetadataService(
IRepository<PageMetadata> repository,
IMapper mapper,
IPageRouteCatalogService pageRouteCatalogService
)
注入了仓储、对象映射器和路由目录服务。
2. 关系路径处理(Legacy模式)
NormalizeRelations: 标准化关系列表(去空格、转小写、去重、排序)BuildRelationPath: 将关系列表构建为路径字符串IsLegacyRelationMatch: 比较两个关系列表是否匹配(用于向后兼容旧的关系存储方式)
3. 查询功能
根据关系查询(旧方式)
GetPageMetadataAsync(IReadOnlyCollection<string> relations)
先尝试精确匹配,不成功则遍历所有实体进行旧式匹配。
根据路由查询
GetPageMetadataByRouteAsync(PageMetadataRoute route)
支持两种查询方式:
- 现代路由匹配: 通过
FindRouteMetadata精确匹配controller/action/parameters - 旧式关系匹配: 通过
FindLegacyRouteMetadata使用关系列表匹配
解析页面元数据
ResolvePageMetadataAsync(route, explicitMetadata)
合并多个来源的元数据:
- 公共元数据(如果应继承)
- 路由匹配的元数据
- 显式传入的元数据
通过 Merge 方法合并,后面的覆盖前面的。
4. CRUD操作
保存元数据
SavePageMetadataAsync(VmPageMetadata pageMetadata)
- 验证公共元数据不能包含路由或关系
- 标准化数据(关键词去重、元标签清理)
- 验证路由有效性
- 根据ID或RouteKey查找已存在实体,更新或插入
删除和缓存刷新
带有 [InvalidateCache] 特性,删除或修改时自动清除相关缓存。
5. 分页查询
GetPageAsync(searchText, pageIndex, pageSize)
支持按RouteKey、Relations、Title、Description进行模糊搜索(正则表达式)。
关键特性
缓存策略
使用 [Cache] 和 [InvalidateCache] 特性进行缓存管理:
- 查询方法缓存1天(
ExpirationTime.Day) - 修改操作自动失效相关缓存
路由匹配逻辑
FindRouteMetadata
匹配策略:
- Controller/Action必须完全匹配
- 候选元数据的参数必须是请求参数的子集
- 优先返回参数最多的匹配(更精确)
- 其次优先完全RouteKey匹配
继承模式
ShouldInheritPublic
决定是否继承公共元数据:
- 显式设置为
Ignore则不继承 - 有路由元数据时,根据其继承模式决定
- 无配置页面根据公共元数据的
ApplyToUnconfiguredPages决定
数据标准化
NormalizePageMetadata
- 清理重复关键词
- 统一元标签格式
- 公共元数据清除路由和关系信息
- 非公共元数据清除关系并构建RouteKey
设计亮点
- 向后兼容: 同时支持旧的关系模式和新的路由模式
- 灵活继承: 三级元数据合并机制(公共 → 路由 → 显式)
- 智能匹配: 参数子集匹配和优先级排序
- 自动缓存: 声明式缓存管理,简化代码
- 数据验证: 保存前进行完整性和有效性检查
AI 正在分析代码…
评论加载中...