feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
51
backend/src/Health.Application/Diets/DietContracts.cs
Normal file
51
backend/src/Health.Application/Diets/DietContracts.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Diets;
|
||||
|
||||
public sealed record DietFoodItemInput(
|
||||
string Name,
|
||||
string? Portion,
|
||||
int? Calories,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record DietRecordCreateRequest(
|
||||
MealType MealType,
|
||||
int? TotalCalories,
|
||||
int? HealthScore,
|
||||
DateOnly RecordedAt,
|
||||
IReadOnlyList<DietFoodItemInput> FoodItems);
|
||||
|
||||
public sealed record DietRecordPatchRequest(
|
||||
int? TotalCalories,
|
||||
int? HealthScore);
|
||||
|
||||
public sealed record DietFoodItemDto(
|
||||
string Name,
|
||||
string? Portion,
|
||||
int? Calories);
|
||||
|
||||
public sealed record DietRecordDto(
|
||||
Guid Id,
|
||||
string MealType,
|
||||
int? TotalCalories,
|
||||
int? HealthScore,
|
||||
DateOnly RecordedAt,
|
||||
IReadOnlyList<DietFoodItemDto> FoodItems);
|
||||
|
||||
public interface IDietService
|
||||
{
|
||||
Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IDietRepository
|
||||
{
|
||||
Task<IReadOnlyList<DietRecord>> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct);
|
||||
Task<DietRecord?> GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct);
|
||||
Task AddAsync(DietRecord record, CancellationToken ct);
|
||||
void Delete(DietRecord record);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Health.Application.Diets;
|
||||
|
||||
public sealed record DietImageUploadFile(string FileName, long Length, Stream Content);
|
||||
|
||||
public sealed record DietImageAnalysisJob(Guid Id, IReadOnlyList<string> FilePaths);
|
||||
|
||||
public sealed record DietImageAnalysisResult(bool Success, int Code, string? Data, string? Message);
|
||||
|
||||
public interface IDietImageAnalysisCoordinator
|
||||
{
|
||||
Task<DietImageAnalysisResult> AnalyzeAsync(IReadOnlyList<DietImageUploadFile> files, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IDietImageAnalysisQueue
|
||||
{
|
||||
Task<Guid> EnqueueAsync(IReadOnlyList<string> filePaths, CancellationToken ct);
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct);
|
||||
Task<DietImageAnalysisResult> WaitForResultAsync(Guid jobId, CancellationToken ct);
|
||||
Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct);
|
||||
Task<bool> RetryAsync(Guid jobId, string error, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IDietImageAnalyzer
|
||||
{
|
||||
Task<DietImageAnalysisResult> AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct);
|
||||
}
|
||||
77
backend/src/Health.Application/Diets/DietService.cs
Normal file
77
backend/src/Health.Application/Diets/DietService.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Diets;
|
||||
|
||||
public sealed class DietService(IDietRepository diets) : IDietService
|
||||
{
|
||||
private readonly IDietRepository _diets = diets;
|
||||
|
||||
public async Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct)
|
||||
{
|
||||
var recordedAt = DateOnly.TryParse(date, out var d) ? d : (DateOnly?)null;
|
||||
var parsedMealType = Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt) ? mt : (MealType?)null;
|
||||
|
||||
var records = await _diets.ListAsync(userId, recordedAt, parsedMealType, ct);
|
||||
return records.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = new DietRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MealType = request.MealType,
|
||||
TotalCalories = request.TotalCalories,
|
||||
HealthScore = request.HealthScore,
|
||||
RecordedAt = request.RecordedAt,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
foreach (var item in request.FoodItems)
|
||||
{
|
||||
record.FoodItems.Add(new DietFoodItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = item.Name,
|
||||
Portion = item.Portion,
|
||||
Calories = item.Calories,
|
||||
SortOrder = item.SortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
await _diets.AddAsync(record, ct);
|
||||
await _diets.SaveChangesAsync(ct);
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct)
|
||||
{
|
||||
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
_diets.Delete(record);
|
||||
await _diets.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
if (request.TotalCalories.HasValue) record.TotalCalories = request.TotalCalories.Value;
|
||||
if (request.HealthScore.HasValue) record.HealthScore = request.HealthScore.Value;
|
||||
await _diets.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DietRecordDto ToDto(DietRecord record) => new(
|
||||
record.Id,
|
||||
record.MealType.ToString(),
|
||||
record.TotalCalories,
|
||||
record.HealthScore,
|
||||
record.RecordedAt,
|
||||
record.FoodItems.OrderBy(f => f.SortOrder).Select(f => new DietFoodItemDto(f.Name, f.Portion, f.Calories)).ToList());
|
||||
}
|
||||
Reference in New Issue
Block a user