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,86 @@
using Health.Application.Reports;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Reports;
public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueue
{
private const int MaxAttempts = 3;
private readonly AppDbContext _db = db;
public async Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
await _db.ReportAnalysisTasks.AddAsync(new ReportAnalysisTaskRecord
{
Id = job.TaskId,
ReportId = job.ReportId,
FilePath = job.FilePath,
Status = "Pending",
Attempts = 0,
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
await _db.SaveChangesAsync(ct);
}
public Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.ReportAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.ReportAnalysisTasks.AsNoTracking()
.Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return null;
var claimed = await _db.ReportAnalysisTasks
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
return claimed == 0
? null
: new ReportAnalysisJob(candidate.Id, candidate.ReportId, candidate.FilePath);
}
public async Task CompleteAsync(Guid taskId, CancellationToken ct)
{
await _db.ReportAnalysisTasks
.Where(x => x.Id == taskId && x.Status == "Processing")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
public async Task RetryAsync(Guid taskId, string error, CancellationToken ct)
{
var task = await _db.ReportAnalysisTasks.FirstOrDefaultAsync(x => x.Id == taskId, ct);
if (task == null) return;
task.LastError = error.Length > 2000 ? error[..2000] : error;
task.UpdatedAt = DateTime.UtcNow;
if (task.Attempts >= MaxAttempts)
{
task.Status = "Failed";
}
else
{
task.Status = "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
}
}