using System.Text.Json; using Health.Application.Users; namespace Health.Infrastructure.Users; public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCleanup { private readonly string _uploadsRoot = Path.GetFullPath(uploadsRoot); public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct) { // 正式报告路径来自服务端生成的报告记录,只允许删除 reports 目录中的文件。 foreach (var fileUrl in references.FileUrls) { ct.ThrowIfCancellationRequested(); var localPath = ResolveReportPath(fileUrl); if (localPath != null && File.Exists(localPath)) File.Delete(localPath); } // 对话元数据可能包含客户端传入的 URL,只允许解析当前账号自己的目录。 var attachmentUrls = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var metadataJson in references.ConversationMetadataJson) AddMetadataUrls(attachmentUrls, metadataJson); foreach (var fileUrl in attachmentUrls) { ct.ThrowIfCancellationRequested(); var localPath = ResolveOwnedAttachmentPath(userId, fileUrl); if (localPath != null && File.Exists(localPath)) File.Delete(localPath); } ct.ThrowIfCancellationRequested(); var userDirectory = Path.Combine(_uploadsRoot, "users", userId.ToString("N")); if (Directory.Exists(userDirectory)) Directory.Delete(userDirectory, recursive: true); return Task.CompletedTask; } private static void AddMetadataUrls(HashSet fileUrls, string metadataJson) { try { using var document = JsonDocument.Parse(metadataJson); AddStringProperty(document.RootElement, "imageUrl", fileUrls); AddStringProperty(document.RootElement, "pdfUrl", fileUrls); } catch (JsonException) { // 旧消息元数据可能不是合法 JSON;不影响其余账号数据注销。 } } private static void AddStringProperty(JsonElement root, string propertyName, HashSet fileUrls) { if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) return; var value = property.GetString(); if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value); } private string? ResolveReportPath(string fileUrl) { try { var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute) ? absolute.AbsolutePath : fileUrl.Split('?', 2)[0]; var prefixIndex = path.IndexOf("/uploads/reports/", StringComparison.OrdinalIgnoreCase); if (prefixIndex < 0) return null; var fileName = Uri.UnescapeDataString(path[(prefixIndex + "/uploads/reports/".Length)..]); return ResolveInside(Path.Combine(_uploadsRoot, "reports"), fileName); } catch (ArgumentException) { return null; } catch (UriFormatException) { return null; } } private string? ResolveOwnedAttachmentPath(Guid userId, string fileUrl) { try { var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute) ? absolute.AbsolutePath : fileUrl.Split('?', 2)[0]; path = Uri.UnescapeDataString(path); var protectedPrefix = "/api/files/content/"; var protectedIndex = path.IndexOf(protectedPrefix, StringComparison.OrdinalIgnoreCase); var legacyPrefix = $"/uploads/users/{userId:N}/"; var legacyIndex = path.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase); var fileName = protectedIndex >= 0 ? path[(protectedIndex + protectedPrefix.Length)..] : legacyIndex >= 0 ? path[(legacyIndex + legacyPrefix.Length)..] : null; return fileName == null ? null : ResolveInside(Path.Combine(_uploadsRoot, "users", userId.ToString("N")), fileName); } catch (ArgumentException) { return null; } catch (UriFormatException) { return null; } } private static string? ResolveInside(string root, string fileName) { if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null; var fullRoot = Path.GetFullPath(root); var fullPath = Path.GetFullPath(Path.Combine(fullRoot, fileName)); var rootPrefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null; } }