feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
83
backend/src/Health.Application/Reports/ReportContracts.cs
Normal file
83
backend/src/Health.Application/Reports/ReportContracts.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Reports;
|
||||
|
||||
public sealed record ReportUploadFile(
|
||||
string FileName,
|
||||
long Length,
|
||||
Stream Content);
|
||||
|
||||
public sealed record ReportDto(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string FileUrl,
|
||||
string FileType,
|
||||
string Category,
|
||||
string Status,
|
||||
string AiStatus,
|
||||
string ReviewStatus,
|
||||
string? Severity,
|
||||
string? AiSummary,
|
||||
string? AiIndicators,
|
||||
string? DoctorComment,
|
||||
string? DoctorRecommendation,
|
||||
string? DoctorName,
|
||||
DateTime? ReviewedAt,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record ReportUploadResult(
|
||||
bool Success,
|
||||
int Code,
|
||||
string? Message,
|
||||
ReportDto? Report);
|
||||
|
||||
public sealed record ReportAnalysisJob(
|
||||
Guid TaskId,
|
||||
Guid ReportId,
|
||||
string FilePath);
|
||||
|
||||
public sealed record StoredReportFile(
|
||||
string FileUrl,
|
||||
string FilePath);
|
||||
|
||||
public interface IReportService
|
||||
{
|
||||
Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct);
|
||||
Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct);
|
||||
Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportAnalysisQueue
|
||||
{
|
||||
Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default);
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct);
|
||||
Task CompleteAsync(Guid taskId, CancellationToken ct);
|
||||
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportAnalysisService
|
||||
{
|
||||
Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportRepository
|
||||
{
|
||||
Task<IReadOnlyList<Report>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<Report?> GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
Task<Report?> GetByIdAsync(Guid reportId, CancellationToken ct);
|
||||
Task AddAsync(Report report, CancellationToken ct);
|
||||
void Delete(Report report);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportFileStorage
|
||||
{
|
||||
Task<StoredReportFile> SaveAsync(ReportUploadFile file, string extension, CancellationToken ct);
|
||||
string GetLocalFilePath(string fileUrl);
|
||||
bool Exists(string filePath);
|
||||
void Delete(string filePath);
|
||||
}
|
||||
122
backend/src/Health.Application/Reports/ReportService.cs
Normal file
122
backend/src/Health.Application/Reports/ReportService.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Reports;
|
||||
|
||||
public sealed class ReportService(
|
||||
IReportRepository reports,
|
||||
IReportFileStorage fileStorage,
|
||||
IReportAnalysisQueue queue) : IReportService
|
||||
{
|
||||
private const long MaxReportImageBytes = 10 * 1024 * 1024;
|
||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg", ".jpeg", ".png", ".webp"
|
||||
};
|
||||
|
||||
private readonly IReportRepository _reports = reports;
|
||||
private readonly IReportFileStorage _fileStorage = fileStorage;
|
||||
private readonly IReportAnalysisQueue _queue = queue;
|
||||
|
||||
public async Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var result = await _reports.ListAsync(userId, ct);
|
||||
return result.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct)
|
||||
{
|
||||
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
|
||||
return report == null ? null : ToDto(report);
|
||||
}
|
||||
|
||||
public async Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct)
|
||||
{
|
||||
if (file.Length <= 0)
|
||||
return Fail(400, "未上传文件");
|
||||
|
||||
if (file.Length > MaxReportImageBytes)
|
||||
return Fail(400, "报告图片不能超过 10MB,请压缩后重新上传");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return Fail(400, "目前仅支持上传 JPG、PNG、WEBP 格式的报告图片");
|
||||
|
||||
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
|
||||
var report = new Report
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
FileUrl = storedFile.FileUrl,
|
||||
FileType = ReportFileType.Image,
|
||||
Category = ReportCategory.Other,
|
||||
Status = ReportStatus.Analyzing,
|
||||
AiSummary = null,
|
||||
AiIndicators = null,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _reports.AddAsync(report, ct);
|
||||
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, storedFile.FilePath), ct);
|
||||
|
||||
return new ReportUploadResult(true, 0, "报告已上传,AI 正在分析中", ToDto(report));
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct)
|
||||
{
|
||||
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
|
||||
if (report == null) return false;
|
||||
|
||||
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
|
||||
if (_fileStorage.Exists(filePath))
|
||||
_fileStorage.Delete(filePath);
|
||||
|
||||
_reports.Delete(report);
|
||||
await _reports.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct)
|
||||
{
|
||||
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
|
||||
if (report == null) return false;
|
||||
|
||||
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
|
||||
if (!_fileStorage.Exists(filePath)) return false;
|
||||
|
||||
report.Status = ReportStatus.Analyzing;
|
||||
report.AiSummary = null;
|
||||
report.AiIndicators = null;
|
||||
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, filePath), ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static ReportDto ToDto(Report report) => new(
|
||||
report.Id,
|
||||
report.UserId,
|
||||
report.FileUrl,
|
||||
report.FileType.ToString(),
|
||||
report.Category.ToString(),
|
||||
report.Status.ToString(),
|
||||
ToAiStatus(report),
|
||||
report.Status == ReportStatus.DoctorReviewed ? "Reviewed" : "Pending",
|
||||
report.Severity,
|
||||
report.AiSummary,
|
||||
report.AiIndicators,
|
||||
report.DoctorComment,
|
||||
report.DoctorRecommendation,
|
||||
report.DoctorName,
|
||||
report.ReviewedAt,
|
||||
report.CreatedAt);
|
||||
|
||||
private static string ToAiStatus(Report report) => report.Status switch
|
||||
{
|
||||
ReportStatus.Analyzing => "Analyzing",
|
||||
ReportStatus.AnalysisFailed => "Failed",
|
||||
_ when string.IsNullOrWhiteSpace(report.AiSummary) => "Analyzing",
|
||||
_ => "Succeeded"
|
||||
};
|
||||
|
||||
private static ReportUploadResult Fail(int code, string message) =>
|
||||
new(false, code, message, null);
|
||||
}
|
||||
Reference in New Issue
Block a user