feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Exercises;
|
||||
|
||||
public sealed record ExercisePlanItemInput(
|
||||
DateOnly ScheduledDate,
|
||||
string? ExerciseType,
|
||||
int DurationMinutes,
|
||||
bool IsRestDay);
|
||||
|
||||
public sealed record ExercisePlanCreateRequest(
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
string? ExerciseType,
|
||||
int DurationMinutes,
|
||||
TimeOnly? ReminderTime = null);
|
||||
|
||||
public sealed record ExercisePlanItemDto(
|
||||
Guid Id,
|
||||
DateOnly ScheduledDate,
|
||||
string ExerciseType,
|
||||
int DurationMinutes,
|
||||
bool IsCompleted,
|
||||
DateTime? CompletedAt,
|
||||
bool IsRestDay);
|
||||
|
||||
public sealed record ExercisePlanDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
TimeOnly ReminderTime,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<ExercisePlanItemDto> Items);
|
||||
|
||||
public sealed record ExercisePlanSummaryDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
TimeOnly ReminderTime,
|
||||
DateTime CreatedAt,
|
||||
int TotalDays,
|
||||
int CompletedDays,
|
||||
IReadOnlyList<ExercisePlanItemDto> Items);
|
||||
|
||||
public interface IExerciseService
|
||||
{
|
||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
||||
Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IExerciseRepository
|
||||
{
|
||||
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task AddAsync(ExercisePlan plan, CancellationToken ct);
|
||||
void Delete(ExercisePlan plan);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IExerciseReminderProducer
|
||||
{
|
||||
Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
132
backend/src/Health.Application/Exercises/ExerciseService.cs
Normal file
132
backend/src/Health.Application/Exercises/ExerciseService.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Exercises;
|
||||
|
||||
public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService
|
||||
{
|
||||
private static readonly TimeOnly DefaultReminderTime = new(19, 0);
|
||||
private readonly IExerciseRepository _exercises = exercises;
|
||||
|
||||
public async Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var today = BeijingToday();
|
||||
var plans = await _exercises.ListActiveOnAsync(userId, today, ct);
|
||||
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.ScheduledDate == today));
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct)
|
||||
{
|
||||
var startDate = request.StartDate;
|
||||
var endDate = request.EndDate < startDate ? startDate : request.EndDate;
|
||||
if (endDate.DayNumber - startDate.DayNumber > 365)
|
||||
endDate = startDate.AddDays(365);
|
||||
|
||||
var exerciseType = string.IsNullOrWhiteSpace(request.ExerciseType) ? "运动" : request.ExerciseType.Trim();
|
||||
var duration = request.DurationMinutes > 0 ? request.DurationMinutes : 30;
|
||||
var plan = NewPlan(userId, startDate, endDate, request.ReminderTime);
|
||||
for (var date = startDate; date <= endDate; date = date.AddDays(1))
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType,
|
||||
DurationMinutes = duration, IsRestDay = false,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct)
|
||||
{
|
||||
var normalizedEnd = endDate < startDate ? startDate : endDate;
|
||||
var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime);
|
||||
foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First()))
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = item.ScheduledDate,
|
||||
ExerciseType = string.IsNullOrWhiteSpace(item.ExerciseType) ? "散步" : item.ExerciseType.Trim(),
|
||||
DurationMinutes = item.DurationMinutes > 0 ? item.DurationMinutes : 30,
|
||||
IsRestDay = item.IsRestDay,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plans = await _exercises.ListAsync(userId, 20, ct);
|
||||
return plans.Select(p => new ExercisePlanSummaryDto(
|
||||
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt,
|
||||
p.Items.Count, p.Items.Count(i => i.IsCompleted),
|
||||
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList();
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
|
||||
if (plan == null) return false;
|
||||
_exercises.Delete(plan);
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
|
||||
{
|
||||
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
||||
if (item == null) return null;
|
||||
item.IsCompleted = !item.IsCompleted;
|
||||
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
|
||||
item.Plan.UpdatedAt = DateTime.UtcNow;
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
return item.IsCompleted;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
|
||||
{
|
||||
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
||||
if (item == null) return false;
|
||||
item.IsCompleted = true;
|
||||
item.CompletedAt = DateTime.UtcNow;
|
||||
item.Plan.UpdatedAt = DateTime.UtcNow;
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetLatestAsync(userId, ct);
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
|
||||
{
|
||||
await _exercises.AddAsync(plan, ct);
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate,
|
||||
ReminderTime = reminderTime ?? DefaultReminderTime,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
|
||||
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
|
||||
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
|
||||
|
||||
private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
|
||||
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
|
||||
item.IsCompleted, item.CompletedAt, item.IsRestDay);
|
||||
|
||||
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
}
|
||||
Reference in New Issue
Block a user