feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构

## 后端安全加固
- 新增 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: 更新
This commit is contained in:
MingNian
2026-07-20 10:19:01 +08:00
parent 0d4fd88ce7
commit 9cea41705e
48 changed files with 1181 additions and 212 deletions

View File

@@ -16,5 +16,5 @@ public interface IAttachmentContextBuilder
/// <summary>
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
/// </summary>
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct);
}

View File

@@ -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);
}

View File

@@ -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(),

View File

@@ -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<AttachmentContextBuilder> _logger = logger;
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct)
public async Task<AttachmentContext?> 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<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct)
private async Task<AttachmentContext?> 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(
}
// ── PDFPdfPig 抽取文本 ──
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct)
private Task<AttachmentContext?> 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();

View File

@@ -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)

View File

@@ -8,8 +8,9 @@ public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository
public async Task<CalendarDataSnapshot> 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)

View File

@@ -0,0 +1,64 @@
namespace Health.Infrastructure.Files;
public static class UserUploadPathResolver
{
private static readonly HashSet<string> 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)..];
}
}

View File

@@ -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);

View File

@@ -9,14 +9,23 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
{
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase);
foreach (var metadataJson in references.ConversationMetadataJson)
AddMetadataUrls(fileUrls, metadataJson);
foreach (var fileUrl in fileUrls)
// 正式报告路径来自服务端生成的报告记录,只允许删除 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<string>(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;
}

View File

@@ -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,

View File

@@ -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<FollowUpStatus>(st.GetString(), out var fs)) followUp.Status = fs;
await db.SaveChangesAsync(ct);

View File

@@ -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"
};
}

View File

@@ -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";
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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()
{

View File

@@ -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()
{

View File

@@ -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));
}
}