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