feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化

- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
This commit is contained in:
MingNian
2026-06-20 20:41:42 +08:00
parent c610417e29
commit 4d213b5a44
132 changed files with 6733 additions and 2856 deletions

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

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