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)

合并多个来源的元数据:

  1. 公共元数据(如果应继承)
  2. 路由匹配的元数据
  3. 显式传入的元数据

通过 Merge 方法合并,后面的覆盖前面的。

4. CRUD操作

保存元数据

SavePageMetadataAsync(VmPageMetadata pageMetadata)
  • 验证公共元数据不能包含路由或关系
  • 标准化数据(关键词去重、元标签清理)
  • 验证路由有效性
  • 根据ID或RouteKey查找已存在实体,更新或插入

删除和缓存刷新

带有 [InvalidateCache] 特性,删除或修改时自动清除相关缓存。

5. 分页查询

GetPageAsync(searchText, pageIndex, pageSize)

支持按RouteKey、Relations、Title、Description进行模糊搜索(正则表达式)。

关键特性

缓存策略

使用 [Cache][InvalidateCache] 特性进行缓存管理:

  • 查询方法缓存1天(ExpirationTime.Day
  • 修改操作自动失效相关缓存

路由匹配逻辑

FindRouteMetadata

匹配策略:

  1. Controller/Action必须完全匹配
  2. 候选元数据的参数必须是请求参数的子集
  3. 优先返回参数最多的匹配(更精确)
  4. 其次优先完全RouteKey匹配

继承模式

ShouldInheritPublic

决定是否继承公共元数据:

  • 显式设置为 Ignore 则不继承
  • 有路由元数据时,根据其继承模式决定
  • 无配置页面根据公共元数据的 ApplyToUnconfiguredPages 决定

数据标准化

NormalizePageMetadata
  • 清理重复关键词
  • 统一元标签格式
  • 公共元数据清除路由和关系信息
  • 非公共元数据清除关系并构建RouteKey

设计亮点

  1. 向后兼容: 同时支持旧的关系模式和新的路由模式
  2. 灵活继承: 三级元数据合并机制(公共 → 路由 → 显式)
  3. 智能匹配: 参数子集匹配和优先级排序
  4. 自动缓存: 声明式缓存管理,简化代码
  5. 数据验证: 保存前进行完整性和有效性检查
评论加载中...