From 9cea41705e60304984d309ec361fdc667e521ac6 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Mon, 20 Jul 2026 10:19:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=87=E4=BB=B6=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA=20+=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20+=20=E5=AA=92=E4=BD=93URL=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=20+=20provider=20=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 后端安全加固 - 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越 - LocalReportFileStorage: 文件存储路径安全加固 - local_account_file_cleanup: 账号删除时文件清理逻辑增强 - AuthService: 认证逻辑增强 - file_endpoints / report_endpoints: 文件访问接口安全加固 - ai_chat_endpoints / doctor_endpoints: 接口安全调整 - Program.cs: 服务注册调整 ## 前端认证与媒体 - 新增 authenticated_network_image.dart: 带认证的图片加载组件 - auth_provider: 认证状态管理大幅增强(+173) - api_client: 网络客户端增强(+124) - chat_provider: 聊天 provider 重构(+76) - omron_device_provider: 蓝牙设备 provider 增强(+53) - sse_handler: SSE 处理增强(+35) - consultation_provider / data_providers / conversation_history_provider: 调整 ## 页面调整 - remaining_pages: 健康档案/饮食记录等页面增强(+115) - home_page / chat_messages_view: 主页微调 - doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports) - report_pages / settings_pages / notification_prefs_page: 微调 - device_scan_page / diet_capture_page / admin_home_page: 微调 ## 测试 - 新增 file_path_security_tests: 文件路径安全测试 - 新增 protected_media_url_test: 媒体URL保护测试 - 新增 user_session_identity_test: 用户会话身份测试 - account_deletion_tests / application_service_tests / auth_tests: 更新 --- .../AI/AttachmentContextContracts.cs | 2 +- .../Calendars/CalendarService.cs | 13 +- .../Reports/ReportService.cs | 2 +- .../AI/AttachmentContextBuilder.cs | 27 +-- .../Health.Infrastructure/Auth/AuthService.cs | 29 ++- .../Calendars/EfCalendarRepository.cs | 5 +- .../Files/UserUploadPathResolver.cs | 64 +++++++ .../Reports/LocalReportFileStorage.cs | 20 +- .../Users/local_account_file_cleanup.cs | 85 ++++++--- .../Endpoints/ai_chat_endpoints.cs | 25 +-- .../Endpoints/doctor_endpoints.cs | 22 ++- .../Health.WebApi/Endpoints/file_endpoints.cs | 30 ++- .../Endpoints/report_endpoints.cs | 46 +++++ backend/src/Health.WebApi/Program.cs | 10 +- .../Health.Tests/account_deletion_tests.cs | 8 +- .../Health.Tests/application_service_tests.cs | 18 ++ backend/tests/Health.Tests/auth_tests.cs | 43 +++++ .../Health.Tests/file_path_security_tests.cs | 40 ++++ health_app/lib/core/api_client.dart | 124 ++++++++++--- .../lib/pages/admin/admin_home_page.dart | 7 +- .../lib/pages/device/device_scan_page.dart | 5 +- .../lib/pages/diet/diet_capture_page.dart | 5 +- .../doctor/doctor_consultations_page.dart | 1 + .../pages/doctor/doctor_dashboard_page.dart | 1 + .../doctor/doctor_followup_edit_page.dart | 8 +- .../pages/doctor/doctor_followups_page.dart | 7 +- .../lib/pages/doctor/doctor_home_page.dart | 7 +- .../doctor/doctor_patient_detail_page.dart | 1 + .../lib/pages/doctor/doctor_profile_page.dart | 1 + .../doctor/doctor_report_detail_page.dart | 1 + .../lib/pages/doctor/doctor_reports_page.dart | 1 + health_app/lib/pages/home/home_page.dart | 12 +- .../home/widgets/chat_messages_view.dart | 27 +-- health_app/lib/pages/remaining_pages.dart | 115 +++++++++--- health_app/lib/pages/report/report_pages.dart | 12 +- .../settings/notification_prefs_page.dart | 3 +- .../lib/pages/settings/settings_pages.dart | 2 + health_app/lib/providers/auth_provider.dart | 175 ++++++++++++++++-- health_app/lib/providers/chat_provider.dart | 76 ++++++-- .../lib/providers/consultation_provider.dart | 19 +- .../conversation_history_provider.dart | 4 +- health_app/lib/providers/data_providers.dart | 9 + .../lib/providers/omron_device_provider.dart | 53 +++++- .../services/in_app_notification_service.dart | 2 +- health_app/lib/utils/sse_handler.dart | 35 +++- .../widgets/authenticated_network_image.dart | 131 +++++++++++++ health_app/test/protected_media_url_test.dart | 34 ++++ .../test/user_session_identity_test.dart | 26 +++ 48 files changed, 1181 insertions(+), 212 deletions(-) create mode 100644 backend/src/Health.Infrastructure/Files/UserUploadPathResolver.cs create mode 100644 backend/tests/Health.Tests/file_path_security_tests.cs create mode 100644 health_app/lib/widgets/authenticated_network_image.dart create mode 100644 health_app/test/protected_media_url_test.dart create mode 100644 health_app/test/user_session_identity_test.dart diff --git a/backend/src/Health.Application/AI/AttachmentContextContracts.cs b/backend/src/Health.Application/AI/AttachmentContextContracts.cs index f81124f..6330015 100644 --- a/backend/src/Health.Application/AI/AttachmentContextContracts.cs +++ b/backend/src/Health.Application/AI/AttachmentContextContracts.cs @@ -16,5 +16,5 @@ public interface IAttachmentContextBuilder /// /// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。 /// - Task BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct); + Task BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct); } diff --git a/backend/src/Health.Application/Calendars/CalendarService.cs b/backend/src/Health.Application/Calendars/CalendarService.cs index 28d344c..cb207f1 100644 --- a/backend/src/Health.Application/Calendars/CalendarService.cs +++ b/backend/src/Health.Application/Calendars/CalendarService.cs @@ -40,7 +40,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer .Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate }) .ToList(); var followUps = snapshot.FollowUps - .Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date) + .Where(f => BeijingDate(f.ScheduledAt) == date) .Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() }) .ToList(); @@ -67,7 +67,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate })); } - foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)) + foreach (var followUp in snapshot.FollowUps.Where(f => BeijingDate(f.ScheduledAt) == date)) { entries.Add(new CalendarEntry("followup", new { @@ -88,5 +88,14 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer && (medication.StartDate == null || medication.StartDate <= date) && (medication.EndDate == null || medication.EndDate >= date); + private static DateOnly BeijingDate(DateTime value) + { + // EF 从 timestamptz 读取的是 UTC;单元测试及旧内存对象可能是 Unspecified。 + var beijing = value.Kind == DateTimeKind.Unspecified + ? value + : value.ToUniversalTime().AddHours(8); + return DateOnly.FromDateTime(beijing); + } + private sealed record CalendarEntry(string Type, object Value); } diff --git a/backend/src/Health.Application/Reports/ReportService.cs b/backend/src/Health.Application/Reports/ReportService.cs index 56bd14b..f0610bd 100644 --- a/backend/src/Health.Application/Reports/ReportService.cs +++ b/backend/src/Health.Application/Reports/ReportService.cs @@ -94,7 +94,7 @@ public sealed class ReportService( public static ReportDto ToDto(Report report) => new( report.Id, report.UserId, - report.FileUrl, + $"/api/reports/{report.Id}/file", report.FileType.ToString(), report.Category.ToString(), report.Status.ToString(), diff --git a/backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs b/backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs index f35f1e1..8f1ee8e 100644 --- a/backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs +++ b/backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Health.Application.AI; +using Health.Infrastructure.Files; using Microsoft.Extensions.Logging; using UglyToad.PdfPig; @@ -19,19 +20,19 @@ public sealed class AttachmentContextBuilder( private readonly VisionClient _vision = vision; private readonly ILogger _logger = logger; - public async Task BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct) + public async Task BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct) { if (!string.IsNullOrWhiteSpace(imageUrl)) - return await BuildImageAsync(imageUrl!, ct); + return await BuildImageAsync(userId, imageUrl!, ct); if (!string.IsNullOrWhiteSpace(pdfUrl)) - return await BuildPdfAsync(pdfUrl!, ct); + return await BuildPdfAsync(userId, pdfUrl!, ct); return null; } // ── 图片:调 VLM 输出结构化 JSON ── - private async Task BuildImageAsync(string imageUrl, CancellationToken ct) + private async Task BuildImageAsync(Guid userId, string imageUrl, CancellationToken ct) { - var filePath = ResolveLocalPath(imageUrl); + var filePath = UserUploadPathResolver.Resolve(userId, imageUrl); if (filePath == null || !File.Exists(filePath)) { _logger.LogWarning("Image file not found for {Url}", imageUrl); @@ -105,9 +106,9 @@ public sealed class AttachmentContextBuilder( } // ── PDF:PdfPig 抽取文本 ── - private Task BuildPdfAsync(string pdfUrl, CancellationToken ct) + private Task BuildPdfAsync(Guid userId, string pdfUrl, CancellationToken ct) { - var filePath = ResolveLocalPath(pdfUrl); + var filePath = UserUploadPathResolver.Resolve(userId, pdfUrl); var fileName = Path.GetFileName(pdfUrl); if (filePath == null || !File.Exists(filePath)) { @@ -150,18 +151,6 @@ public sealed class AttachmentContextBuilder( } } - private static string? ResolveLocalPath(string url) - { - // url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。 - var idx = url.IndexOf("/uploads/", StringComparison.Ordinal); - if (idx < 0) return null; - var relative = url[(idx + "/uploads/".Length)..]; - // 去掉可能的 query string - var q = relative.IndexOf('?'); - if (q >= 0) relative = relative[..q]; - return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative); - } - private static string StripCodeFence(string raw) { var t = raw.Trim(); diff --git a/backend/src/Health.Infrastructure/Auth/AuthService.cs b/backend/src/Health.Infrastructure/Auth/AuthService.cs index 134d4d7..9e11f2e 100644 --- a/backend/src/Health.Infrastructure/Auth/AuthService.cs +++ b/backend/src/Health.Infrastructure/Auth/AuthService.cs @@ -88,14 +88,39 @@ public sealed class AuthService( { var tokens = AddTokens(AdminId, AdminPhone, "Admin"); await _db.SaveChangesAsync(ct); - return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } }); + return new AuthResult(0, new + { + tokens.accessToken, + tokens.refreshToken, + user = new + { + id = AdminId, + phone = AdminPhone, + role = "Admin", + name = "管理员" + } + }); } var user = await _db.Users.FindAsync([oldToken.UserId], ct); if (user == null) return Error(40002, "用户不存在"); var userTokens = AddTokens(user.Id, user.Phone, user.Role); await _db.SaveChangesAsync(ct); - return new AuthResult(0, new { userTokens.accessToken, userTokens.refreshToken, user = new { user.Role } }); + return new AuthResult(0, new + { + userTokens.accessToken, + userTokens.refreshToken, + user = new + { + user.Id, + user.Phone, + user.Role, + user.Name, + user.Gender, + user.AvatarUrl, + BirthDate = user.BirthDate?.ToString("yyyy-MM-dd") + } + }); } public async Task LogoutAsync(string refreshToken, CancellationToken ct) diff --git a/backend/src/Health.Infrastructure/Calendars/EfCalendarRepository.cs b/backend/src/Health.Infrastructure/Calendars/EfCalendarRepository.cs index 67852ff..b329c34 100644 --- a/backend/src/Health.Infrastructure/Calendars/EfCalendarRepository.cs +++ b/backend/src/Health.Infrastructure/Calendars/EfCalendarRepository.cs @@ -8,8 +8,9 @@ public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository public async Task GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) { - var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); - var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + // 日历的日期边界按北京时间计算,数据库仍统一使用 UTC。 + var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8); + var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8); var medications = await _db.Medications .Where(m => m.UserId == userId && m.IsActive) diff --git a/backend/src/Health.Infrastructure/Files/UserUploadPathResolver.cs b/backend/src/Health.Infrastructure/Files/UserUploadPathResolver.cs new file mode 100644 index 0000000..0b10697 --- /dev/null +++ b/backend/src/Health.Infrastructure/Files/UserUploadPathResolver.cs @@ -0,0 +1,64 @@ +namespace Health.Infrastructure.Files; + +public static class UserUploadPathResolver +{ + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf" + }; + + public static string? Resolve(Guid userId, string value) + { + if (userId == Guid.Empty || string.IsNullOrWhiteSpace(value)) return null; + try + { + var path = Uri.TryCreate(value, UriKind.Absolute, out var absolute) + ? absolute.AbsolutePath + : value.Split('?', 2)[0]; + path = Uri.UnescapeDataString(path); + + string? fileName; + if (path == Path.GetFileName(path)) + { + fileName = path; + } + else + { + fileName = ExtractAfter(path, "/api/files/content/"); + if (fileName == null) + { + var legacyPrefix = $"/uploads/users/{userId:N}/"; + fileName = ExtractAfter(path, legacyPrefix); + } + } + + if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null; + if (!AllowedExtensions.Contains(Path.GetExtension(fileName))) return null; + if (!Guid.TryParse(Path.GetFileNameWithoutExtension(fileName), out _)) return null; + + var root = Path.GetFullPath(Path.Combine( + Directory.GetCurrentDirectory(), + "uploads", + "users", + userId.ToString("N"))); + var candidate = Path.GetFullPath(Path.Combine(root, fileName)); + return candidate.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + ? candidate + : null; + } + catch (ArgumentException) + { + return null; + } + catch (UriFormatException) + { + return null; + } + } + + private static string? ExtractAfter(string path, string prefix) + { + var index = path.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); + return index < 0 ? null : path[(index + prefix.Length)..]; + } +} diff --git a/backend/src/Health.Infrastructure/Reports/LocalReportFileStorage.cs b/backend/src/Health.Infrastructure/Reports/LocalReportFileStorage.cs index 6217535..5390d0f 100644 --- a/backend/src/Health.Infrastructure/Reports/LocalReportFileStorage.cs +++ b/backend/src/Health.Infrastructure/Reports/LocalReportFileStorage.cs @@ -19,8 +19,24 @@ public sealed class LocalReportFileStorage : IReportFileStorage return new StoredReportFile($"/uploads/reports/{fileName}", filePath); } - public string GetLocalFilePath(string fileUrl) => - Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/')); + public string GetLocalFilePath(string fileUrl) + { + var reportsRoot = Path.GetFullPath(Path.Combine( + Directory.GetCurrentDirectory(), + "uploads", + "reports")); + var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute) + ? absolute.AbsolutePath + : fileUrl.Split('?', 2)[0]; + var fileName = Path.GetFileName(Uri.UnescapeDataString(path)); + if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) + return Path.Combine(reportsRoot, "__invalid__"); + + var candidate = Path.GetFullPath(Path.Combine(reportsRoot, fileName)); + return candidate.StartsWith(reportsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + ? candidate + : Path.Combine(reportsRoot, "__invalid__"); + } public bool Exists(string filePath) => File.Exists(filePath); diff --git a/backend/src/Health.Infrastructure/Users/local_account_file_cleanup.cs b/backend/src/Health.Infrastructure/Users/local_account_file_cleanup.cs index 30dc1f2..2df4b5b 100644 --- a/backend/src/Health.Infrastructure/Users/local_account_file_cleanup.cs +++ b/backend/src/Health.Infrastructure/Users/local_account_file_cleanup.cs @@ -9,14 +9,23 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct) { - var fileUrls = new HashSet(references.FileUrls, StringComparer.OrdinalIgnoreCase); - foreach (var metadataJson in references.ConversationMetadataJson) - AddMetadataUrls(fileUrls, metadataJson); - - foreach (var fileUrl in fileUrls) + // 正式报告路径来自服务端生成的报告记录,只允许删除 reports 目录中的文件。 + foreach (var fileUrl in references.FileUrls) { ct.ThrowIfCancellationRequested(); - var localPath = ResolveLocalPath(fileUrl); + 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); } @@ -52,31 +61,65 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value); } - private string? ResolveLocalPath(string fileUrl) + private string? ResolveReportPath(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)..]); + 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; } + } - var queryIndex = relativePath.IndexOfAny(['?', '#']); - if (queryIndex >= 0) relativePath = relativePath[..queryIndex]; - relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar); + 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; + } + } - var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath)); - var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + 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; } diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index f25b669..6a49d26 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -24,13 +24,12 @@ public static class AiChatEndpoints public static void MapAiChatEndpoints(this WebApplication app) { - // SSE 流式对话(GET 方式,token 通过 query string 传递) + // SSE 流式对话。认证统一走 ASP.NET Core JWT 中间件。 app.MapGet("/api/ai/{agentType}/chat", async ( string message, string? conversationId, string? imageUrl, string? pdfUrl, - string token, string agentType, HttpContext http, DeepSeekClient llmClient, @@ -43,8 +42,7 @@ public static class AiChatEndpoints IPatientContextService patientContexts, CancellationToken ct) => { - // 支持 token 通过 query string(浏览器 EventSource)或 header 传递 - var userId = GetUserId(http) ?? GetUserIdFromToken(token); + var userId = GetUserId(http); if (userId == null) { http.Response.StatusCode = 401; @@ -89,7 +87,7 @@ public static class AiChatEndpoints await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct); // 附件解析(图片走 VLM、PDF 走 PdfPig),结果同时拼 LLM 上下文 + 持久化到 user message metadata - var attachment = await attachments.BuildAsync(imageUrl, pdfUrl, ct); + var attachment = await attachments.BuildAsync(userId.Value, imageUrl, pdfUrl, ct); string? userMessageMetadataJson = null; if (attachment != null) { @@ -277,7 +275,7 @@ public static class AiChatEndpoints await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct); await http.Response.WriteAsync("data: [DONE]\n\n", ct); - }); + }).RequireAuthorization(); app.MapPost("/api/ai/confirm-write/{commandId:guid}", async ( Guid commandId, @@ -327,7 +325,7 @@ public static class AiChatEndpoints : Results.Json( new { code = 40401, data = (object?)null, message = "对话不存在" }, statusCode: StatusCodes.Status404NotFound); - }); + }).RequireAuthorization(); // 一键清空当前用户的全部对话 app.MapDelete("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) => @@ -421,19 +419,6 @@ public static class AiChatEndpoints private static Guid? GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null; - private static Guid? GetUserIdFromToken(string? token) - { - if (string.IsNullOrEmpty(token)) return null; - try - { - var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); - var jwt = handler.ReadJwtToken(token); - var sub = jwt.Claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - return sub != null && Guid.TryParse(sub, out var id) ? id : null; - } - catch (Exception) { return null; } - } - private static async Task TryUpdateConversationSummaryAsync( IAiConversationService conversations, DeepSeekClient llmClient, diff --git a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs index 6b55cde..92f9058 100644 --- a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs @@ -43,6 +43,20 @@ public static class DoctorEndpoints return (startUtc, startUtc.AddDays(1)); } + private static DateTime ParseBeijingDateTimeAsUtc(string value) + { + if (DateTimeOffset.TryParse(value, out var offset) && + (value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || + value.LastIndexOf('+') > 9 || + value.LastIndexOf('-') > 9)) + return offset.UtcDateTime; + + var beijing = DateTime.Parse(value); + return DateTime.SpecifyKind( + DateTime.SpecifyKind(beijing, DateTimeKind.Unspecified).AddHours(-8), + DateTimeKind.Utc); + } + public static void MapDoctorEndpoints(this WebApplication app) { var group = app.MapGroup("/api/doctor").RequireAuthorization(); @@ -304,7 +318,7 @@ public static class DoctorEndpoints query = query.Where(r => r.Status == s); var reports = await query.OrderByDescending(r => r.CreatedAt) - .Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) + .Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) .ToListAsync(); return Results.Ok(new { code = 0, data = reports, message = (string?)null }); }); @@ -317,7 +331,7 @@ public static class DoctorEndpoints return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId) - .Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) + .Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) .FirstOrDefaultAsync(); if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); return Results.Ok(new { code = 0, data = report, message = (string?)null }); @@ -391,7 +405,7 @@ public static class DoctorEndpoints Title = json.RootElement.GetProperty("title").GetString() ?? "", DoctorName = profile.Name, Department = profile.Department, - ScheduledAt = DateTime.Parse(json.RootElement.GetProperty("scheduledAt").GetString()!), + ScheduledAt = ParseBeijingDateTimeAsUtc(json.RootElement.GetProperty("scheduledAt").GetString()!), Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null, Status = FollowUpStatus.Upcoming, CreatedAt = DateTime.UtcNow @@ -415,7 +429,7 @@ public static class DoctorEndpoints var body = await reader.ReadToEndAsync(ct); var json = System.Text.Json.JsonDocument.Parse(body); if (json.RootElement.TryGetProperty("title", out var t)) followUp.Title = t.GetString() ?? followUp.Title; - if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = DateTime.Parse(sa.GetString()!); + if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = ParseBeijingDateTimeAsUtc(sa.GetString()!); if (json.RootElement.TryGetProperty("notes", out var no)) followUp.Notes = no.GetString(); if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse(st.GetString(), out var fs)) followUp.Status = fs; await db.SaveChangesAsync(ct); diff --git a/backend/src/Health.WebApi/Endpoints/file_endpoints.cs b/backend/src/Health.WebApi/Endpoints/file_endpoints.cs index 92018a6..a1367ce 100644 --- a/backend/src/Health.WebApi/Endpoints/file_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/file_endpoints.cs @@ -1,3 +1,5 @@ +using Health.Infrastructure.Files; + namespace Health.WebApi.Endpoints; public static class FileEndpoints @@ -47,24 +49,48 @@ public static class FileEndpoints var storedName = $"{fileId}{ext}"; var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}"); - await using var stream = new FileStream(filePath, FileMode.Create); + await using var stream = new FileStream(filePath, FileMode.CreateNew); await file.CopyToAsync(stream, ct); results.Add(new { id = fileId, name = file.FileName, size = file.Length, - url = $"/uploads/users/{userDirectoryName}/{storedName}", + url = $"/api/files/content/{storedName}", contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType }); } return Results.Ok(new { code = 0, data = results, message = (string?)null }); }); + + group.MapGet("/content/{fileName}", (string fileName, HttpContext http) => + { + var userId = GetUserId(http); + var filePath = UserUploadPathResolver.Resolve(userId, fileName); + if (filePath == null || !File.Exists(filePath)) + return Results.NotFound(); + + return Results.File( + filePath, + ContentTypeFor(filePath), + enableRangeProcessing: true); + }); } private static Guid GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; + + private static string ContentTypeFor(string path) => + Path.GetExtension(path).ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".webp" => "image/webp", + ".gif" => "image/gif", + ".pdf" => "application/pdf", + _ => "application/octet-stream" + }; } diff --git a/backend/src/Health.WebApi/Endpoints/report_endpoints.cs b/backend/src/Health.WebApi/Endpoints/report_endpoints.cs index e919ba4..e8656ef 100644 --- a/backend/src/Health.WebApi/Endpoints/report_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/report_endpoints.cs @@ -24,6 +24,47 @@ public static class ReportEndpoints : Results.Ok(new { code = 0, data = report, message = (string?)null }); }); + group.MapGet("/{id:guid}/file", async ( + Guid id, + HttpContext http, + AppDbContext db, + IReportFileStorage fileStorage, + CancellationToken ct) => + { + var currentUserId = GetUserId(http); + var report = await db.Reports + .Include(r => r.User) + .FirstOrDefaultAsync(r => r.Id == id, ct); + if (report == null) + return Results.NotFound(); + + var allowed = report.UserId == currentUserId; + if (!allowed && GetRole(http) == "Doctor") + { + var doctorId = await db.DoctorProfiles + .Where(profile => profile.UserId == currentUserId) + .Select(profile => profile.DoctorId) + .FirstOrDefaultAsync(ct); + allowed = doctorId != null && report.User.DoctorId == doctorId; + } + if (!allowed) + return Results.NotFound(); + + var filePath = fileStorage.GetLocalFilePath(report.FileUrl); + if (!fileStorage.Exists(filePath)) + return Results.NotFound(); + + var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".webp" => "image/webp", + ".pdf" => "application/pdf", + _ => "application/octet-stream" + }; + return Results.File(filePath, contentType, enableRangeProcessing: true); + }); + group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) => { var userId = GetUserId(http); @@ -66,4 +107,9 @@ public static class ReportEndpoints private static Guid GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; + + private static string GetRole(HttpContext http) => + http.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? + http.User.FindFirst("Role")?.Value ?? + "User"; } diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index c0c90a3..4da8344 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -34,7 +34,6 @@ using Health.WebApi.Middleware; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.FileProviders; using Microsoft.IdentityModel.Tokens; // 加载 .env 文件(开发环境) @@ -211,13 +210,8 @@ app.UseAuthorization(); app.UseDefaultFiles(); app.UseStaticFiles(); -var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); -Directory.CreateDirectory(uploadsPath); -app.UseStaticFiles(new StaticFileOptions -{ - FileProvider = new PhysicalFileProvider(uploadsPath), - RequestPath = "/uploads" -}); +// 用户上传文件不能作为静态目录公开;统一通过带鉴权和归属校验的 API 读取。 +Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), "uploads")); if (app.Environment.IsDevelopment()) app.MapOpenApi(); diff --git a/backend/tests/Health.Tests/account_deletion_tests.cs b/backend/tests/Health.Tests/account_deletion_tests.cs index 204f97d..f749274 100644 --- a/backend/tests/Health.Tests/account_deletion_tests.cs +++ b/backend/tests/Health.Tests/account_deletion_tests.cs @@ -13,8 +13,10 @@ public sealed class AccountDeletionTests var userId = Guid.NewGuid(); var userDirectory = Path.Combine(root, "users", userId.ToString("N")); var reportPath = Path.Combine(root, "reports", "owned-report.pdf"); - var chatImagePath = Path.Combine(root, "owned-chat.jpg"); - var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().ToString("N"), "keep.jpg"); + var chatFileName = $"{Guid.NewGuid()}.jpg"; + var chatImagePath = Path.Combine(userDirectory, chatFileName); + var otherUserId = Guid.NewGuid(); + var otherUserPath = Path.Combine(root, "users", otherUserId.ToString("N"), "keep.jpg"); var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt"); try @@ -31,7 +33,7 @@ public sealed class AccountDeletionTests var cleanup = new LocalAccountFileCleanup(root); var references = new AccountFileReferences( ["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"], - ["{\"imageUrl\":\"/uploads/owned-chat.jpg\"}"]); + [$"{{\"imageUrl\":\"/uploads/users/{userId:N}/{chatFileName}\"}}", $"{{\"imageUrl\":\"/uploads/users/{otherUserId:N}/keep.jpg\"}}"]); await cleanup.DeleteAsync(userId, references, CancellationToken.None); diff --git a/backend/tests/Health.Tests/application_service_tests.cs b/backend/tests/Health.Tests/application_service_tests.cs index d7a4f06..756d727 100644 --- a/backend/tests/Health.Tests/application_service_tests.cs +++ b/backend/tests/Health.Tests/application_service_tests.cs @@ -211,6 +211,24 @@ public sealed class ApplicationServiceTests Assert.Equal(["medication", "exercise", "followup"], events); } + [Fact] + public async Task Calendar_FollowUpUsesBeijingDateForUtcTimestamp() + { + var followUp = new FollowUp + { + Id = Guid.NewGuid(), + ScheduledAt = new DateTime(2026, 6, 18, 17, 0, 0, DateTimeKind.Utc), + Title = "北京时间复查" + }; + var service = new CalendarService(new FakeCalendarRepository( + new CalendarDataSnapshot([], [], [followUp]))); + + var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None); + + var target = Assert.Single(result); + Assert.Equal("2026-06-19", target.GetType().GetProperty("date")!.GetValue(target)); + } + [Fact] public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates() { diff --git a/backend/tests/Health.Tests/auth_tests.cs b/backend/tests/Health.Tests/auth_tests.cs index c5a3dc3..3278890 100644 --- a/backend/tests/Health.Tests/auth_tests.cs +++ b/backend/tests/Health.Tests/auth_tests.cs @@ -1,4 +1,6 @@ +using System.Text.Json; using Health.Domain.Entities; +using Health.Infrastructure.Auth; using Health.Infrastructure.Data; using Health.Infrastructure.Services; using Microsoft.EntityFrameworkCore; @@ -118,6 +120,47 @@ public class AuthTests Assert.NotNull(active); } + [Fact] + public async Task Refresh_Should_Return_Stable_User_Identity() + { + using var db = CreateDbContext(); + var config = CreateConfig(); + var jwt = new JwtProvider(config); + var user = new User + { + Id = Guid.NewGuid(), + Phone = "13800138000", + Role = "User", + Name = "测试用户", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + var oldRefresh = jwt.GenerateRefreshToken(); + db.Users.Add(user); + db.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + UserId = user.Id, + Token = oldRefresh, + ExpiresAt = DateTime.UtcNow.AddDays(30) + }); + await db.SaveChangesAsync(); + var service = new AuthService( + db, + jwt, + new SmsService(), + new AppleTokenValidator(config)); + + var result = await service.RefreshAsync(oldRefresh, CancellationToken.None); + + Assert.Equal(0, result.Code); + var data = JsonSerializer.SerializeToElement(result.Data); + var refreshedUser = data.GetProperty("user"); + Assert.Equal(user.Id, refreshedUser.GetProperty("Id").GetGuid()); + Assert.Equal(user.Phone, refreshedUser.GetProperty("Phone").GetString()); + Assert.Equal(user.Role, refreshedUser.GetProperty("Role").GetString()); + } + [Fact] public async Task VerificationCode_Expired_Should_Fail_Login() { diff --git a/backend/tests/Health.Tests/file_path_security_tests.cs b/backend/tests/Health.Tests/file_path_security_tests.cs new file mode 100644 index 0000000..3d79b7f --- /dev/null +++ b/backend/tests/Health.Tests/file_path_security_tests.cs @@ -0,0 +1,40 @@ +using Health.Infrastructure.Files; + +namespace Health.Tests; + +public sealed class FilePathSecurityTests +{ + private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private readonly string _fileName = "22222222-2222-2222-2222-222222222222.jpg"; + + [Fact] + public void ProtectedUrl_ResolvesInsideCurrentUserDirectory() + { + var path = UserUploadPathResolver.Resolve( + _userId, + $"/api/files/content/{_fileName}"); + + Assert.NotNull(path); + Assert.Contains(Path.Combine("users", _userId.ToString("N")), path); + Assert.EndsWith(_fileName, path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LegacyUrl_OnlyResolvesForItsOwner() + { + var legacy = $"/uploads/users/{_userId:N}/{_fileName}"; + + Assert.NotNull(UserUploadPathResolver.Resolve(_userId, legacy)); + Assert.Null(UserUploadPathResolver.Resolve(Guid.NewGuid(), legacy)); + } + + [Theory] + [InlineData("/api/files/content/../../secret.jpg")] + [InlineData("/api/files/content/not-a-guid.jpg")] + [InlineData("/api/files/content/22222222-2222-2222-2222-222222222222.exe")] + [InlineData("/uploads/reports/22222222-2222-2222-2222-222222222222.jpg")] + public void UnsafeOrUnownedPath_IsRejected(string value) + { + Assert.Null(UserUploadPathResolver.Resolve(_userId, value)); + } +} diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index c931257..030278d 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: kReleaseMode ? 'https://erpapi.datalumina.cn/xiaomai' - : 'http://10.4.165.54:5000', + : 'http://192.168.1.34:5000', ); class ApiException implements Exception { @@ -44,6 +44,7 @@ class ApiClient { final Dio _dio; final LocalDatabase _db; final AuthExpiredNotifier? _authExpiredNotifier; + Future<_TokenRefreshResult>? _refreshInFlight; ApiClient({ required LocalDatabase db, @@ -79,6 +80,63 @@ class ApiClient { await _db.delete('refresh_token'); } + Future clearTokensIfRefreshMatches(String failedRefreshToken) async { + if (await refreshToken != failedRefreshToken) return; + await clearTokens(); + } + + Future<_TokenRefreshResult> _refreshTokens() { + final active = _refreshInFlight; + if (active != null) return active; + final future = _performTokenRefresh(); + _refreshInFlight = future; + return future.whenComplete(() { + if (identical(_refreshInFlight, future)) _refreshInFlight = null; + }); + } + + Future<_TokenRefreshResult> _performTokenRefresh() async { + final refresh = await refreshToken; + if (refresh == null) return const _TokenRefreshResult.invalid(); + try { + final response = await Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 30), + ), + ).post('/api/auth/refresh', data: {'refreshToken': refresh}); + final body = response.data; + final data = body is Map ? body['data'] : null; + final rawCode = body is Map ? body['code'] : null; + final code = rawCode is int ? rawCode : int.tryParse('$rawCode'); + if (code != null && code != 0) { + return _TokenRefreshResult.invalid(refreshToken: refresh); + } + if (data is Map && + data['accessToken'] is String && + data['refreshToken'] is String) { + final accessToken = data['accessToken'] as String; + final newRefreshToken = data['refreshToken'] as String; + // 刷新期间用户可能已经退出或切换账号,旧响应不得覆盖新会话。 + if (await refreshToken != refresh) { + return const _TokenRefreshResult.transientFailure(); + } + await saveTokens(accessToken, newRefreshToken); + return _TokenRefreshResult.success(accessToken); + } + return const _TokenRefreshResult.transientFailure(); + } on DioException catch (error) { + if (error.response?.statusCode == 400 || + error.response?.statusCode == 401) { + return _TokenRefreshResult.invalid(refreshToken: refresh); + } + return const _TokenRefreshResult.transientFailure(); + } catch (_) { + return const _TokenRefreshResult.transientFailure(); + } + } + void notifyAuthExpired() { _authExpiredNotifier?.notify(); } @@ -202,31 +260,51 @@ class _AuthInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) async { - if (err.response?.statusCode == 401) { - final refresh = await _client.refreshToken; - if (refresh != null) { - try { - final response = await Dio( - BaseOptions(baseUrl: baseUrl), - ).post('/api/auth/refresh', data: {'refreshToken': refresh}); - final data = response.data['data']; - if (data != null) { - await _client.saveTokens(data['accessToken'], data['refreshToken']); - final opts = err.requestOptions; - final token = data['accessToken']; - opts.headers['Authorization'] = 'Bearer $token'; - final retryResponse = await Dio( - BaseOptions(baseUrl: baseUrl), - ).fetch(opts); - return handler.resolve(retryResponse); - } - } catch (e) { - log('[ApiClient] token刷新失败: $e'); - } + if (err.response?.statusCode != 401 || + err.requestOptions.extra['authRetried'] == true) { + return handler.next(err); + } + + final result = await _client._refreshTokens(); + if (result.accessToken != null) { + try { + final opts = err.requestOptions; + opts.extra['authRetried'] = true; + opts.headers['Authorization'] = 'Bearer ${result.accessToken}'; + final retryResponse = await _client.dio.fetch(opts); + return handler.resolve(retryResponse); + } catch (error) { + log('[ApiClient] 请求重试失败: $error'); + } + } else if (result.isInvalid) { + final failedRefresh = result.failedRefreshToken; + if (failedRefresh != null) { + await _client.clearTokensIfRefreshMatches(failedRefresh); + } else { + await _client.clearTokens(); } - await _client.clearTokens(); _client.notifyAuthExpired(); } handler.next(err); } } + +class _TokenRefreshResult { + final String? accessToken; + final bool isInvalid; + final String? failedRefreshToken; + + const _TokenRefreshResult._({ + this.accessToken, + this.isInvalid = false, + this.failedRefreshToken, + }); + + const _TokenRefreshResult.success(String accessToken) + : this._(accessToken: accessToken); + + const _TokenRefreshResult.invalid({String? refreshToken}) + : this._(isInvalid: true, failedRefreshToken: refreshToken); + + const _TokenRefreshResult.transientFailure() : this._(); +} diff --git a/health_app/lib/pages/admin/admin_home_page.dart b/health_app/lib/pages/admin/admin_home_page.dart index 266821e..9d5ce9b 100644 --- a/health_app/lib/pages/admin/admin_home_page.dart +++ b/health_app/lib/pages/admin/admin_home_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../providers/auth_provider.dart'; import '../../widgets/admin_drawer.dart'; import '../../widgets/backoffice_ui.dart'; import 'admin_doctors_page.dart'; @@ -12,7 +13,11 @@ final adminPageProvider = NotifierProvider( class AdminPageNotifier extends Notifier { @override - String build() => 'doctors'; + String build() { + ref.watch(userSessionIdentityProvider); + return 'doctors'; + } + void set(String page) => state = page; } diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index 7b1a684..8313eed 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -274,11 +274,12 @@ class _DeviceScanPageState extends ConsumerState if (await notifier.isDuplicateReading(device, reading)) return false; final api = ref.read(apiClientProvider); - await api.post('/api/health-records', data: reading.toHealthRecord()); + final records = >[reading.toHealthRecord()]; final heartRateRecord = reading.toHeartRateRecord(); if (heartRateRecord != null) { - await api.post('/api/health-records', data: heartRateRecord); + records.add(heartRateRecord); } + await api.post('/api/health-records/batch', data: records); await notifier.recordSuccessfulSync(device: device, reading: reading); return true; } diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 4d51177..ad4f18f 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -81,7 +81,10 @@ class DietFoodValidationException implements Exception { class DietNotifier extends Notifier { @override - DietState build() => DietState(); + DietState build() { + ref.watch(userSessionIdentityProvider); + return DietState(); + } void setImage(String path) { state = state.copyWith(imagePath: path); diff --git a/health_app/lib/pages/doctor/doctor_consultations_page.dart b/health_app/lib/pages/doctor/doctor_consultations_page.dart index 2d2bb94..24d5d69 100644 --- a/health_app/lib/pages/doctor/doctor_consultations_page.dart +++ b/health_app/lib/pages/doctor/doctor_consultations_page.dart @@ -9,6 +9,7 @@ import '../../widgets/backoffice_ui.dart'; final _consListProvider = FutureProvider>>(( ref, ) async { + ref.watch(userSessionIdentityProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/consultations'); return (res.data['data'] as List?)?.cast>() ?? []; diff --git a/health_app/lib/pages/doctor/doctor_dashboard_page.dart b/health_app/lib/pages/doctor/doctor_dashboard_page.dart index 08bdb34..3a6b24f 100644 --- a/health_app/lib/pages/doctor/doctor_dashboard_page.dart +++ b/health_app/lib/pages/doctor/doctor_dashboard_page.dart @@ -7,6 +7,7 @@ import '../../widgets/backoffice_ui.dart'; import '../doctor/doctor_home_page.dart' show doctorPageProvider; final _dashboardProvider = FutureProvider?>((ref) async { + ref.watch(userSessionIdentityProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/dashboard'); return res.data['data'] as Map?; diff --git a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart index c9a4faf..d10fbfc 100644 --- a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart +++ b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart @@ -9,6 +9,7 @@ import '../../widgets/app_toast.dart'; import '../../widgets/backoffice_ui.dart'; final _ptsSimple = FutureProvider>>((ref) async { + ref.watch(userSessionIdentityProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/patients-simple'); return (res.data['data'] as List?)?.cast>() ?? []; @@ -50,7 +51,9 @@ class _DoctorFollowUpEditPageState _titleCtrl.text = d['title'] ?? ''; _notesCtrl.text = d['notes'] ?? ''; _pid = d['userId']?.toString(); - final at = DateTime.tryParse(d['scheduledAt']?.toString() ?? ''); + final at = DateTime.tryParse( + d['scheduledAt']?.toString() ?? '', + )?.toLocal(); if (at != null) { _date = at; _time = TimeOfDay.fromDateTime(at); @@ -261,7 +264,7 @@ class _DoctorFollowUpEditPageState final data = { 'userId': _pid, 'title': _titleCtrl.text.trim(), - 'scheduledAt': at.toIso8601String(), + 'scheduledAt': at.toUtc().toIso8601String(), 'notes': _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(), }; if (isEdit) { @@ -294,6 +297,7 @@ final _fupDetailForEdit = FutureProvider.family?, String>(( ref, id, ) async { + ref.watch(userSessionIdentityProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/follow-ups'); final items = (res.data['data'] as List?)?.cast>() ?? []; diff --git a/health_app/lib/pages/doctor/doctor_followups_page.dart b/health_app/lib/pages/doctor/doctor_followups_page.dart index b7d3306..0d411bd 100644 --- a/health_app/lib/pages/doctor/doctor_followups_page.dart +++ b/health_app/lib/pages/doctor/doctor_followups_page.dart @@ -8,6 +8,7 @@ import '../../utils/backoffice_formatters.dart'; import '../../widgets/backoffice_ui.dart'; final _fupRefresh = FutureProvider((ref) async { + ref.watch(userSessionIdentityProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/follow-ups'); final items = (res.data['data'] as List?)?.cast>() ?? []; @@ -21,7 +22,11 @@ final _fupList = NotifierProvider>>( class FupListN extends Notifier>> { @override - List> build() => []; + List> build() { + ref.watch(userSessionIdentityProvider); + return []; + } + void replace(List> v) => state = v; void markDone(String id) => state = state .map((f) => f['id'] == id ? {...f, 'status': 'Completed'} : f) diff --git a/health_app/lib/pages/doctor/doctor_home_page.dart b/health_app/lib/pages/doctor/doctor_home_page.dart index 6efc9b9..4ab184e 100644 --- a/health_app/lib/pages/doctor/doctor_home_page.dart +++ b/health_app/lib/pages/doctor/doctor_home_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../providers/auth_provider.dart'; import '../../widgets/doctor_drawer.dart'; import '../../widgets/backoffice_ui.dart'; import 'doctor_dashboard_page.dart'; @@ -97,6 +98,10 @@ final doctorPageProvider = NotifierProvider( class DoctorPageNotifier extends Notifier { @override - String build() => 'dashboard'; + String build() { + ref.watch(userSessionIdentityProvider); + return 'dashboard'; + } + void set(String page) => state = page; } diff --git a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart index e174d92..63e7a0a 100644 --- a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart @@ -8,6 +8,7 @@ import '../../widgets/backoffice_ui.dart'; final _patientDetailProvider = FutureProvider.family?, String>((ref, id) async { + ref.watch(userSessionIdentityProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/patients/$id'); return res.data['data'] as Map?; diff --git a/health_app/lib/pages/doctor/doctor_profile_page.dart b/health_app/lib/pages/doctor/doctor_profile_page.dart index 96f2263..4056ff2 100644 --- a/health_app/lib/pages/doctor/doctor_profile_page.dart +++ b/health_app/lib/pages/doctor/doctor_profile_page.dart @@ -7,6 +7,7 @@ import '../../widgets/app_toast.dart'; import '../../widgets/backoffice_ui.dart'; final _docProfileProvider = FutureProvider?>((ref) async { + ref.watch(userSessionIdentityProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/profile'); return res.data['data'] as Map?; diff --git a/health_app/lib/pages/doctor/doctor_report_detail_page.dart b/health_app/lib/pages/doctor/doctor_report_detail_page.dart index 57e3fc4..2550cc1 100644 --- a/health_app/lib/pages/doctor/doctor_report_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_report_detail_page.dart @@ -10,6 +10,7 @@ import '../../widgets/backoffice_ui.dart'; final _reportDetailProvider = FutureProvider.family?, String>((ref, id) async { + ref.watch(userSessionIdentityProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/reports/$id'); return res.data['data'] as Map?; diff --git a/health_app/lib/pages/doctor/doctor_reports_page.dart b/health_app/lib/pages/doctor/doctor_reports_page.dart index 937fc70..f72274b 100644 --- a/health_app/lib/pages/doctor/doctor_reports_page.dart +++ b/health_app/lib/pages/doctor/doctor_reports_page.dart @@ -9,6 +9,7 @@ import '../../widgets/backoffice_ui.dart'; final _reportsProvider = FutureProvider>>(( ref, ) async { + ref.watch(userSessionIdentityProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/reports'); return (res.data['data'] as List?)?.cast>() ?? []; diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index b3b76b2..b7c4b3f 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -85,6 +85,7 @@ class _HomePageState extends ConsumerState } void _sendMessage() { + if (ref.read(chatProvider).isStreaming) return; final text = _textCtrl.text.trim(); final imagePath = _pickedImagePath; if (text.isEmpty && imagePath == null) return; @@ -425,6 +426,9 @@ class _HomePageState extends ConsumerState } Widget _buildInputBar() { + final isStreaming = ref.watch( + chatProvider.select((state) => state.isStreaming), + ); return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), child: Container( @@ -490,7 +494,9 @@ class _HomePageState extends ConsumerState color: Colors.transparent, child: InkWell( customBorder: const CircleBorder(), - onTap: _sendMessage, + onTap: isStreaming + ? () => ref.read(chatProvider.notifier).stopGenerating() + : _sendMessage, child: Ink( width: 38, height: 38, @@ -498,8 +504,8 @@ class _HomePageState extends ConsumerState gradient: AppColors.primaryGradient, shape: BoxShape.circle, ), - child: const Icon( - LucideIcons.send, + child: Icon( + isStreaming ? Icons.stop_rounded : LucideIcons.send, size: 18, color: Colors.white, ), diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 13ef4ba..36c76fc 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -6,12 +6,12 @@ import '../../../core/app_colors.dart'; import '../../../core/app_design_tokens.dart'; import '../../../core/app_module_visuals.dart'; import '../../../core/app_theme.dart'; -import '../../../core/api_client.dart' show baseUrl; import '../../../core/navigation_provider.dart'; import '../../../providers/chat_provider.dart'; import '../../../providers/data_providers.dart'; import '../../../widgets/ai_content.dart'; import '../../../widgets/app_toast.dart'; +import '../../../widgets/authenticated_network_image.dart'; ChatMessage messageAtDisplayIndex(List messages, int index) => messages[index]; @@ -1045,8 +1045,8 @@ class ChatMessagesView extends ConsumerWidget { child: localPath != null ? Image.file(File(localPath), fit: BoxFit.cover) : imageUrl != null - ? Image.network( - _mediaUrl(imageUrl), + ? AuthenticatedNetworkImage( + imageUrl: imageUrl, fit: BoxFit.cover, errorBuilder: (_, e, s) => Container( width: 80, @@ -1122,7 +1122,11 @@ class ChatMessagesView extends ConsumerWidget { static void _showFullImage(BuildContext context, String? path) { if (path == null) return; - final resolvedPath = _mediaUrl(path); + final isNetwork = + path.startsWith('http://') || + path.startsWith('https://') || + path.startsWith('/uploads/') || + path.startsWith('/api/'); showDialog( context: context, builder: (ctx) => Dialog( @@ -1134,9 +1138,12 @@ class ChatMessagesView extends ConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(12), child: InteractiveViewer( - child: resolvedPath.startsWith('http') - ? Image.network(resolvedPath, fit: BoxFit.contain) - : Image.file(File(resolvedPath), fit: BoxFit.contain), + child: isNetwork + ? AuthenticatedNetworkImage( + imageUrl: path, + fit: BoxFit.contain, + ) + : Image.file(File(path), fit: BoxFit.contain), ), ), Positioned( @@ -1160,12 +1167,6 @@ class ChatMessagesView extends ConsumerWidget { ); } - static String _mediaUrl(String path) { - if (path.startsWith('http://') || path.startsWith('https://')) return path; - if (path.startsWith('/uploads/')) return '$baseUrl$path'; - return path; - } - /// 处理 AI 回复里的 markdown 链接点击: /// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程 /// - app://report → 跳到报告列表(用户可在那里上传新报告) diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 8588138..1dcb9b8 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -27,6 +27,7 @@ class DietRecordListPage extends ConsumerStatefulWidget { class _DietRecordListPageState extends ConsumerState { List> _data = []; bool _loading = true; + String? _loadError; int _trendDays = 7; DateTime _selectedDate = DateTime.now(); @@ -37,12 +38,22 @@ class _DietRecordListPageState extends ConsumerState { } Future _refresh() async { - final records = await ref.read(dietServiceProvider).getRecords(); - if (mounted) { + try { + final records = await ref.read(dietServiceProvider).getRecords(); + if (!mounted) return; setState(() { _data = records; - _loading = false; + _loadError = null; }); + } catch (_) { + if (!mounted) return; + if (_data.isEmpty) { + setState(() => _loadError = '网络异常或服务暂时不可用,请稍后重试'); + } else { + AppToast.show(context, '刷新失败,已保留当前记录', type: AppToastType.error); + } + } finally { + if (mounted) setState(() => _loading = false); } } @@ -104,7 +115,19 @@ class _DietRecordListPageState extends ConsumerState { onPressed: () => popRoute(ref), ), ), - body: const Center(child: CircularProgressIndicator()), + body: _loadError == null + ? const Center(child: CircularProgressIndicator()) + : AppErrorState( + title: '饮食记录加载失败', + subtitle: _loadError, + onRetry: () { + setState(() { + _loading = true; + _loadError = null; + }); + _refresh(); + }, + ), ); } @@ -390,33 +413,67 @@ class _DietRecordListPageState extends ConsumerState { void _showEditDialog(String id, num cal) { final ctrl = TextEditingController(text: '$cal'); - showDialog( + var saving = false; + showDialog( context: context, - builder: (ctx) => AlertDialog( - title: const Text('修改热量'), - content: TextField( - controller: ctrl, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: '热量(千卡)'), + barrierDismissible: !saving, + builder: (ctx) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('修改热量'), + content: TextField( + controller: ctrl, + enabled: !saving, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '热量(千卡)'), + ), + actions: [ + TextButton( + onPressed: saving ? null : () => Navigator.pop(ctx), + child: const Text('取消'), + ), + TextButton( + onPressed: saving + ? null + : () async { + final calories = int.tryParse(ctrl.text.trim()); + if (calories == null || calories < 0) { + AppToast.show( + context, + '请输入正确的热量', + type: AppToastType.warning, + ); + return; + } + setDialogState(() => saving = true); + try { + await ref.read(dietServiceProvider).updateRecord(id, { + 'totalCalories': calories, + }); + if (!ctx.mounted) return; + Navigator.pop(ctx); + await _refresh(); + } catch (_) { + if (!ctx.mounted) return; + setDialogState(() => saving = false); + AppToast.show( + context, + '保存失败,请检查网络后重试', + type: AppToastType.error, + ); + } + }, + child: saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('保存'), + ), + ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), - ), - TextButton( - onPressed: () async { - Navigator.pop(ctx); - await ref.read(dietServiceProvider).updateRecord(id, { - 'totalCalories': int.tryParse(ctrl.text) ?? 0, - }); - _refresh(); - }, - child: const Text('保存'), - ), - ], ), - ); + ).whenComplete(ctrl.dispose); } } @@ -2546,7 +2603,7 @@ class _FollowUpItem extends StatelessWidget { String _formatDateTime(String? iso) { if (iso == null) return ''; - final dt = DateTime.tryParse(iso); + final dt = DateTime.tryParse(iso)?.toLocal(); if (dt == null) return iso; return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } diff --git a/health_app/lib/pages/report/report_pages.dart b/health_app/lib/pages/report/report_pages.dart index 29eceef..96cb6b2 100644 --- a/health_app/lib/pages/report/report_pages.dart +++ b/health_app/lib/pages/report/report_pages.dart @@ -19,6 +19,7 @@ import '../../widgets/enterprise_widgets.dart'; import '../../widgets/app_error_state.dart'; import '../../widgets/app_empty_state.dart'; import '../../widgets/app_toast.dart'; +import '../../widgets/authenticated_network_image.dart'; const _reportPageColor = AppColors.report; const _reportPageSoft = Color(0xFFF0F0FF); @@ -202,6 +203,7 @@ class ReportNotifier extends Notifier { @override ReportState build() { + ref.watch(userSessionIdentityProvider); ref.onDispose(() => _pollTimer?.cancel()); Future.microtask(() => loadReports()); return ReportState(); @@ -237,7 +239,7 @@ class ReportNotifier extends Notifier { title: title, type: m['fileType']?.toString() ?? 'Image', uploadedAt: - DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? + DateTime.tryParse(m['createdAt']?.toString() ?? '')?.toLocal() ?? DateTime.now(), fileUrl: m['fileUrl']?.toString(), hasAnalysis: m['aiSummary'] != null, @@ -249,7 +251,9 @@ class ReportNotifier extends Notifier { doctorComment: m['doctorComment']?.toString(), doctorRecommendation: m['doctorRecommendation']?.toString(), doctorName: m['doctorName']?.toString(), - reviewedAt: DateTime.tryParse(m['reviewedAt']?.toString() ?? ''), + reviewedAt: DateTime.tryParse( + m['reviewedAt']?.toString() ?? '', + )?.toLocal(), ); }).toList(); state = state.copyWith( @@ -1053,8 +1057,8 @@ class _ReportOriginalPageState extends ConsumerState { child: InteractiveViewer( minScale: 0.7, maxScale: 4, - child: Image.network( - imageUrl, + child: AuthenticatedNetworkImage( + imageUrl: imageUrl, key: ValueKey('$imageUrl-$_reloadToken'), fit: BoxFit.contain, loadingBuilder: (context, child, progress) { diff --git a/health_app/lib/pages/settings/notification_prefs_page.dart b/health_app/lib/pages/settings/notification_prefs_page.dart index 6122265..dcd4db5 100644 --- a/health_app/lib/pages/settings/notification_prefs_page.dart +++ b/health_app/lib/pages/settings/notification_prefs_page.dart @@ -21,7 +21,8 @@ final notificationPrefsProvider = class NotificationPrefsNotifier extends Notifier { @override NotificationPrefsViewState build() { - Future.microtask(load); + final session = ref.watch(userSessionIdentityProvider); + if (session != null) Future.microtask(load); return const NotificationPrefsViewState(loading: true); } diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index 4c85ce0..5006dee 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -6,6 +6,7 @@ import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/omron_device_provider.dart'; class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @@ -172,6 +173,7 @@ class SettingsPage extends ConsumerWidget { ); if (ok == true) { await ref.read(apiClientProvider).delete('/api/user/account'); + await ref.read(omronDeviceProvider.notifier).clearCurrentAccountData(); await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); } diff --git a/health_app/lib/providers/auth_provider.dart b/health_app/lib/providers/auth_provider.dart index 07a68f8..c5a0922 100644 --- a/health_app/lib/providers/auth_provider.dart +++ b/health_app/lib/providers/auth_provider.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:developer'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -53,11 +54,15 @@ final apiClientProvider = Provider((ref) { }); class AuthNotifier extends Notifier { + static const _cachedUserKey = 'session_user'; + @override AuthState build() { final removeListener = ref.read(authExpiredNotifierProvider).addListener( () { + ref.read(localDbProvider).delete(_cachedUserKey); state = const AuthState(isLoggedIn: false, isLoading: false); + _publishSessionIdentity(null); }, ); ref.onDispose(removeListener); @@ -72,30 +77,51 @@ class AuthNotifier extends Notifier { if (refresh == null) { // 无 token:判定完成、未登录 state = const AuthState(isLoggedIn: false, isLoading: false); + _publishSessionIdentity(null); return; } state = const AuthState(isLoading: true); try { final response = await Dio( - BaseOptions(baseUrl: baseUrl), + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 8), + ), ).post('/api/auth/refresh', data: {'refreshToken': refresh}); - final data = response.data['data']; - if (data != null) { + final body = response.data; + final data = body is Map ? body['data'] : null; + final code = body is Map ? body['code'] : null; + if (data is Map) { await db.write('access_token', data['accessToken']); await db.write('refresh_token', data['refreshToken']); final u = data['user'] as Map?; - state = AuthState( - isLoggedIn: true, - isLoading: false, - user: UserInfo(id: '', phone: '', role: u?['role'] ?? 'User'), - ); + final cachedUser = await _readCachedUser(); + final user = _userFromMap(u, fallback: cachedUser); + state = AuthState(isLoggedIn: true, isLoading: false, user: user); + _publishSessionIdentity(user); + await _cacheUser(user); _loadProfile(); - } else { + } else if (code != null && code != 0) { + await _clearSessionStorage(); state = const AuthState(isLoggedIn: false, isLoading: false); + _publishSessionIdentity(null); + } else { + await _restoreOfflineSession(); } - } catch (_) { - state = const AuthState(isLoggedIn: false, isLoading: false); + } on DioException catch (error) { + if (error.response?.statusCode == 400 || + error.response?.statusCode == 401) { + await _clearSessionStorage(); + state = const AuthState(isLoggedIn: false, isLoading: false); + _publishSessionIdentity(null); + } else { + await _restoreOfflineSession(); + } + } catch (error) { + log('[Auth] startup refresh: $error'); + await _restoreOfflineSession(); } } @@ -120,6 +146,8 @@ class AuthNotifier extends Notifier { birthDate: user['birthDate']?.toString(), ), ); + await _cacheUser(state.user!); + _publishSessionIdentity(state.user); } } catch (e) { log('[Auth] loadProfile: $e'); @@ -177,6 +205,8 @@ class AuthNotifier extends Notifier { role: user['role'] ?? 'User', ), ); + await _cacheUser(state.user!); + _publishSessionIdentity(state.user); return null; } catch (e) { return '注册失败: $e'; @@ -207,6 +237,8 @@ class AuthNotifier extends Notifier { avatarUrl: user['avatarUrl'], ), ); + await _cacheUser(state.user!); + _publishSessionIdentity(state.user); return null; } catch (e) { return '登录失败: $e'; @@ -245,6 +277,8 @@ class AuthNotifier extends Notifier { avatarUrl: user['avatarUrl'], ), ); + await _cacheUser(state.user!); + _publishSessionIdentity(state.user); return null; } catch (_) { return 'Apple 登录失败,请稍后重试'; @@ -264,7 +298,108 @@ class AuthNotifier extends Notifier { } } await api.clearTokens(); + await db.delete(_cachedUserKey); state = const AuthState(isLoggedIn: false, isLoading: false); + _publishSessionIdentity(null); + } + + Future _restoreOfflineSession() async { + final cached = await _readCachedUser() ?? await _userFromStoredToken(); + if (cached == null) { + state = const AuthState(isLoggedIn: false, isLoading: false); + _publishSessionIdentity(null); + return; + } + state = AuthState(user: cached, isLoggedIn: true, isLoading: false); + _publishSessionIdentity(cached); + } + + UserInfo _userFromMap(Map? value, {UserInfo? fallback}) { + return UserInfo( + id: value?['id']?.toString() ?? fallback?.id ?? '', + phone: value?['phone']?.toString() ?? fallback?.phone ?? '', + role: value?['role']?.toString() ?? fallback?.role ?? 'User', + name: value?['name']?.toString() ?? fallback?.name, + avatarUrl: value?['avatarUrl']?.toString() ?? fallback?.avatarUrl, + gender: value?['gender']?.toString() ?? fallback?.gender, + birthDate: value?['birthDate']?.toString() ?? fallback?.birthDate, + ); + } + + Future _cacheUser(UserInfo user) { + return ref + .read(localDbProvider) + .write( + _cachedUserKey, + jsonEncode({ + 'id': user.id, + 'phone': user.phone, + 'role': user.role, + 'name': user.name, + 'avatarUrl': user.avatarUrl, + 'gender': user.gender, + 'birthDate': user.birthDate, + }), + ); + } + + Future _readCachedUser() async { + final raw = await ref.read(localDbProvider).read(_cachedUserKey); + if (raw == null || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return _userFromMap(Map.from(decoded)); + } catch (_) { + return null; + } + } + + Future _userFromStoredToken() async { + final token = await ref.read(localDbProvider).read('access_token'); + if (token == null) return null; + try { + final parts = token.split('.'); + if (parts.length != 3) return null; + final payload = jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), + ); + if (payload is! Map) return null; + final map = Map.from(payload); + String? claim(String shortName, String longName) => + map[shortName]?.toString() ?? map[longName]?.toString(); + return UserInfo( + id: + claim( + 'sub', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier', + ) ?? + '', + phone: + claim( + 'phone', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone', + ) ?? + '', + role: + claim( + 'role', + 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role', + ) ?? + 'User', + ); + } catch (_) { + return null; + } + } + + Future _clearSessionStorage() async { + await ref.read(apiClientProvider).clearTokens(); + await ref.read(localDbProvider).delete(_cachedUserKey); + } + + void _publishSessionIdentity(UserInfo? user) { + ref.read(userSessionIdentityProvider.notifier).setUser(user); } } @@ -272,3 +407,21 @@ class AuthNotifier extends Notifier { final userRoleProvider = Provider((ref) { return ref.watch(authProvider).user?.role ?? 'User'; }); + +final userSessionIdentityProvider = + NotifierProvider( + UserSessionIdentityNotifier.new, + ); + +class UserSessionIdentityNotifier extends Notifier { + @override + String? build() => null; + + void setUser(UserInfo? user) { + if (user == null || user.id.isEmpty) { + state = null; + return; + } + state = user.id; + } +} diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 18c3032..083bba6 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -86,10 +86,12 @@ class ChatNotifier extends Notifier { ActiveAgent? _lastTriggeredAgent; Timer? _agentTapLockTimer; bool _loadingConversation = false; + int _generation = 0; /// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。 /// 历史记录页一键清空 / 删除当前会话时调用。 Future resetSession() async { + _generation++; await _cancelActiveStream(); _cancelPendingAgentWelcome(); _lastTriggeredAgent = null; @@ -140,7 +142,9 @@ class ChatNotifier extends Notifier { @override ChatState build() { + ref.watch(userSessionIdentityProvider); ref.onDispose(() { + _generation++; _subscription?.cancel(); _agentTapLockTimer?.cancel(); _subscription = null; @@ -172,10 +176,13 @@ class ChatNotifier extends Notifier { } Future loadConversation(String convId) async { - if (state.isStreaming) return '小脉正在回复,请稍后再切换对话'; if (_loadingConversation) return '正在加载其他对话,请稍候'; _loadingConversation = true; - await _cancelActiveStream(); + if (state.isStreaming) { + await stopGenerating(); + } else { + await _cancelActiveStream(); + } _cancelPendingAgentWelcome(); try { final api = ref.read(apiClientProvider); @@ -194,7 +201,9 @@ class ChatNotifier extends Notifier { role: role, content: map['content']?.toString() ?? '', createdAt: - DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? + DateTime.tryParse( + map['createdAt']?.toString() ?? '', + )?.toLocal() ?? DateTime.now(), type: _messageTypeFromMetadata(metadata), metadata: metadata, @@ -258,7 +267,8 @@ class ChatNotifier extends Notifier { Future sendImage(String imagePath, String text) async { if (state.isStreaming) return; final file = File(imagePath); - if (!await file.exists()) return; + if (!await file.exists() || state.isStreaming) return; + final generation = ++_generation; _lastTriggeredAgent = null; _cancelPendingAgentWelcome(); _resumeConversationFromHistory(); @@ -286,6 +296,8 @@ class ChatNotifier extends Notifier { uploadError = e; } + if (generation != _generation || !state.isStreaming) return; + // 更新消息元数据(保留本地路径 + 添加远程URL) final updatedMsgs = state.messages.toList(); final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id); @@ -318,14 +330,15 @@ class ChatNotifier extends Notifier { // 把图片 URL 透传给后端,后端会调 VLM 识图并把描述拼到 LLM 上下文 final userText = text.isNotEmpty ? text : '请帮我看看这张图片'; - await _sendToAI(userText, imageUrl: uploadedUrl); + await _sendToAI(generation, userText, imageUrl: uploadedUrl); } /// 发送 PDF 附件 + 文字(PDF 解析在后端做)。 Future sendPdf(String pdfPath, String fileName, String text) async { if (state.isStreaming) return; final file = File(pdfPath); - if (!await file.exists()) return; + if (!await file.exists() || state.isStreaming) return; + final generation = ++_generation; _lastTriggeredAgent = null; _cancelPendingAgentWelcome(); _resumeConversationFromHistory(); @@ -350,6 +363,8 @@ class ChatNotifier extends Notifier { // ignore,下方统一处理 } + if (generation != _generation || !state.isStreaming) return; + // 更新消息附带的远程 URL if (uploadedUrl != null) { final updatedMsgs = state.messages.toList(); @@ -376,11 +391,12 @@ class ChatNotifier extends Notifier { return; } - await _sendToAI(userMsg.content, pdfUrl: uploadedUrl); + await _sendToAI(generation, userMsg.content, pdfUrl: uploadedUrl); } Future sendMessage(String text) async { if (text.trim().isEmpty || state.isStreaming) return; + final generation = ++_generation; _lastTriggeredAgent = null; _cancelPendingAgentWelcome(); _resumeConversationFromHistory(); @@ -396,14 +412,16 @@ class ChatNotifier extends Notifier { isStreaming: true, ); - await _sendToAI(text); + await _sendToAI(generation, text); } Future _sendToAI( + int generation, String text, { String? imageUrl, String? pdfUrl, }) async { + if (generation != _generation || !state.isStreaming) return; final aiMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}_ai', role: 'assistant', @@ -423,6 +441,7 @@ class ChatNotifier extends Notifier { _addError(aiMsg, '未登录,请重新登录'); return; } + if (generation != _generation || !state.isStreaming) return; // 始终用 unified 智能体,AI 自动判断意图分配工具 final stream = SseHandler.connect( @@ -435,12 +454,18 @@ class ChatNotifier extends Notifier { ); await _cancelActiveStream(); + if (generation != _generation || !state.isStreaming) return; + final done = Completer(); _streamDone = done; _subscription = stream.listen( - (event) => _processEvent(event, aiMsg), + (event) { + if (generation == _generation) _processEvent(event, aiMsg); + }, onError: (_) { - _addError(aiMsg, '网络异常,请稍后重试'); + if (generation == _generation) { + _addError(aiMsg, '网络异常,请稍后重试'); + } if (!done.isCompleted) done.complete(); }, onDone: () { @@ -454,11 +479,13 @@ class ChatNotifier extends Notifier { _subscription = null; } - if (state.isStreaming) { + if (generation == _generation && state.isStreaming) { _done(aiMsg); } } catch (e) { - _addError(aiMsg, '网络异常,请稍后重试'); + if (generation == _generation) { + _addError(aiMsg, '网络异常,请稍后重试'); + } } } @@ -476,6 +503,31 @@ class ChatNotifier extends Notifier { _streamDone = null; } + Future stopGenerating() async { + if (!state.isStreaming) return; + _generation++; + await _cancelActiveStream(); + final messages = state.messages.toList(); + if (messages.isNotEmpty && + !messages.last.isUser && + messages.last.type == MessageType.text) { + final last = messages.last; + if (last.content.trim().isEmpty) { + messages.removeLast(); + } else { + last.content = '${last.content.trimRight()}\n\n(已停止生成)'; + last.metadata = {...?last.metadata, 'generationStopped': true}; + messages[messages.length - 1] = last; + } + } + state = state.copyWith( + messages: messages, + isStreaming: false, + thinkingText: null, + ); + ref.invalidate(conversationHistoryProvider); + } + void _addError(ChatMessage aiMsg, String errorText) { aiMsg.content = errorText; aiMsg.type = MessageType.text; diff --git a/health_app/lib/providers/consultation_provider.dart b/health_app/lib/providers/consultation_provider.dart index a324396..fc349da 100644 --- a/health_app/lib/providers/consultation_provider.dart +++ b/health_app/lib/providers/consultation_provider.dart @@ -89,7 +89,14 @@ class ConsultationChatNotifier extends Notifier { String get _hubUrl => '$baseUrl/hubs/consultation'; @override - ConsultationChatState build() => const ConsultationChatState(); + ConsultationChatState build() { + ref.watch(userSessionIdentityProvider); + ref.onDispose(() async { + await _hub?.stop(); + _hub = null; + }); + return const ConsultationChatState(); + } Future init(String doctorId) async { state = state.copyWith(doctorId: doctorId, isLoading: true); @@ -169,7 +176,7 @@ class ConsultationChatNotifier extends Notifier { senderName: data['senderName']?.toString(), content: data['content']?.toString() ?? '', createdAt: data['createdAt'] != null - ? DateTime.tryParse(data['createdAt'].toString()) ?? + ? DateTime.tryParse(data['createdAt'].toString())?.toLocal() ?? DateTime.now() : DateTime.now(), ); @@ -228,7 +235,9 @@ class ConsultationChatNotifier extends Notifier { senderName: map['senderName']?.toString(), content: map['content']?.toString() ?? '', createdAt: - DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? + DateTime.tryParse( + map['createdAt']?.toString() ?? '', + )?.toLocal() ?? DateTime.now(), ); }).toList(); @@ -354,7 +363,9 @@ class ConsultationChatNotifier extends Notifier { senderName: map['senderName']?.toString(), content: map['content']?.toString() ?? '', createdAt: - DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? + DateTime.tryParse( + map['createdAt']?.toString() ?? '', + )?.toLocal() ?? DateTime.now(), ); }) diff --git a/health_app/lib/providers/conversation_history_provider.dart b/health_app/lib/providers/conversation_history_provider.dart index 549f69b..db0fd8b 100644 --- a/health_app/lib/providers/conversation_history_provider.dart +++ b/health_app/lib/providers/conversation_history_provider.dart @@ -25,7 +25,7 @@ class ConversationListItem { summary: json['summary']?.toString(), messageCount: (json['messageCount'] as num?)?.toInt() ?? 0, updatedAt: - DateTime.tryParse(json['updatedAt']?.toString() ?? '') ?? + DateTime.tryParse(json['updatedAt']?.toString() ?? '')?.toLocal() ?? DateTime.now(), ); } @@ -35,6 +35,8 @@ class ConversationHistoryNotifier extends AsyncNotifier> { @override Future> build() { + final session = ref.watch(userSessionIdentityProvider); + if (session == null) return Future.value(const []); return _fetch(); } diff --git a/health_app/lib/providers/data_providers.dart b/health_app/lib/providers/data_providers.dart index a1289d1..746a544 100644 --- a/health_app/lib/providers/data_providers.dart +++ b/health_app/lib/providers/data_providers.dart @@ -44,6 +44,7 @@ final inAppNotificationServiceProvider = Provider(( }); final notificationUnreadCountProvider = FutureProvider((ref) async { + ref.watch(userSessionIdentityProvider); final history = await ref .watch(inAppNotificationServiceProvider) .getHistory(); @@ -52,6 +53,7 @@ final notificationUnreadCountProvider = FutureProvider((ref) async { /// 最新健康数据 Provider final latestHealthProvider = FutureProvider>((ref) async { + ref.watch(userSessionIdentityProvider); final service = ref.watch(healthServiceProvider); return service.getLatest(); }); @@ -60,6 +62,7 @@ final latestHealthProvider = FutureProvider>((ref) async { final medicationListProvider = FutureProvider>>(( ref, ) async { + ref.watch(userSessionIdentityProvider); final service = ref.watch(medicationServiceProvider); return service.getList(); }); @@ -67,24 +70,28 @@ final medicationListProvider = FutureProvider>>(( final exercisePlansProvider = FutureProvider>>(( ref, ) async { + ref.watch(userSessionIdentityProvider); return ref.watch(exerciseServiceProvider).getPlans(); }); final followUpListProvider = FutureProvider>>(( ref, ) async { + ref.watch(userSessionIdentityProvider); return ref.watch(followUpServiceProvider).getList(); }); final dietRecordsProvider = FutureProvider>>(( ref, ) async { + ref.watch(userSessionIdentityProvider); return ref.watch(dietServiceProvider).getRecords(); }); final medicationReminderProvider = FutureProvider>>(( ref, ) async { + ref.watch(userSessionIdentityProvider); final service = ref.watch(medicationServiceProvider); return service.getReminders(); }); @@ -92,6 +99,7 @@ final medicationReminderProvider = FutureProvider>>(( /// 医生列表 Provider final doctorListProvider = FutureProvider.autoDispose>>((ref) async { + ref.watch(userSessionIdentityProvider); final service = ref.watch(consultationServiceProvider); return service.getDoctors().timeout(const Duration(seconds: 8)); }); @@ -100,6 +108,7 @@ final doctorListProvider = final currentExercisePlanProvider = FutureProvider?>(( ref, ) async { + ref.watch(userSessionIdentityProvider); final service = ref.watch(exerciseServiceProvider); return service.getCurrentPlan().timeout(const Duration(seconds: 8)); }); diff --git a/health_app/lib/providers/omron_device_provider.dart b/health_app/lib/providers/omron_device_provider.dart index c4d0543..a95a11c 100644 --- a/health_app/lib/providers/omron_device_provider.dart +++ b/health_app/lib/providers/omron_device_provider.dart @@ -50,11 +50,13 @@ class DeviceBindState { class DeviceBindNotifier extends Notifier { StreamSubscription? _connSub; + late String? _sessionIdentity; @override DeviceBindState build() { + _sessionIdentity = ref.watch(userSessionIdentityProvider); ref.onDispose(() => _connSub?.cancel()); - _loadBinding(); + if (_sessionIdentity != null) _loadBinding(_sessionIdentity!); _listenConnection(); return const DeviceBindState(); } @@ -70,17 +72,36 @@ class DeviceBindNotifier extends Notifier { }); } - Future _loadBinding() async { + String _accountKey(String baseKey, [String? session]) => + '$baseKey:${session ?? _sessionIdentity}'; + + Future _loadBinding(String session) async { final db = ref.read(localDbProvider); - await _migrateLegacyBloodPressureDevice(); - final raw = await db.read(_boundDevicesKey); + await _migrateLegacyBloodPressureDevice(session); + final raw = await db.read(_accountKey(_boundDevicesKey, session)); final devices = _decodeDevices(raw); + if (_sessionIdentity != session) return; state = state.copyWith(devices: devices); } - Future _migrateLegacyBloodPressureDevice() async { + Future _migrateLegacyBloodPressureDevice(String session) async { final db = ref.read(localDbProvider); - final existing = await db.read(_boundDevicesKey); + final accountKey = _accountKey(_boundDevicesKey, session); + final existing = await db.read(accountKey); + final oldSharedDevices = await db.read(_boundDevicesKey); + if (existing == null && oldSharedDevices != null) { + await db.write(accountKey, oldSharedDevices); + await db.delete(_boundDevicesKey); + final oldFingerprints = await db.read(_readingFingerprintsKey); + if (oldFingerprints != null) { + await db.write( + _accountKey(_readingFingerprintsKey, session), + oldFingerprints, + ); + await db.delete(_readingFingerprintsKey); + } + return; + } final legacyMac = await db.read(_legacyBpMacKey); if (existing != null || legacyMac == null || legacyMac.isEmpty) return; @@ -91,7 +112,7 @@ class DeviceBindNotifier extends Notifier { type: BleDeviceType.bloodPressure, serviceUuid: BleDeviceType.bloodPressure.serviceUuid, ); - await db.write(_boundDevicesKey, jsonEncode([migrated.toJson()])); + await db.write(accountKey, jsonEncode([migrated.toJson()])); await db.delete(_legacyBpMacKey); await db.delete(_legacyBpNameKey); await db.delete(_legacyBpLastSyncKey); @@ -117,7 +138,7 @@ class DeviceBindNotifier extends Notifier { Future _saveDevices(List devices) async { final db = ref.read(localDbProvider); await db.write( - _boundDevicesKey, + _accountKey(_boundDevicesKey), jsonEncode(devices.map((device) => device.toJson()).toList()), ); state = state.copyWith(devices: devices); @@ -188,7 +209,7 @@ class DeviceBindNotifier extends Notifier { Future> _loadFingerprints() async { final db = ref.read(localDbProvider); - final raw = await db.read(_readingFingerprintsKey); + final raw = await db.read(_accountKey(_readingFingerprintsKey)); if (raw == null || raw.isEmpty) return {}; try { final decoded = jsonDecode(raw); @@ -203,7 +224,19 @@ class DeviceBindNotifier extends Notifier { Future _saveFingerprints(Map fingerprints) async { final db = ref.read(localDbProvider); - await db.write(_readingFingerprintsKey, jsonEncode(fingerprints)); + await db.write( + _accountKey(_readingFingerprintsKey), + jsonEncode(fingerprints), + ); + } + + Future clearCurrentAccountData() async { + final session = _sessionIdentity; + if (session == null) return; + final db = ref.read(localDbProvider); + await db.delete(_accountKey(_boundDevicesKey, session)); + await db.delete(_accountKey(_readingFingerprintsKey, session)); + state = const DeviceBindState(); } Map _pruneFingerprints( diff --git a/health_app/lib/services/in_app_notification_service.dart b/health_app/lib/services/in_app_notification_service.dart index 55cacb8..12c023a 100644 --- a/health_app/lib/services/in_app_notification_service.dart +++ b/health_app/lib/services/in_app_notification_service.dart @@ -34,7 +34,7 @@ class InAppNotification { actionTargetId: json['actionTargetId']?.toString(), isRead: json['isRead'] == true, createdAt: - DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? + DateTime.tryParse(json['createdAt']?.toString() ?? '')?.toLocal() ?? DateTime.now(), ); diff --git a/health_app/lib/utils/sse_handler.dart b/health_app/lib/utils/sse_handler.dart index acbec57..75b9c6e 100644 --- a/health_app/lib/utils/sse_handler.dart +++ b/health_app/lib/utils/sse_handler.dart @@ -14,7 +14,7 @@ class SseHandler { String? pdfUrl, required String token, }) { - final params = {'message': message, 'token': token}; + final params = {'message': message}; if (conversationId != null) { params['conversationId'] = conversationId; } @@ -29,14 +29,26 @@ class SseHandler { .join('&'); final url = '$baseUrl/api/ai/$agentType/chat?$query'; - final controller = StreamController>(); - _connect(controller, url); + final cancelToken = CancelToken(); + late final StreamController> controller; + controller = StreamController>( + onListen: () { + _connect(controller, url, token, cancelToken); + }, + onCancel: () { + if (!cancelToken.isCancelled) { + cancelToken.cancel('用户停止生成'); + } + }, + ); return controller.stream; } static Future _connect( StreamController> controller, String url, + String token, + CancelToken cancelToken, ) async { try { final dio = Dio( @@ -48,7 +60,11 @@ class SseHandler { final response = await dio.get( url, - options: Options(responseType: ResponseType.stream), + cancelToken: cancelToken, + options: Options( + responseType: ResponseType.stream, + headers: {'Authorization': 'Bearer $token'}, + ), ); final stream = response.data.stream as Stream>; @@ -81,10 +97,19 @@ class SseHandler { } } controller.close(); + } on DioException catch (e) { + if (CancelToken.isCancel(e)) { + if (!controller.isClosed) await controller.close(); + return; + } + if (!controller.isClosed) { + controller.add({'action': 'error', 'message': e.toString()}); + await controller.close(); + } } catch (e) { if (!controller.isClosed) { controller.add({'action': 'error', 'message': e.toString()}); - controller.close(); + await controller.close(); } } } diff --git a/health_app/lib/widgets/authenticated_network_image.dart b/health_app/lib/widgets/authenticated_network_image.dart new file mode 100644 index 0000000..73c0e2d --- /dev/null +++ b/health_app/lib/widgets/authenticated_network_image.dart @@ -0,0 +1,131 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/api_client.dart' show baseUrl; +import '../providers/auth_provider.dart'; + +String protectedMediaUrl(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return trimmed; + + final absolute = Uri.tryParse(trimmed); + final path = absolute?.path ?? trimmed.split('?').first; + final legacyIndex = path.toLowerCase().indexOf('/uploads/users/'); + if (legacyIndex >= 0) { + final fileName = Uri.decodeComponent(path.split('/').last); + return '$baseUrl/api/files/content/${Uri.encodeComponent(fileName)}'; + } + + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + if (trimmed.startsWith('/')) return '$baseUrl$trimmed'; + return '$baseUrl/$trimmed'; +} + +bool mediaRequiresAuthentication(String url) { + final normalizedBase = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/'; + return url == baseUrl || url.startsWith(normalizedBase); +} + +class AuthenticatedNetworkImage extends ConsumerStatefulWidget { + final String imageUrl; + final BoxFit? fit; + final double? width; + final double? height; + final ImageLoadingBuilder? loadingBuilder; + final ImageErrorWidgetBuilder? errorBuilder; + + const AuthenticatedNetworkImage({ + super.key, + required this.imageUrl, + this.fit, + this.width, + this.height, + this.loadingBuilder, + this.errorBuilder, + }); + + @override + ConsumerState createState() => + _AuthenticatedNetworkImageState(); +} + +class _AuthenticatedNetworkImageState + extends ConsumerState { + late String _resolvedUrl; + Future? _bytes; + + @override + void initState() { + super.initState(); + _configure(); + } + + @override + void didUpdateWidget(covariant AuthenticatedNetworkImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.imageUrl != widget.imageUrl) _configure(); + } + + void _configure() { + _resolvedUrl = protectedMediaUrl(widget.imageUrl); + _bytes = mediaRequiresAuthentication(_resolvedUrl) + ? _loadProtectedBytes(_resolvedUrl) + : null; + } + + Future _loadProtectedBytes(String url) async { + final response = await ref + .read(apiClientProvider) + .dio + .get>( + url, + options: Options(responseType: ResponseType.bytes), + ); + return Uint8List.fromList(response.data ?? const []); + } + + @override + Widget build(BuildContext context) { + final bytes = _bytes; + if (bytes == null) { + return Image.network( + _resolvedUrl, + fit: widget.fit, + width: widget.width, + height: widget.height, + loadingBuilder: widget.loadingBuilder, + errorBuilder: widget.errorBuilder, + ); + } + + return FutureBuilder( + future: bytes, + builder: (context, snapshot) { + if (snapshot.hasError) { + return widget.errorBuilder?.call( + context, + snapshot.error!, + snapshot.stackTrace, + ) ?? + const Icon(Icons.broken_image_outlined); + } + final data = snapshot.data; + if (data == null) { + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); + } + return Image.memory( + data, + fit: widget.fit, + width: widget.width, + height: widget.height, + errorBuilder: widget.errorBuilder, + ); + }, + ); + } +} diff --git a/health_app/test/protected_media_url_test.dart b/health_app/test/protected_media_url_test.dart new file mode 100644 index 0000000..6692260 --- /dev/null +++ b/health_app/test/protected_media_url_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/core/api_client.dart' show baseUrl; +import 'package:health_app/widgets/authenticated_network_image.dart'; + +void main() { + test('legacy user upload url is converted to protected endpoint', () { + const fileName = '22222222-2222-2222-2222-222222222222.jpg'; + + expect( + protectedMediaUrl('/uploads/users/old-user-id/$fileName'), + '$baseUrl/api/files/content/$fileName', + ); + }); + + test('protected relative endpoint uses configured API base url', () { + expect( + protectedMediaUrl('/api/reports/report-id/file'), + '$baseUrl/api/reports/report-id/file', + ); + }); + + test('external urls are left unchanged', () { + const url = 'https://example.com/image.jpg'; + expect(protectedMediaUrl(url), url); + expect(mediaRequiresAuthentication(url), isFalse); + }); + + test('only configured API media receives the login token', () { + expect( + mediaRequiresAuthentication('$baseUrl/api/files/content/a.jpg'), + isTrue, + ); + }); +} diff --git a/health_app/test/user_session_identity_test.dart b/health_app/test/user_session_identity_test.dart new file mode 100644 index 0000000..e3cff48 --- /dev/null +++ b/health_app/test/user_session_identity_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/providers/auth_provider.dart'; + +void main() { + test('user-scoped state waits for a stable server user id', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(userSessionIdentityProvider.notifier); + + notifier.setUser(UserInfo(id: '', phone: '13800138000', role: 'User')); + expect(container.read(userSessionIdentityProvider), isNull); + + notifier.setUser( + UserInfo( + id: '11111111-1111-1111-1111-111111111111', + phone: '13800138000', + role: 'User', + ), + ); + expect( + container.read(userSessionIdentityProvider), + '11111111-1111-1111-1111-111111111111', + ); + }); +}