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 =