using System.Text.RegularExpressions;
using Dpz.Core.Entity.Base.ExpressTreeQuery;
using Dpz.Core.Public.ViewModel.Request;
using Dpz.Core.Public.ViewModel.Response;
namespace Dpz.Core.Service.RepositoryServiceImpl;
public class CodeFileSystemEntryService(
IRepository<CodeFileSystemEntry> repository,
IConfiguration configuration,
ILogger<CodeFileSystemEntryService> logger,
IFusionCache fusionCache
) : ICodeFileSystemEntryService
{
private static readonly TimeSpan CacheDefaultExpiration = TimeSpan.FromDays(7);
private readonly Lazy<CodeViewOption> _codeViewCfg = new(() =>
{
var codeView = configuration.GetSection("CodeView").Get<CodeViewOption>();
return codeView ?? throw new InvalidConfigurationException("CodeView 配置无效");
});
public async Task<CodeFileSystemEntryResponse?> FindByPathAsync(
IReadOnlyCollection<string>? pathSegments,
CancellationToken cancellationToken = default
)
{
var segments = NormalizeSegments(pathSegments);
var cacheKey = BuildPathKey(segments);
return await GetOrSetCacheAsync<CodeFileSystemEntryResponse?>(
nameof(FindByPathAsync),
"Path=" + cacheKey,
ct => FindByPathCoreAsync(segments, ct),
cancellationToken: cancellationToken
);
}
public async Task<CodeFileSystemEntryResponse?> FindByPathWithoutCacheAsync(
IReadOnlyCollection<string>? pathSegments,
CancellationToken cancellationToken = default
)
{
var segments = NormalizeSegments(pathSegments);
return await FindByPathCoreAsync(segments, cancellationToken);
}
public async Task<List<CodeFileSystemEntryResponse>> GetChildrenAsync(
IReadOnlyCollection<string>? parentPathSegments,
CancellationToken cancellationToken = default
)
{
var segments = NormalizeSegments(parentPathSegments);
var cacheKey = BuildPathKey(segments);
return await GetOrSetCacheAsync<List<CodeFileSystemEntryResponse>>(
nameof(GetChildrenAsync),
"Parent=" + cacheKey,
async ct =>
{
var filter = Builders<CodeFileSystemEntry>.Filter.Eq(
x => x.ParentPathSegments,
segments
);
var entries = await repository.SearchFor(filter).ToListAsync(ct);
return await MapToResponseListAsync(entries, ct);
},
cancellationToken: cancellationToken
);
}
public async Task<List<CodeFileSystemEntryResponse>> SearchAsync(
string keyword,
CancellationToken cancellationToken = default
)
{
if (string.IsNullOrWhiteSpace(keyword))
{
return [];
}
return await GetOrSetCacheAsync<List<CodeFileSystemEntryResponse>>(
nameof(SearchAsync),
"Keyword=" + keyword,
async ct =>
{
var filter = Builders<CodeFileSystemEntry>.Filter.Regex(
x => x.Name,
new BsonRegularExpression(BuildSearchPattern(keyword), "i")
);
var entries = await repository.SearchFor(filter).ToListAsync(ct);
return await MapToResponseListAsync(entries, ct);
},
cancellationToken: cancellationToken
);
}
/// <inheritdoc />
public async Task<List<CodeContentSearchHitResponse>> SearchContentAsync(
string query,
int? maxResults = null,
CancellationToken cancellationToken = default
)
{
var pattern = CodeContentSearch.BuildEscapedPattern(query);
if (pattern == null)
{
return [];
}
// 字面量转义后再做不区分大小写的正则,避免用户输入变成 Mongo 正则炸弹
var limit = CodeContentSearch.ClampMaxResults(maxResults);
var filter = Builders<CodeFileSystemEntry>.Filter.And(
Builders<CodeFileSystemEntry>.Filter.Eq(x => x.IsDirectory, false),
Builders<CodeFileSystemEntry>.Filter.Eq(
x => x.CodeFileContentType,
CodeFileContentType.Text
),
Builders<CodeFileSystemEntry>.Filter.Regex(
x => x.FileContent,
new BsonRegularExpression(pattern, "i")
)
);
var entries = await repository
.SearchFor(filter)
.Limit(limit)
.ToListAsync(cancellationToken);
return entries
.Select(entry => new CodeContentSearchHitResponse
{
PathSegments = entry.PathSegments.ToList(),
Name = entry.Name,
Path = string.Join('/', entry.PathSegments),
Snippet = CodeContentSearch.BuildSnippet(entry.FileContent, query),
})
.ToList();
}
/// <summary>
/// 将用户输入转换为安全的 MongoDB 正则:
/// `*` 视为任意字符、`?` 视为单个字符通配符,其余正则元字符按字面量匹配;
/// 含通配符时整体锚定首尾(glob 语义),否则保持子串匹配。
/// </summary>
private static string BuildSearchPattern(string keyword)
{
var hasWildcard = keyword.Contains('*') || keyword.Contains('?');
var pattern = ApplicationTools.WildcardToRegexPattern(keyword);
return hasWildcard ? $"^{pattern}$" : pattern;
}
public async Task<IPagedList<CodeFileSystemEntryListResponse>> GetPagedListAsync(
CodeFlatRequest request,
CancellationToken cancellationToken = default
)
{
// 生成查询条件
var filter = GenerateConditionFilter.GenerateConditionExpressionTree<
CodeFileSystemEntry,
CodeFlatRequest
>(request);
var query = repository.SearchFor(filter);
// 应用排序
if (request.SortField.HasValue)
{
query = request.SortField.Value switch
{
CodeFileSortField.Size => request.IsDescending
? query.OrderByDescending(x => x.Size)
: query.OrderBy(x => x.Size),
CodeFileSortField.CreatedTime => request.IsDescending
? query.OrderByDescending(x => x.CreatedTime)
: query.OrderBy(x => x.CreatedTime),
CodeFileSortField.LastWriteTime => request.IsDescending
? query.OrderByDescending(x => x.LastWriteTime)
: query.OrderBy(x => x.LastWriteTime),
CodeFileSortField.LastUpdateTime => request.IsDescending
? query.OrderByDescending(x => x.LastUpdateTime)
: query.OrderBy(x => x.LastUpdateTime),
CodeFileSortField.AiAnalyzeTime => request.IsDescending
? query.OrderByDescending(x => x.AiAnalyzeTime)
: query.OrderBy(x => x.AiAnalyzeTime),
_ => query,
};
}
// 执行分页查询
var entityPagedList = await query
.Select(x => new CodeFileSystemEntryListResponse
{
Id = x.Id.ToString(),
PathSegments = x.PathSegments,
Name = x.Name,
ParentPathSegments = x.ParentPathSegments,
IsDirectory = x.IsDirectory,
Extension = x.Extension,
Size = x.Size,
Hash = x.Hash,
CodeFileContentType = x.CodeFileContentType,
CodeLanguage = x.CodeLanguage,
Tags = x.Tags,
CreatedTime = x.CreatedTime,
LastWriteTime = x.LastWriteTime,
LastUpdateTime = x.LastUpdateTime,
Description = x.Description,
AiAnalyzeTime = x.AiAnalyzeTime,
AiAnalyzeHash = x.AiAnalyzeHash,
})
.ToPagedListAsync(request.PageIndex, request.PageSize, cancellationToken);
entityPagedList.ForEach(x =>
x.CodeLanguage = ResolveCodeLanguage(x.IsDirectory, x.Extension, x.Name)
);
return entityPagedList;
}
public async Task<List<string[]>> GetAllDirectoriesAsync(
string[]? pathSegments = null,
CancellationToken cancellationToken = default
)
{
var normalizedPath = NormalizeSegments(pathSegments);
var pathKey = BuildPathKey(normalizedPath);
return await GetOrSetCacheAsync<List<string[]>>(
nameof(GetAllDirectoriesAsync),
"Path=" + pathKey,
async ct =>
{
var filterBuilder = Builders<CodeFileSystemEntry>.Filter;
var filter = filterBuilder.Eq(x => x.IsDirectory, true);
// 如果指定了路径,则只查询该路径及其所有子目录
if (normalizedPath.Count > 0)
{
// 查询所有 PathSegments 以 normalizedPath 开头的目录
// 先查询所有目录,然后在内存中过滤
var allDirectories = await repository
.SearchFor(filter)
.ToListAsync(cancellationToken: ct);
var filteredDirectories = allDirectories
.Where(x =>
x.PathSegments.Count >= normalizedPath.Count
&& x.PathSegments.Take(normalizedPath.Count)
.SequenceEqual(normalizedPath)
)
.ToList();
return filteredDirectories
.Select(x => x.PathSegments.ToArray())
.OrderBy(x => x.Length)
.ThenBy(x => string.Join("/", x))
.ToList();
}
// 没有指定路径,返回所有目录
var directories = await repository
.SearchFor(filter)
.ToListAsync(cancellationToken: ct);
return directories
.Select(x => x.PathSegments.ToArray())
.OrderBy(x => x.Length)
.ThenBy(x => string.Join("/", x))
.ToList();
},
cancellationToken: cancellationToken
);
}
public async Task<bool> SaveDescriptionAsync(
string[]? path,
string name,
string? description,
CancellationToken cancellationToken = default
)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var segments = NormalizeSegments(path).ToList();
segments.Add(name);
var filter = Builders<CodeFileSystemEntry>.Filter.Eq(x => x.PathSegments, segments);
var entry = await repository.SearchFor(filter).FirstOrDefaultAsync(cancellationToken);
if (entry == null)
{
return false;
}
entry.Description = description;
entry.LastUpdateTime = DateTime.Now;
await repository.UpdateAsync(entry, cancellationToken);
await InvalidateEntryCachesAsync(entry);
await InvalidateSearchCacheAsync(name);
return true;
}
public async Task SaveAiAnalyzeResultAsync(
string[]? path,
string name,
string analyzeResult,
string? analyzedHash = null,
CancellationToken cancellationToken = default
)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
var segments = NormalizeSegments(path).ToList();
segments.Add(name);
var filter = Builders<CodeFileSystemEntry>.Filter.Eq(x => x.PathSegments, segments);
var entry = await repository.SearchFor(filter).FirstOrDefaultAsync(cancellationToken);
if (entry == null)
{
return;
}
entry.AiAnalyzeResult = analyzeResult;
entry.AiAnalyzeHash = analyzedHash ?? entry.Hash;
entry.AiAnalyzeTime = DateTime.Now;
entry.LastUpdateTime = DateTime.Now;
await repository.UpdateAsync(entry, cancellationToken);
await InvalidateEntryCachesAsync(entry);
await InvalidateSearchCacheAsync(name);
}
public async Task<bool> ShouldAnalyzeAsync(
string[]? path,
string? name,
CodeContainer? codeContainer,
CancellationToken cancellationToken = default
)
{
var useAiAnalyze = configuration.GetValue("CodeUseAIAnalyze", false);
if (!useAiAnalyze)
{
return false;
}
if (
string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(codeContainer?.CodeContent)
)
{
return false;
}
// 检查代码行数,少于50行不分析
var lineCount = codeContainer
.CodeContent.Split(["\r\n", "\r", "\n"], StringSplitOptions.None)
.Length;
if (lineCount < 50)
{
return false;
}
if (name.Contains(".min"))
{
return false;
}
var language = codeContainer.Language?.ToLowerInvariant();
var isSupportedLanguage = language is "csharp" or "javascript" or "typescript";
if (!isSupportedLanguage)
{
return false;
}
var segments = NormalizeSegments(path).ToList();
var filter = Builders<CodeFileSystemEntry>.Filter.Eq(x => x.PathSegments, segments);
var entry = await repository.SearchFor(filter).FirstOrDefaultAsync(cancellationToken);
if (entry == null)
{
return false;
}
if (string.IsNullOrWhiteSpace(entry.AiAnalyzeResult))
{
return true;
}
if (!string.Equals(entry.AiAnalyzeHash, entry.Hash, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
private async Task InvalidateEntryCachesAsync(CodeFileSystemEntry entry)
{
var entryKey = BuildPathKey(entry.PathSegments);
await fusionCache.RemoveAsync(BuildCacheKey(nameof(FindByPathAsync), "Path=" + entryKey));
var parentKey = BuildPathKey(entry.ParentPathSegments);
await fusionCache.RemoveAsync(
BuildCacheKey(nameof(GetChildrenAsync), "Parent=" + parentKey)
);
}
private async Task<CodeFileSystemEntryResponse?> FindByPathCoreAsync(
List<string> segments,
CancellationToken cancellationToken
)
{
// 先尝试精确匹配(区分大小写)
var filter = Builders<CodeFileSystemEntry>.Filter.Eq(x => x.PathSegments, segments);
var entry = await repository.SearchFor(filter).FirstOrDefaultAsync(cancellationToken);
if (entry == null && segments.Count > 0)
{
// 如果精确匹配失败,则构建不区分大小写的正则过滤器
var regexes = segments
.Select(x => new BsonRegularExpression($"^{Regex.Escape(x)}$", "i"))
.ToArray();
var caseInsensitiveFilter = new BsonDocumentFilterDefinition<CodeFileSystemEntry>(
new BsonDocument
{
{
nameof(CodeFileSystemEntry.PathSegments),
new BsonDocument
{
{ "$size", segments.Count },
{ "$all", new BsonArray(regexes) },
}
},
}
);
#if DEBUG
var serializerRegistry = BsonSerializer.SerializerRegistry;
var documentSerializer = serializerRegistry.GetSerializer<CodeFileSystemEntry>();
var serializerArgs = new RenderArgs<CodeFileSystemEntry>(
documentSerializer,
serializerRegistry
);
var bsonDocumentQuery = caseInsensitiveFilter.Render(serializerArgs);
logger.LogInformation(
"构建查询条件: {BsonDocumentQuery}",
bsonDocumentQuery.ToString()
);
#endif
entry = await repository
.SearchFor(caseInsensitiveFilter)
.FirstOrDefaultAsync(cancellationToken);
}
if (entry == null)
{
return null;
}
if (!entry.IsDirectory)
{
entry = await FixPreviewContentAsync(entry, cancellationToken);
}
var readmeContent = entry.IsDirectory
? await ResolveReadmeContentAsync(entry.PathSegments, cancellationToken)
: null;
return MapToResponse(entry, readmeContent);
}
private async Task InvalidateSearchCacheAsync(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
await fusionCache.RemoveAsync(BuildCacheKey(nameof(SearchAsync), "Keyword=" + name));
}
private async Task<T> GetOrSetCacheAsync<T>(
string methodName,
string parameters,
Func<CancellationToken, Task<T>> factory,
CancellationToken cancellationToken = default
)
{
return await fusionCache.GetOrSetAsync<T>(
BuildCacheKey(methodName, parameters),
async (context, factoryCancellationToken) =>
{
context.Tags =
[
GeneratedCodeFileSystemEntryServiceCacheMetadata.DefaultPrefix,
GeneratedCodeFileSystemEntryServiceCacheMetadata.GetMethodTag(methodName),
];
return await factory(factoryCancellationToken);
},
options =>
options
.SetDuration(CacheDefaultExpiration)
.SetFailSafe(true, CacheDefaultExpiration),
token: cancellationToken
);
}
private static string BuildCacheKey(string methodName, string parameters)
{
return string.Concat(
GeneratedCodeFileSystemEntryServiceCacheMetadata.DefaultPrefix,
":",
methodName,
":",
parameters
);
}
private static List<string> NormalizeSegments(IReadOnlyCollection<string>? segments)
{
return segments?.Where(x => !string.IsNullOrWhiteSpace(x)).ToList() ?? [];
}
private CodeFileSystemEntryResponse MapToResponse(
CodeFileSystemEntry entry,
string? readmeContent
)
{
return new CodeFileSystemEntryResponse
{
Id = entry.Id.ToString(),
PathSegments = entry.PathSegments.ToList(),
Name = entry.Name,
ParentPathSegments = entry.ParentPathSegments.ToList(),
IsDirectory = entry.IsDirectory,
Extension = entry.Extension,
Size = entry.Size,
Hash = entry.Hash,
CodeFileContentType = entry.CodeFileContentType,
FileContent = entry.FileContent,
CodeLanguage = ResolveCodeLanguage(entry.IsDirectory, entry.Extension, entry.Name),
Tags = entry.Tags.ToList(),
CreatedTime = entry.CreatedTime,
LastWriteTime = entry.LastWriteTime,
LastUpdateTime = entry.LastUpdateTime,
Description = entry.Description,
AiAnalyzeResult = entry.AiAnalyzeResult,
AiAnalyzeTime = entry.AiAnalyzeTime,
AiAnalyzeHash = entry.AiAnalyzeHash,
ReadmeContent = readmeContent,
};
}
private async Task<List<CodeFileSystemEntryResponse>> MapToResponseListAsync(
List<CodeFileSystemEntry> entries,
CancellationToken cancellationToken
)
{
if (entries.Count == 0)
{
return [];
}
var directories = entries.Where(x => x.IsDirectory).ToList();
var readmeMap = await ResolveReadmeContentMapAsync(directories, cancellationToken);
return entries
.Select(entry =>
{
string? readmeContent = null;
if (
entry.IsDirectory
&& readmeMap.TryGetValue(BuildPathKey(entry.PathSegments), out var content)
)
{
readmeContent = content;
}
return MapToResponse(entry, readmeContent);
})
.ToList();
}
/// <summary>
/// 解析文件语言,目录不参与语言识别。
/// </summary>
/// <param name="isDirectory">是否为目录。</param>
/// <param name="extension">文件扩展名。</param>
/// <param name="name">文件名。</param>
/// <returns>配置中的语言标识,目录或未配置时返回 null。</returns>
private string? ResolveCodeLanguage(bool isDirectory, string? extension, string? name)
{
if (isDirectory)
{
return null;
}
return ResolveCodeLanguage(_codeViewCfg.Value, extension, name);
}
/// <summary>
/// 按配置解析文件语言;未配置的扩展名或文件名不支持预览。
/// </summary>
/// <param name="codeView">代码查看配置。</param>
/// <param name="extension">文件扩展名。</param>
/// <param name="name">文件名。</param>
/// <returns>配置中的语言标识,未匹配则返回 null。</returns>
private static string? ResolveCodeLanguage(
CodeViewOption codeView,
string? extension,
string? name
)
{
foreach (var key in GetLanguageLookupKeys(extension, name))
{
if (
TryGetConfiguredLanguage(
codeView.ExtensionToLanguage,
key,
out var configuredLanguage
)
)
{
return configuredLanguage;
}
}
return null;
}
/// <summary>
/// 从配置中查找语言映射,扩展名和文件名匹配忽略大小写。
/// </summary>
/// <param name="extensionToLanguage">扩展名和语言映射配置。</param>
/// <param name="key">待匹配的扩展名或文件名。</param>
/// <param name="language">匹配到的语言标识。</param>
/// <returns>是否匹配到配置。</returns>
private static bool TryGetConfiguredLanguage(
Dictionary<string, string>? extensionToLanguage,
string key,
out string? language
)
{
language = null;
if (extensionToLanguage == null)
{
return false;
}
if (extensionToLanguage.TryGetValue(key, out var exactLanguage))
{
language = exactLanguage;
return true;
}
var matched = extensionToLanguage.FirstOrDefault(x =>
string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase)
);
if (matched.Key == null)
{
return false;
}
language = matched.Value;
return true;
}
/// <summary>
/// 生成配置查找键,兼容扩展名和无扩展名的特殊文件名配置。
/// </summary>
/// <param name="extension">文件扩展名。</param>
/// <param name="name">文件名。</param>
/// <returns>用于匹配 ExtensionToLanguage 的候选键。</returns>
private static IEnumerable<string> GetLanguageLookupKeys(string? extension, string? name)
{
var primary = string.IsNullOrWhiteSpace(extension) ? name : extension;
foreach (var key in ExpandLanguageLookupKey(primary))
{
yield return key;
}
if (
!string.IsNullOrWhiteSpace(name)
&& !string.Equals(name, primary, StringComparison.OrdinalIgnoreCase)
)
{
foreach (var key in ExpandLanguageLookupKey(name))
{
yield return key;
}
}
}
/// <summary>
/// 生成带点和不带点两种形式的配置键。
/// </summary>
/// <param name="value">扩展名或文件名。</param>
/// <returns>规范化后的候选键。</returns>
private static IEnumerable<string> ExpandLanguageLookupKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
yield break;
}
var normalized = value.Trim().ToLowerInvariant();
yield return normalized;
if (normalized.StartsWith('.'))
{
yield return normalized.TrimStart('.');
}
else
{
yield return "." + normalized;
}
}
/// <summary>
/// 自愈可预览文件的历史记录内容。
/// </summary>
/// <param name="entry">数据库中的文件系统项。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>补齐内容后的文件系统项。</returns>
private async Task<CodeFileSystemEntry> FixPreviewContentAsync(
CodeFileSystemEntry entry,
CancellationToken cancellationToken
)
{
var codeView = _codeViewCfg.Value;
var shouldPreview = ResolveCodeLanguage(codeView, entry.Extension, entry.Name) != null;
if (!shouldPreview)
{
return entry;
}
if (entry.CodeFileContentType == CodeFileContentType.Text && entry.FileContent != null)
{
return entry;
}
var fileInfo = ResolveSourceFileInfo(codeView, entry.PathSegments);
if (fileInfo is not { Exists: true })
{
return entry;
}
entry.CodeFileContentType = CodeFileContentType.Text;
entry.FileContent = await ReadTextContentAsync(fileInfo, cancellationToken) ?? "";
entry.LastUpdateTime = DateTime.Now;
await repository.UpdateAsync(entry, cancellationToken);
return entry;
}
/// <summary>
/// 根据路径分段解析源代码目录下的物理文件。
/// </summary>
/// <param name="codeView">代码查看配置。</param>
/// <param name="pathSegments">相对源码根目录的路径分段。</param>
/// <returns>位于源码根目录下的文件信息;路径越界时返回 null。</returns>
private static FileInfo? ResolveSourceFileInfo(
CodeViewOption codeView,
IReadOnlyCollection<string> pathSegments
)
{
if (
string.IsNullOrWhiteSpace(codeView.SourceCodeRoot)
|| !Directory.Exists(codeView.SourceCodeRoot)
)
{
throw new InvalidConfigurationException("CodeView.SourceCodeRoot 配置无效");
}
var sourceRoot = Path.GetFullPath(codeView.SourceCodeRoot);
var fullPath = pathSegments.Aggregate(sourceRoot, Path.Combine);
var normalizedFullPath = Path.GetFullPath(fullPath);
var rootWithSeparator =
sourceRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
if (
!string.Equals(normalizedFullPath, sourceRoot, StringComparison.OrdinalIgnoreCase)
&& !normalizedFullPath.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase)
)
{
return null;
}
return new FileInfo(normalizedFullPath);
}
private async Task<Dictionary<string, string?>> ResolveReadmeContentMapAsync(
List<CodeFileSystemEntry> directories,
CancellationToken cancellationToken
)
{
if (directories.Count == 0)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
var parentSegmentsList = directories.Select(x => x.PathSegments).ToList();
var filter = Builders<CodeFileSystemEntry>.Filter.And(
Builders<CodeFileSystemEntry>.Filter.In(x => x.ParentPathSegments, parentSegmentsList),
Builders<CodeFileSystemEntry>.Filter.Regex(
x => x.Name,
new BsonRegularExpression("^readme\\.md$", "i")
)
);
var readmes = await repository.SearchFor(filter).ToListAsync(cancellationToken);
return readmes.ToDictionary(
x => BuildPathKey(x.ParentPathSegments),
x => x.FileContent,
StringComparer.OrdinalIgnoreCase
);
}
private async Task<string?> ResolveReadmeContentAsync(
List<string> parentPathSegments,
CancellationToken cancellationToken
)
{
var filter = Builders<CodeFileSystemEntry>.Filter.And(
Builders<CodeFileSystemEntry>.Filter.Eq(x => x.ParentPathSegments, parentPathSegments),
Builders<CodeFileSystemEntry>.Filter.Regex(
x => x.Name,
new BsonRegularExpression("^readme\\.md$", "i")
)
);
var readme = await repository.SearchFor(filter).FirstOrDefaultAsync(cancellationToken);
return readme?.FileContent;
}
private static string BuildPathKey(IEnumerable<string> segments)
{
return string.Join("/", segments);
}
/// <summary>
/// 读取文本文件内容
/// </summary>
private async Task<string?> ReadTextContentAsync(
FileInfo fileInfo,
CancellationToken cancellationToken
)
{
try
{
using var reader = fileInfo.OpenText();
return await reader.ReadToEndAsync(cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "读取文件内容失败: {FilePath}", fileInfo.FullName);
return null;
}
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
通过对源码、接口、实体及相关调用方的交叉阅读,下面是对 CodeFileSystemEntryService.cs 的完整解读。
一句话定位
它是「代码文件浏览器/代码仓库管理」业务的服务层实现:把磁盘上的源码树(由 SyncCodeFileSystemEntriesRequest.cs 同步进 MongoDB 的 CodeFileSystemEntry 集合)对外提供浏览、搜索、分页、描述编辑、AI 分析判断等能力,并全程用 FusionCache 做 7 天缓存。
依赖与协作关系
- 数据实体 CodeFileSystemEntry.cs:每条记录代表一个目录或文件,核心字段是
PathSegments(完整路径分段)、ParentPathSegments、IsDirectory、FileContent(已入库的文本内容)、Hash、AI 分析字段等;它声明了PathSegments / ParentPathSegments / Name / IsDirectory相关索引。 - 接口 ICodeFileSystemEntryService.cs:本类实现的全部公共方法与之对应。
- 通用仓库
IRepository<CodeFileSystemEntry>+ MongoDB 驱动(Builders、BsonRegularExpression、ToPagedListAsync等)。 - 配置
CodeViewOption(见 CodeViewOption.cs):SourceCodeRoot(源码根目录)与ExtensionToLanguage(扩展名→语言映射),由_codeViewCfg惰性加载,配置无效直接抛InvalidConfigurationException。 IFusionCache:所有查询类方法都先查缓存,键前缀/标签来自源码生成器生成的GeneratedCodeFileSystemEntryServiceCacheMetadata(该类型是 Dpz.Core.SourceGenerator 增量生成器产物,源码树中搜索不到其定义;同步处理程序里也用"Dpz.Core.Service.RepositoryServiceImpl.CodeFileSystemEntryService"作为同一套缓存命名空间前缀)。- 静态工具:
- CodeContentSearch.cs:内容搜索的“查询规范化/转义/片段截取”逻辑(转义避免 Mongo 正则炸弹、上限 20 条、命中片段半径 80)。
- ApplicationTools.cs 的
WildcardToRegexPattern:把*/?转成 glob 式正则。
- 主要调用方:MVC 与 API 的 CodeController.cs(另一份在 src/Dpz.Core.Web/Controllers/CodeController.cs)、CodeNoteTreeExtensions.cs(构造树形浏览)、CodeExplorerMcpTools.cs(MCP 工具)、以及 WebJobs 的 AnalyzeCodeHandler.cs(写回 AI 分析结果)。
方法按职责分组讲解
1. 路径/实体浏览(读 + 缓存)
FindByPathAsync:规范化空分段 → 拼缓存键 → 走GetOrSetCacheAsync。FindByPathWithoutCacheAsync:同一核心逻辑但绕过缓存(用于需要最新状态的场景)。- 核心
FindByPathCoreAsync的查找策略是两段式:- 先做精确匹配(
PathSegments == segments,区分大小写); - 未命中且分段非空时,退化为不区分大小写的正则匹配:对每个分段生成
^转义串$+i选项,构造{ $size: n, $all: [...] }的文档过滤器,保证顺序和数量都匹配;DEBUG下把渲染出的 BSON 查询打日志便于排查。 - 若实体是文件,先调
FixPreviewContentAsync尝试“自愈”内容;若是目录,再尝试解析该目录下的 README.md(忽略大小写)作为ReadmeContent。
- 先做精确匹配(
GetChildrenAsync:按ParentPathSegments == segments取子项;返回前批量解析各子目录的 README 映射(ResolveReadmeContentMapAsync:一条$in+^readme\.md$的查询),避免 N+1。
2. 搜索
SearchAsync(按文件名):由BuildSearchPattern处理关键字——含*/?时整体锚定首尾(glob 语义:整名匹配),否则子串匹配;走缓存。SearchContentAsync(按文件内容,不缓存):仅查IsDirectory=false && CodeFileContentType==Text,对FileContent用字面量转义后的不区分大小写正则(借助 CodeContentSearch.cs 防止用户输入成为正则炸弹),Limit上限 20,再截取关键词周边片段组装CodeContentSearchHitResponse。
3. 管理/后台
GetPagedListAsync:用 [CodeFlatRequest](GenerateConditionFilter.GenerateConditionExpressionTree动态表达式树)生成过滤条件,按SortField(Size/CreatedTime/LastWriteTime/LastUpdateTime/AiAnalyzeTime)+ 升降序排序后分页;随后逐条补算CodeLanguage(列表视图不返回正文)。GetAllDirectoriesAsync:查所有目录(或指定前缀下的目录),在内存里用SequenceEqual前缀过滤后按“深度→路径字符串”排序返回,供前端下拉/树选择。
4. 写操作与缓存失效
SaveDescriptionAsync/SaveAiAnalyzeResultAsync:参数语义一致——path是父路径,name是自身名,拼接成完整PathSegments后定位实体并UpdateAsync;随后做两级缓存清理:InvalidateEntryCachesAsync(清掉该条目FindByPathAsync缓存 + 父目录GetChildrenAsync缓存)和InvalidateSearchCacheAsync(按 name 清文件名搜索缓存)。ShouldAnalyzeAsync:AI 分析触发决策。条件链:全局开关CodeUseAIAnalyze→ 代码非空 → 行数 ≥ 50 → 文件名不含.min→ 语言属于 csharp/javascript/typescript → 库里该条目存在;最后判断:无历史结果、或AiAnalyzeHash != 当前 Hash时返回 true(内容已变需重分析),否则 false。注意此处path语义与“保存类”方法不同:直接拿path(调用方传入的是含自身文件名的完整路径)作为PathSegments查询,未再拼接name。
5. 缓存基础设施(私有)
GetOrSetCacheAsync<T>:统一封装 FusionCache 的GetOrSetAsync,缓存时长与 fail-safe 均为 7 天;写入时附加GeneratedCodeFileSystemEntryServiceCacheMetadata提供的默认前缀和方法级 Tag。BuildCacheKey:前缀:方法名:参数,例如...:FindByPathAsync:Path=a/b。
6. 语言识别
ResolveCodeLanguage系列:目录不识别;文件按“扩展名(或文件名)→配置语言”映射。键会同时生成“带点/不带点”两种形式(cs与.cs),匹配忽略大小写、先精确后遍历。
7. 内容自愈(对磁盘的兜底读取)
FixPreviewContentAsync:当某文件按配置应可预览,但库里FileContent为空或内容类型不是 Text(历史旧数据常见)时,回到CodeView.SourceCodeRoot下读物理文件并回写数据库。这是对“入库数据不全”的兜底。ResolveSourceFileInfo:把PathSegments逐个Path.Combine拼到源码根下,并用Path.GetFullPath+ 前缀比较做目录穿越防护(拼接结果不得跳出根目录)。ReadTextContentAsync:读文本失败只记警告并返回 null,不影响主流程。
8. README 解析
ResolveReadmeContentAsync/ResolveReadmeContentMapAsync:按“父路径 +^readme\.md$(i)”查 README 内容,前者单查(详情页目录),后者批量$in查(子项列表),用于浏览目录时直接展示说明。
端到端调用流程
浏览路径时:Controller → BuildCodeNoteTreeAsync(CodeNoteTreeExtensions.cs)→ FindByPathAsync + GetChildrenAsync;当判定是文件且 ShouldAnalyzeAsync=true 时,Controller 发布 AnalyzeCodeMessage,Jobs 的 AnalyzeCodeHandler.cs 分析完成后回调 SaveAiAnalyzeResultAsync 并触发缓存清理。数据源头则由 Mediator 的 Sync 处理器扫描磁盘写入/更新/删除集合记录。
flowchart TD
subgraph 数据入口
Sync[SyncCodeFileSystemEntriesHandler 扫描磁盘] -->|写入/更新/删除| Mongo[(MongoDB CodeFileSystemEntry)]
end
subgraph 读路径-浏览与搜索
C1[Web/WebApi CodeController / MCP工具 / CodeNoteTreeExtensions]
C1 -->|FindByPathAsync / GetChildrenAsync / SearchAsync / GetAllDirectoriesAsync| G1{GetOrSetCacheAsync<br/>FusionCache 命中?}
G1 -->|命中| R1[直接返回缓存]
G1 -->|未命中| DB1[按 Mongo 过滤查询<br/>FindByPathCore 先精确后不区分大小写正则]
DB1 --> D1{找到条目?}
D1 -->|否| NullRet[返回 null]
D1 -->|是|
AI 正在分析代码…
评论加载中...