feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试

## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
This commit is contained in:
MingNian
2026-07-15 23:22:52 +08:00
parent e654c1e0cc
commit fade61ac21
138 changed files with 12636 additions and 5013 deletions

View File

@@ -43,7 +43,7 @@ public static class MedicationAgentHandler
return toolName switch
{
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
_ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}

View File

@@ -157,9 +157,9 @@ public sealed class PromptManager
""";
private const string UnifiedPrompt = """
AI
AI
1. //// record_health_data
@@ -182,10 +182,18 @@ public sealed class PromptManager
-
- +
-
- record_health_data98%
- record_health_data116/89
-
-
- record_health_data
-
- pendingConfirmation=true
-
- ///
- 2-5
-
-
-
""";
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddHealthArchiveSurgeryStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SurgeryHistoryStatus",
table: "HealthArchives",
type: "character varying(8)",
maxLength: 8,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SurgeryHistoryStatus",
table: "HealthArchives");
}
}
}

View File

@@ -486,6 +486,10 @@ namespace Health.Infrastructure.Data.Migrations
b.Property<DateOnly?>("SurgeryDate")
.HasColumnType("date");
b.Property<string>("SurgeryHistoryStatus")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("SurgeryType")
.HasColumnType("text");

View File

@@ -170,6 +170,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
builder.Entity<HealthArchive>(e =>
{
e.HasIndex(a => a.UserId).IsUnique();
e.Property(a => a.SurgeryHistoryStatus).HasMaxLength(8);
});
builder.Entity<HealthArchiveSurgery>(e =>

View File

@@ -0,0 +1,284 @@
using Health.Application.Notifications;
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Infrastructure.Notifications;
public sealed class EfReminderCatchUpService(AppDbContext db) : IReminderCatchUpService
{
private readonly AppDbContext _db = db;
public async Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct)
{
var pref = await GetPreferenceAsync(userId, ct);
if (!pref.PushEnabled) return new ReminderCatchUpResult(0);
var beijingNow = utcNow.AddHours(8);
var today = DateOnly.FromDateTime(beijingNow);
var todayStartUtc = beijingNow.Date.AddHours(-8);
var todayEndUtc = todayStartUtc.AddDays(1);
var created = 0;
created += await CreateHealthRecordReminderAsync(userId, pref, beijingNow, todayStartUtc, todayEndUtc, ct);
created += await CreateMedicationRemindersAsync(userId, pref, beijingNow, today, todayStartUtc, todayEndUtc, ct);
created += await CreateExerciseRemindersAsync(userId, pref, beijingNow, today, ct);
created += await CreateFollowUpRemindersAsync(userId, pref, beijingNow, ct);
if (created > 0)
await _db.SaveChangesAsync(ct);
return new ReminderCatchUpResult(created);
}
private async Task<NotificationPreference> GetPreferenceAsync(Guid userId, CancellationToken ct)
{
var pref = await _db.NotificationPreferences.FirstOrDefaultAsync(x => x.UserId == userId, ct);
if (pref != null) return pref;
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
await _db.NotificationPreferences.AddAsync(pref, ct);
return pref;
}
private async Task<int> CreateHealthRecordReminderAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
DateTime todayStartUtc,
DateTime todayEndUtc,
CancellationToken ct)
{
if (!pref.HealthRecordReminder || IsInDnd(pref, beijingNow)) return 0;
var window = HealthRecordWindow(beijingNow);
if (window == null) return 0;
var recordedTypes = await _db.HealthRecords.AsNoTracking()
.Where(r => r.UserId == userId && r.RecordedAt >= todayStartUtc && r.RecordedAt < todayEndUtc)
.Select(r => r.MetricType)
.Distinct()
.ToListAsync(ct);
var missing = EnabledHealthMetrics(pref)
.Where(x => !recordedTypes.Contains(x.Type))
.ToList();
if (missing.Count == 0) return 0;
var date = DateOnly.FromDateTime(beijingNow);
var sourceId = StableGuid($"health_record|{userId:N}|{date:yyyyMMdd}|{window}");
return await AddNotificationIfMissingAsync(
userId,
sourceId,
"health_record_reminder",
window == "evening" ? "今日健康指标还未完成" : "记得录入今日健康指标",
$"建议录入:{string.Join("", missing.Select(x => x.Label))}",
"info",
"health",
null,
ct);
}
private async Task<int> CreateMedicationRemindersAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
DateOnly today,
DateTime todayStartUtc,
DateTime todayEndUtc,
CancellationToken ct)
{
if (!pref.MedicationReminder || IsInDnd(pref, beijingNow)) return 0;
var now = TimeOnly.FromDateTime(beijingNow);
var earliest = now.AddHours(-4);
var medications = await _db.Medications.AsNoTracking()
.Where(m => m.UserId == userId && m.IsActive)
.ToListAsync(ct);
var created = 0;
foreach (var medication in medications.Where(m => IsMedicationActiveOn(m, today) && ShouldDoseToday(m, today)))
{
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInTimeWindow(t, earliest, now)))
{
var logged = await _db.MedicationLogs.AsNoTracking().AnyAsync(l =>
l.UserId == userId &&
l.MedicationId == medication.Id &&
l.ScheduledTime == scheduledTime &&
l.CreatedAt >= todayStartUtc &&
l.CreatedAt < todayEndUtc, ct);
if (logged) continue;
var sourceId = StableGuid($"medication|{userId:N}|{medication.Id:N}|{today:yyyyMMdd}|{scheduledTime:HHmm}");
var details = string.Join(" ", new[] { medication.Dosage, scheduledTime.ToString("HH:mm") }
.Where(x => !string.IsNullOrWhiteSpace(x)));
created += await AddNotificationIfMissingAsync(
userId,
sourceId,
"MedicationReminder",
"用药时间到了",
$"请按计划服用{medication.Name}{(details.Length > 0 ? $"{details}" : "")},服用后记得打卡。",
"info",
"medication",
medication.Id.ToString(),
ct);
}
}
return created;
}
private async Task<int> CreateExerciseRemindersAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
DateOnly today,
CancellationToken ct)
{
if (IsInDnd(pref, beijingNow)) return 0;
var now = TimeOnly.FromDateTime(beijingNow);
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
.Include(x => x.Plan)
.Where(x => x.Plan.UserId == userId &&
x.ScheduledDate == today &&
!x.IsCompleted &&
!x.IsRestDay &&
x.Plan.ReminderTime <= now)
.ToListAsync(ct);
var created = 0;
foreach (var item in dueItems)
{
var sourceId = StableGuid($"exercise|{userId:N}|{item.Id:N}|{today:yyyyMMdd}");
created += await AddNotificationIfMissingAsync(
userId,
sourceId,
"ExerciseReminder",
"今日运动计划",
$"今天安排了{item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
"info",
"exercise",
item.PlanId.ToString(),
ct);
}
return created;
}
private async Task<int> CreateFollowUpRemindersAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
CancellationToken ct)
{
if (!pref.FollowUpReminder || IsInDnd(pref, beijingNow)) return 0;
var nowUtc = beijingNow.AddHours(-8);
var upcomingEndUtc = nowUtc.AddHours(24);
var followUps = await _db.FollowUps.AsNoTracking()
.Where(x => x.UserId == userId && x.ScheduledAt >= nowUtc && x.ScheduledAt <= upcomingEndUtc)
.ToListAsync(ct);
var created = 0;
foreach (var followUp in followUps)
{
var sourceId = StableGuid($"followup|{userId:N}|{followUp.Id:N}|24h");
created += await AddNotificationIfMissingAsync(
userId,
sourceId,
"follow_up_reminder",
"复查提醒",
$"{followUp.Title} 即将到期,请提前安排时间。",
"info",
"followup",
followUp.Id.ToString(),
ct);
}
return created;
}
private async Task<int> AddNotificationIfMissingAsync(
Guid userId,
Guid sourceId,
string type,
string title,
string message,
string severity,
string? actionType,
string? actionTargetId,
CancellationToken ct)
{
if (await _db.UserNotifications.AsNoTracking().AnyAsync(x => x.SourceId == sourceId, ct))
return 0;
await _db.UserNotifications.AddAsync(new UserNotification
{
Id = Guid.NewGuid(),
UserId = userId,
SourceId = sourceId,
Type = type,
Title = title,
Message = message,
Severity = severity,
ActionType = actionType,
ActionTargetId = actionTargetId,
CreatedAt = DateTime.UtcNow,
}, ct);
return 1;
}
private static string? HealthRecordWindow(DateTime beijingNow)
{
var minutes = beijingNow.Hour * 60 + beijingNow.Minute;
if (minutes is >= 7 * 60 and <= 10 * 60 + 30) return "morning";
if (minutes is >= 11 * 60 + 30 and <= 14 * 60) return "noon";
if (minutes is >= 18 * 60 and <= 21 * 60 + 30) return "evening";
return null;
}
private static IReadOnlyList<(HealthMetricType Type, string Label)> EnabledHealthMetrics(NotificationPreference pref)
{
var result = new List<(HealthMetricType, string)>();
if (pref.HealthRecordReminderBloodPressure) result.Add((HealthMetricType.BloodPressure, "血压"));
if (pref.HealthRecordReminderHeartRate) result.Add((HealthMetricType.HeartRate, "心率"));
if (pref.HealthRecordReminderGlucose) result.Add((HealthMetricType.Glucose, "血糖"));
if (pref.HealthRecordReminderSpO2) result.Add((HealthMetricType.SpO2, "血氧"));
if (pref.HealthRecordReminderWeight) result.Add((HealthMetricType.Weight, "体重"));
return result;
}
private static bool IsInDnd(NotificationPreference pref, DateTime beijingNow)
{
if (!pref.DndEnabled) return false;
var minuteOfDay = beijingNow.Hour * 60 + beijingNow.Minute;
return pref.DndStartMinutes <= pref.DndEndMinutes
? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes
: minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes;
}
private static bool IsMedicationActiveOn(Medication medication, DateOnly date) =>
(medication.StartDate == null || medication.StartDate <= date) &&
(medication.EndDate == null || medication.EndDate >= date);
private static bool ShouldDoseToday(Medication medication, DateOnly today)
{
if (medication.Frequency is MedicationFrequency.Daily
or MedicationFrequency.TwiceDaily
or MedicationFrequency.ThreeTimesDaily
or MedicationFrequency.AsNeeded)
return true;
var startDate = medication.StartDate ?? today;
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
if (medication.Frequency == MedicationFrequency.Weekly)
return today.DayOfWeek == startDate.DayOfWeek;
return true;
}
private static bool IsInTimeWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
end >= start ? value >= start && value <= end : value >= start || value <= end;
private static Guid StableGuid(string key)
{
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
return new Guid(bytes);
}
}

View File

@@ -15,6 +15,12 @@ public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificat
NotificationMessage message,
CancellationToken ct)
{
var pushEnabled = await _db.NotificationPreferences.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => (bool?)x.PushEnabled)
.FirstOrDefaultAsync(ct);
if (pushEnabled is false) return false;
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
return false;
@@ -67,7 +73,7 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
.SetProperty(x => x.LastError, "Notification task timed out and reached max retry count")
.SetProperty(x => x.UpdatedAt, now), ct);
}
@@ -81,61 +87,55 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
.FirstOrDefaultAsync(ct);
if (candidate == null) return false;
var claimed = await _db.NotificationOutbox
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
if (claimed == 0) return true;
var task = await _db.NotificationOutbox
.FirstOrDefaultAsync(x => x.Id == candidate.Id && x.Status == "Pending", ct);
if (task == null) return true;
task.Status = "Processing";
task.Attempts += 1;
task.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
try
{
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
?? throw new InvalidOperationException("通知消息内容为空");
var message = JsonSerializer.Deserialize<NotificationMessage>(task.Payload)
?? throw new InvalidOperationException("Notification payload is empty");
var preference = await _db.NotificationPreferences.AsNoTracking()
.FirstOrDefaultAsync(x => x.UserId == candidate.UserId, ct);
.FirstOrDefaultAsync(x => x.UserId == task.UserId, ct);
var deliver = preference == null || ShouldDeliver(preference, message.Type, DateTime.UtcNow.AddHours(8));
if (deliver && !await _db.UserNotifications.AsNoTracking()
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
.AnyAsync(x => x.SourceId == task.SourceTaskId, ct))
{
await _db.UserNotifications.AddAsync(new UserNotification
{
Id = Guid.NewGuid(),
UserId = candidate.UserId,
SourceId = candidate.SourceTaskId,
UserId = task.UserId,
SourceId = task.SourceTaskId,
Type = message.Type,
Title = message.Title,
Message = message.Message,
Severity = message.Severity,
ActionType = message.ActionType,
ActionTargetId = message.ActionTargetId,
CreatedAt = candidate.CreatedAt,
CreatedAt = task.CreatedAt,
}, ct);
// Persist the inbox item first. If completion fails, stale-task
// recovery can safely retry because SourceId is idempotent.
await _db.SaveChangesAsync(ct);
}
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.LastError, (string?)null)
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
task.Status = "Completed";
task.LastError = null;
task.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
var attempts = candidate.Attempts + 1;
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
.SetProperty(x => x.AvailableAt,
DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5))
.SetProperty(x => x.LastError,
ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message)
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
task.Status = task.Attempts >= MaxAttempts ? "Failed" : "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
task.LastError = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
task.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
return true;
@@ -151,14 +151,25 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
: minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes);
if (inDnd) return false;
return type switch
return NormalizeType(type) switch
{
"medication_reminder" => preference.MedicationReminder,
"exercise_reminder" => true,
"follow_up_reminder" => preference.FollowUpReminder,
"doctor_reply" => preference.DoctorReply,
"abnormal_alert" => preference.AbnormalAlert,
"health_record_reminder" => preference.HealthRecordReminder,
"health_metric_alert" => preference.AbnormalAlert,
_ => true,
};
}
private static string NormalizeType(string type) => type switch
{
"MedicationReminder" => "medication_reminder",
"ExerciseReminder" => "exercise_reminder",
"HealthMetricAlert" => "health_metric_alert",
"FollowUpReminder" => "follow_up_reminder",
_ => type.ToLowerInvariant(),
};
}

View File

@@ -9,6 +9,44 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
public async Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
{
var fileUrls = new List<string>();
var userAvatar = await _db.Users
.Where(u => u.Id == userId)
.Select(u => u.AvatarUrl)
.FirstOrDefaultAsync(ct);
AddIfPresent(fileUrls, userAvatar);
var doctorProfile = await _db.DoctorProfiles
.Where(p => p.UserId == userId)
.Select(p => new { p.DoctorId, p.AvatarUrl })
.FirstOrDefaultAsync(ct);
AddIfPresent(fileUrls, doctorProfile?.AvatarUrl);
if (doctorProfile?.DoctorId != null)
{
var doctorAvatar = await _db.Doctors
.Where(d => d.Id == doctorProfile.DoctorId)
.Select(d => d.AvatarUrl)
.FirstOrDefaultAsync(ct);
AddIfPresent(fileUrls, doctorAvatar);
}
fileUrls.AddRange(await _db.Reports
.Where(r => r.UserId == userId && r.FileUrl != "")
.Select(r => r.FileUrl)
.ToListAsync(ct));
var metadata = await _db.ConversationMessages
.Where(m => m.Conversation!.UserId == userId && m.MetadataJson != null && m.MetadataJson != "")
.Select(m => m.MetadataJson!)
.ToListAsync(ct);
return new AccountFileReferences(fileUrls.Distinct().ToList(), metadata);
}
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
@@ -50,4 +88,9 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
await transaction.CommitAsync(ct);
}
private static void AddIfPresent(List<string> values, string? value)
{
if (!string.IsNullOrWhiteSpace(value)) values.Add(value);
}
}

View File

@@ -0,0 +1,83 @@
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)
{
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase);
foreach (var metadataJson in references.ConversationMetadataJson)
AddMetadataUrls(fileUrls, metadataJson);
foreach (var fileUrl in fileUrls)
{
ct.ThrowIfCancellationRequested();
var localPath = ResolveLocalPath(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<string> 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<string> 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? ResolveLocalPath(string fileUrl)
{
var urlPath = fileUrl;
if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri))
urlPath = absoluteUri.AbsolutePath;
var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase);
if (uploadsIndex < 0) return null;
string relativePath;
try
{
relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]);
}
catch (UriFormatException)
{
return null;
}
var queryIndex = relativePath.IndexOfAny(['?', '#']);
if (queryIndex >= 0) relativePath = relativePath[..queryIndex];
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath));
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
}
}