Compare commits

...

2 Commits

Author SHA1 Message Date
MingNian
13714d9ed8 feat: 应用内通知系统 + 结构化手术史/用药等相关改动
- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker
- 通知中心页面及前端通知服务接入
- 健康指标异常、用药/运动提醒等事件统一产出站内通知
- 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整
- AppDbContext 注册通知相关实体
2026-06-21 21:06:29 +08:00
MingNian
b57d0d16f4 feat: 引入 EF Core Migrations + 后台任务/校验修复 + 前端四态
后端
- 改用 EF Core Migrations(InitialCreate),Program.cs 用 MigrateAsync + AUTO_MIGRATE 开关 + 显式 MigrationsAssembly;移除 EnsureCreated、删除手写 DatabaseSchemaMigrator
- 修复后台任务永久卡 Processing:三个队列对超时且达上限的任务原子标记 Failed
- 修复报告重试失效:基础设施异常向上抛激活 RetryAsync,停机取消不计失败
- 修复 AI 用药天数 off-by-one(结束日为包含式)
- AI 报告日志不再记录 VLM 健康正文
- 新增统一输入校验(ValidationException + 中间件映射 400):血压/心率/血糖/血氧/体重范围、药名/日期/服药时间去重、饮食热量与评分、运动时长上限
- 删除健康数据 AI 录入的影子直写路径,统一走 Service 校验

前端
- 新增 AppErrorState / AppFutureView,用药列表、服药打卡、运动计划、随访列表改为 加载/失败/空/数据 四态,失败可重试

瘦身
- 删除过时的 DataSeeder / DevDataSeeder 及调用
- 删除依赖实时服务的 ai_agent_tests 集成测试
2026-06-21 21:04:40 +08:00
64 changed files with 5918 additions and 952 deletions

13
backend/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.8",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -24,7 +24,10 @@ public sealed class PatientContextService(
if (archive != null) if (archive != null)
{ {
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}"); if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})"); if (archive.Surgeries.Count > 0)
sb.AppendLine($"手术: {string.Join("", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
else if (!string.IsNullOrEmpty(archive.SurgeryType))
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
if (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}"); if (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}");
if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}"); if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}");
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}"); if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");

View File

@@ -1,3 +1,4 @@
using Health.Domain;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums; using Health.Domain.Enums;
@@ -5,6 +6,7 @@ namespace Health.Application.Diets;
public sealed class DietService(IDietRepository diets) : IDietService public sealed class DietService(IDietRepository diets) : IDietService
{ {
private const int MaxCalories = 20000; // 单条记录/单项热量上限kcal
private readonly IDietRepository _diets = diets; private readonly IDietRepository _diets = diets;
public async Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct) public async Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct)
@@ -18,6 +20,15 @@ public sealed class DietService(IDietRepository diets) : IDietService
public async Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct) public async Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct)
{ {
ValidateCalories(request.TotalCalories, "总热量");
ValidateScore(request.HealthScore);
foreach (var item in request.FoodItems)
{
if (string.IsNullOrWhiteSpace(item.Name))
throw new ValidationException("食物名称不能为空");
ValidateCalories(item.Calories, $"食物「{item.Name.Trim()}」热量");
}
var record = new DietRecord var record = new DietRecord
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@@ -34,7 +45,7 @@ public sealed class DietService(IDietRepository diets) : IDietService
record.FoodItems.Add(new DietFoodItem record.FoodItems.Add(new DietFoodItem
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = item.Name, Name = item.Name.Trim(),
Portion = item.Portion, Portion = item.Portion,
Calories = item.Calories, Calories = item.Calories,
SortOrder = item.SortOrder, SortOrder = item.SortOrder,
@@ -61,12 +72,27 @@ public sealed class DietService(IDietRepository diets) : IDietService
var record = await _diets.GetOwnedAsync(userId, recordId, ct); var record = await _diets.GetOwnedAsync(userId, recordId, ct);
if (record == null) return false; if (record == null) return false;
ValidateCalories(request.TotalCalories, "总热量");
ValidateScore(request.HealthScore);
if (request.TotalCalories.HasValue) record.TotalCalories = request.TotalCalories.Value; if (request.TotalCalories.HasValue) record.TotalCalories = request.TotalCalories.Value;
if (request.HealthScore.HasValue) record.HealthScore = request.HealthScore.Value; if (request.HealthScore.HasValue) record.HealthScore = request.HealthScore.Value;
await _diets.SaveChangesAsync(ct); await _diets.SaveChangesAsync(ct);
return true; return true;
} }
private static void ValidateCalories(int? calories, string field)
{
if (calories.HasValue && (calories.Value < 0 || calories.Value > MaxCalories))
throw new ValidationException($"{field}应在 0~{MaxCalories} kcal 之间,当前值 {calories.Value} 不合理");
}
private static void ValidateScore(int? score)
{
if (score.HasValue && (score.Value < 0 || score.Value > 100))
throw new ValidationException($"健康评分应在 0~100 之间,当前值 {score.Value} 不合理");
}
private static DietRecordDto ToDto(DietRecord record) => new( private static DietRecordDto ToDto(DietRecord record) => new(
record.Id, record.Id,
record.MealType.ToString(), record.MealType.ToString(),

View File

@@ -1,9 +1,11 @@
using Health.Domain;
using Health.Domain.Entities; using Health.Domain.Entities;
namespace Health.Application.Exercises; namespace Health.Application.Exercises;
public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService
{ {
private const int MaxDurationMinutes = 1440; // 单次运动时长上限 24 小时
private static readonly TimeOnly DefaultReminderTime = new(19, 0); private static readonly TimeOnly DefaultReminderTime = new(19, 0);
private readonly IExerciseRepository _exercises = exercises; private readonly IExerciseRepository _exercises = exercises;
@@ -17,6 +19,8 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
public async Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct) public async Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct)
{ {
ValidateDuration(request.DurationMinutes);
var startDate = request.StartDate; var startDate = request.StartDate;
var endDate = request.EndDate < startDate ? startDate : request.EndDate; var endDate = request.EndDate < startDate ? startDate : request.EndDate;
if (endDate.DayNumber - startDate.DayNumber > 365) if (endDate.DayNumber - startDate.DayNumber > 365)
@@ -39,6 +43,9 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct) public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct)
{ {
foreach (var item in items)
ValidateDuration(item.DurationMinutes);
var normalizedEnd = endDate < startDate ? startDate : endDate; var normalizedEnd = endDate < startDate ? startDate : endDate;
var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime); 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())) foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First()))
@@ -55,6 +62,13 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
return plan.Id; return plan.Id;
} }
// 时长上限校验:下限交由各方法兜底为 30这里只拦截不合理的超大值
private static void ValidateDuration(int durationMinutes)
{
if (durationMinutes > MaxDurationMinutes)
throw new ValidationException($"单次运动时长不能超过 {MaxDurationMinutes} 分钟24 小时),当前值 {durationMinutes} 不合理");
}
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct) public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
{ {
var plans = await _exercises.ListAsync(userId, 20, ct); var plans = await _exercises.ListAsync(userId, 20, ct);

View File

@@ -2,12 +2,22 @@ using Health.Domain.Entities;
namespace Health.Application.HealthArchives; namespace Health.Application.HealthArchives;
public sealed record HealthArchiveSurgeryDto(
Guid Id,
string Type,
DateOnly? Date);
public sealed record HealthArchiveSurgeryInput(
string Type,
DateOnly? Date);
public sealed record HealthArchiveDto( public sealed record HealthArchiveDto(
Guid Id, Guid Id,
Guid UserId, Guid UserId,
string? Diagnosis, string? Diagnosis,
string? SurgeryType, string? SurgeryType,
DateOnly? SurgeryDate, DateOnly? SurgeryDate,
IReadOnlyList<HealthArchiveSurgeryDto> Surgeries,
IReadOnlyList<string> Allergies, IReadOnlyList<string> Allergies,
IReadOnlyList<string> DietRestrictions, IReadOnlyList<string> DietRestrictions,
IReadOnlyList<string> ChronicDiseases, IReadOnlyList<string> ChronicDiseases,
@@ -21,7 +31,8 @@ public sealed record HealthArchiveUpdateRequest(
IReadOnlyList<string>? Allergies, IReadOnlyList<string>? Allergies,
IReadOnlyList<string>? DietRestrictions, IReadOnlyList<string>? DietRestrictions,
IReadOnlyList<string>? ChronicDiseases, IReadOnlyList<string>? ChronicDiseases,
string? FamilyHistory); string? FamilyHistory,
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
public interface IHealthArchiveService public interface IHealthArchiveService
{ {

View File

@@ -42,7 +42,8 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
"update_diagnosis" => request with "update_diagnosis" => request with
{ {
SurgeryType = null, SurgeryDate = null, Allergies = null, SurgeryType = null, SurgeryDate = null, Allergies = null,
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null,
Surgeries = null
}, },
"update_surgery" => request with "update_surgery" => request with
{ {
@@ -77,8 +78,41 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request) private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request)
{ {
if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis; if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis;
if (request.SurgeryType != null) archive.SurgeryType = request.SurgeryType; if (request.Surgeries != null)
if (request.SurgeryDate.HasValue) archive.SurgeryDate = request.SurgeryDate.Value; {
archive.Surgeries.Clear();
var items = request.Surgeries
.Where(s => !string.IsNullOrWhiteSpace(s.Type))
.Select((s, index) => new HealthArchiveSurgery
{
Id = Guid.NewGuid(),
HealthArchiveId = archive.Id,
Type = s.Type.Trim(),
Date = s.Date,
SortOrder = index,
})
.ToList();
foreach (var item in items) archive.Surgeries.Add(item);
SyncLegacySurgeryFields(archive);
}
else if (request.SurgeryType != null)
{
SeedLegacySurgeryIfNeeded(archive);
var type = request.SurgeryType.Trim();
if (type.Length > 0 && !archive.Surgeries.Any(s =>
s.Type == type && s.Date == request.SurgeryDate))
{
archive.Surgeries.Add(new HealthArchiveSurgery
{
Id = Guid.NewGuid(),
HealthArchiveId = archive.Id,
Type = type,
Date = request.SurgeryDate,
SortOrder = archive.Surgeries.Count,
});
}
SyncLegacySurgeryFields(archive);
}
if (request.Allergies != null) archive.Allergies = request.Allergies.ToList(); if (request.Allergies != null) archive.Allergies = request.Allergies.ToList();
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList(); if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList(); if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
@@ -91,6 +125,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
archive.Diagnosis, archive.Diagnosis,
archive.SurgeryType, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
surgeries = archive.Surgeries.Select(s => new { s.Type, date = s.Date?.ToString("yyyy-MM-dd") }),
archive.Allergies, archive.Allergies,
archive.DietRestrictions, archive.DietRestrictions,
archive.ChronicDiseases, archive.ChronicDiseases,
@@ -103,9 +138,41 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
archive.Diagnosis, archive.Diagnosis,
archive.SurgeryType, archive.SurgeryType,
archive.SurgeryDate, archive.SurgeryDate,
GetSurgeries(archive),
archive.Allergies, archive.Allergies,
archive.DietRestrictions, archive.DietRestrictions,
archive.ChronicDiseases, archive.ChronicDiseases,
archive.FamilyHistory, archive.FamilyHistory,
archive.UpdatedAt); archive.UpdatedAt);
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
{
var surgeries = archive.Surgeries
.OrderBy(s => s.SortOrder)
.Select(s => new HealthArchiveSurgeryDto(s.Id, s.Type, s.Date))
.ToList();
if (surgeries.Count == 0 && !string.IsNullOrWhiteSpace(archive.SurgeryType))
surgeries.Add(new HealthArchiveSurgeryDto(Guid.Empty, archive.SurgeryType, archive.SurgeryDate));
return surgeries;
}
private static void SyncLegacySurgeryFields(HealthArchive archive)
{
var first = archive.Surgeries.OrderBy(s => s.SortOrder).FirstOrDefault();
archive.SurgeryType = first?.Type;
archive.SurgeryDate = first?.Date;
}
private static void SeedLegacySurgeryIfNeeded(HealthArchive archive)
{
if (archive.Surgeries.Count > 0 || string.IsNullOrWhiteSpace(archive.SurgeryType)) return;
archive.Surgeries.Add(new HealthArchiveSurgery
{
Id = Guid.NewGuid(),
HealthArchiveId = archive.Id,
Type = archive.SurgeryType,
Date = archive.SurgeryDate,
SortOrder = 0,
});
}
} }

View File

@@ -1,3 +1,4 @@
using Health.Domain;
using Health.Domain.Enums; using Health.Domain.Enums;
namespace Health.Application.HealthRecords; namespace Health.Application.HealthRecords;
@@ -13,4 +14,48 @@ public static class HealthRecordRules
HealthMetricType.Weight => false, HealthMetricType.Weight => false,
_ => false _ => false
}; };
/// <summary>
/// 合法性校验——拦截物理上不可能的数值(负数、超量程等),不合法即抛 ValidationException。
/// 注意:这与 CheckAbnormal医学异常预警是两回事前者拦脏数据后者只是标记偏离正常范围。
/// </summary>
public static void Validate(HealthRecordUpsertRequest request)
{
switch (request.Type)
{
case HealthMetricType.BloodPressure:
var sys = Required(request.Systolic, "收缩压");
var dia = Required(request.Diastolic, "舒张压");
InRange(sys, 40, 300, "收缩压", "mmHg");
InRange(dia, 20, 250, "舒张压", "mmHg");
if (sys <= dia)
throw new ValidationException("收缩压必须大于舒张压");
break;
case HealthMetricType.HeartRate:
InRange(Required(request.Value, "心率"), 20m, 300m, "心率", "次/分");
break;
case HealthMetricType.Glucose:
InRange(Required(request.Value, "血糖"), 0.5m, 60m, "血糖", "mmol/L");
break;
case HealthMetricType.SpO2:
InRange(Required(request.Value, "血氧"), 50m, 100m, "血氧", "%");
break;
case HealthMetricType.Weight:
InRange(Required(request.Value, "体重"), 1m, 500m, "体重", "kg");
break;
}
}
private static int Required(int? value, string field) =>
value ?? throw new ValidationException($"{field}不能为空");
private static decimal Required(decimal? value, string field) =>
value ?? throw new ValidationException($"{field}不能为空");
private static void InRange(decimal value, decimal min, decimal max, string field, string unit)
{
if (value < min || value > max)
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
}
} }

View File

@@ -1,11 +1,17 @@
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums; using Health.Domain.Enums;
using Health.Application.Notifications;
using System.Security.Cryptography;
using System.Text;
namespace Health.Application.HealthRecords; namespace Health.Application.HealthRecords;
public sealed class HealthRecordService(IHealthRecordRepository records) : IHealthRecordService public sealed class HealthRecordService(
IHealthRecordRepository records,
IUserNotificationProducer? notifications = null) : IHealthRecordService
{ {
private readonly IHealthRecordRepository _records = records; private readonly IHealthRecordRepository _records = records;
private readonly IUserNotificationProducer? _notifications = notifications;
public async Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct) public async Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct)
{ {
@@ -20,6 +26,8 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct) public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct)
{ {
HealthRecordRules.Validate(request);
var record = new HealthRecord var record = new HealthRecord
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@@ -37,6 +45,7 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
await _records.AddAsync(record, ct); await _records.AddAsync(record, ct);
await _records.SaveChangesAsync(ct); await _records.SaveChangesAsync(ct);
await EnqueueAbnormalNotificationAsync(record, ct);
return record.Id; return record.Id;
} }
@@ -45,6 +54,8 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
var record = await _records.GetOwnedAsync(userId, id, ct); var record = await _records.GetOwnedAsync(userId, id, ct);
if (record == null) return false; if (record == null) return false;
HealthRecordRules.Validate(request);
record.MetricType = request.Type; record.MetricType = request.Type;
record.Systolic = request.Systolic; record.Systolic = request.Systolic;
record.Diastolic = request.Diastolic; record.Diastolic = request.Diastolic;
@@ -55,6 +66,7 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
record.IsAbnormal = HealthRecordRules.CheckAbnormal(request); record.IsAbnormal = HealthRecordRules.CheckAbnormal(request);
await _records.SaveChangesAsync(ct); await _records.SaveChangesAsync(ct);
await EnqueueAbnormalNotificationAsync(record, ct);
return true; return true;
} }
@@ -98,9 +110,9 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct) public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct)
{ {
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 }; var days = period switch { 7 => 7, 30 => 30, 90 => 90, 365 => 365, _ => 7 };
var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct); var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct);
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList(); return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
} }
public static HealthRecordDto ToDto(HealthRecord record) => new( public static HealthRecordDto ToDto(HealthRecord record) => new(
@@ -113,4 +125,42 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
record.Source.ToString(), record.Source.ToString(),
record.IsAbnormal, record.IsAbnormal,
record.RecordedAt); record.RecordedAt);
private async Task EnqueueAbnormalNotificationAsync(HealthRecord record, CancellationToken ct)
{
if (!record.IsAbnormal || _notifications == null) return;
var (title, message, severity, target) = record.MetricType switch
{
HealthMetricType.BloodPressure when record.Systolic >= 140 || record.Diastolic >= 90 =>
("血压偏高提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg高于参考范围。建议休息后复测如伴明显不适请及时就医。", "warning", "blood_pressure"),
HealthMetricType.BloodPressure =>
("血压偏低提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg低于参考范围。请留意头晕、乏力等不适必要时及时就医。", "warning", "blood_pressure"),
HealthMetricType.HeartRate when record.Value > 100 =>
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
HealthMetricType.HeartRate =>
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
HealthMetricType.Glucose when record.Value >= 7.0m =>
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
HealthMetricType.Glucose =>
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L低于参考范围。请及时关注身体状况。", "critical", "glucose"),
HealthMetricType.SpO2 =>
("血氧偏低提醒", $"本次血氧为 {record.Value:0.#}%,低于参考范围。建议立即复测;如持续偏低或伴呼吸不适,请及时就医。", "critical", "spo2"),
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
};
var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
await _notifications.EnqueueAsync(
record.UserId,
sourceId,
new NotificationMessage("HealthMetricAlert", title, message, severity, "health", target),
ct);
}
private static Guid DeterministicGuid(string value)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return new Guid(hash.AsSpan(0, 16));
}
} }

View File

@@ -78,7 +78,6 @@ public interface IMedicationRepository
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct); Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct); Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct); Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task AddMedicationAsync(Medication medication, CancellationToken ct); Task AddMedicationAsync(Medication medication, CancellationToken ct);
Task AddLogAsync(MedicationLog log, CancellationToken ct); Task AddLogAsync(MedicationLog log, CancellationToken ct);
void DeleteMedication(Medication medication); void DeleteMedication(Medication medication);

View File

@@ -21,10 +21,18 @@ public sealed class MedicationReminderScanner(IMedicationRepository medications)
foreach (var medication in active) foreach (var medication in active)
{ {
if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue; if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue;
if (await _medications.HasTakenInWindowAsync(medication.UserId, medication.Id, todayStartUtc, todayEndUtc, ct)) continue;
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time))) foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time)))
{ {
if (await _medications.DoseLogExistsAsync(
medication.UserId,
medication.Id,
scheduledTime,
todayStartUtc,
todayEndUtc,
ct))
continue;
tasks.Add(new MedicationReminderTask( tasks.Add(new MedicationReminderTask(
Guid.NewGuid(), Guid.NewGuid(),
medication.UserId, medication.UserId,

View File

@@ -1,3 +1,4 @@
using Health.Domain;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums; using Health.Domain.Enums;
@@ -16,14 +17,19 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
public async Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct) public async Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct)
{ {
var name = ValidateName(request.Name);
ValidateDateRange(request.StartDate, request.EndDate);
var times = NormalizeTimes(request.TimeOfDay);
if (times.Count == 0) times = [new TimeOnly(8, 0)];
var med = new Medication var med = new Medication
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
UserId = userId, UserId = userId,
Name = request.Name, Name = name,
Dosage = request.Dosage, Dosage = request.Dosage,
Frequency = request.Frequency, Frequency = request.Frequency,
TimeOfDay = request.TimeOfDay.Count > 0 ? request.TimeOfDay.ToList() : [new TimeOnly(8, 0)], TimeOfDay = times,
StartDate = request.StartDate, StartDate = request.StartDate,
EndDate = request.EndDate, EndDate = request.EndDate,
Source = request.Source, Source = request.Source,
@@ -43,12 +49,13 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
var med = await _medications.GetOwnedAsync(userId, medicationId, ct); var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
if (med == null) return false; if (med == null) return false;
if (!string.IsNullOrWhiteSpace(request.Name)) med.Name = request.Name; if (request.Name != null) med.Name = ValidateName(request.Name);
if (request.Dosage != null) med.Dosage = request.Dosage; if (request.Dosage != null) med.Dosage = request.Dosage;
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value; if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
if (request.TimeOfDay != null) med.TimeOfDay = request.TimeOfDay.ToList(); if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value; if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value; if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
ValidateDateRange(med.StartDate, med.EndDate);
if (request.Notes != null) med.Notes = request.Notes; if (request.Notes != null) med.Notes = request.Notes;
med.UpdatedAt = DateTime.UtcNow; med.UpdatedAt = DateTime.UtcNow;
@@ -56,6 +63,28 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
return true; return true;
} }
// ── 输入校验 ──
private static string ValidateName(string? name)
{
var trimmed = name?.Trim();
if (string.IsNullOrEmpty(trimmed))
throw new ValidationException("药品名称不能为空");
if (trimmed.Length > 200)
throw new ValidationException("药品名称过长(不超过 200 字)");
return trimmed;
}
private static void ValidateDateRange(DateOnly? startDate, DateOnly? endDate)
{
if (startDate.HasValue && endDate.HasValue && endDate.Value < startDate.Value)
throw new ValidationException("结束日期不能早于开始日期");
}
// 同一时间点去重并排序;空集合交由调用方/实体默认处理
private static List<TimeOnly> NormalizeTimes(IReadOnlyList<TimeOnly> times) =>
times.Distinct().OrderBy(t => t).ToList();
public async Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct) public async Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct)
{ {
var med = await _medications.GetOwnedAsync(userId, medicationId, ct); var med = await _medications.GetOwnedAsync(userId, medicationId, ct);

View File

@@ -5,22 +5,56 @@ public sealed record InAppNotificationDto(
string Type, string Type,
string Title, string Title,
string Message, string Message,
string Severity,
string? ActionType,
string? ActionTargetId,
bool IsRead,
DateTime CreatedAt); DateTime CreatedAt);
public sealed record NotificationMessage(
string Type,
string Title,
string Message,
string Severity,
string? ActionType,
string? ActionTargetId);
public interface IInAppNotificationService public interface IInAppNotificationService
{ {
Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct); Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<InAppNotificationDto>> GetHistoryAsync(Guid userId, CancellationToken ct);
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct); Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct);
} }
public interface IInAppNotificationRepository public interface IInAppNotificationRepository
{ {
Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct); Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<InAppNotificationRecord>> GetHistoryAsync(Guid userId, CancellationToken ct);
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct); Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct);
} }
public sealed record InAppNotificationRecord( public sealed record InAppNotificationRecord(
Guid Id, Guid Id,
string Type, string Type,
string Payload, string Title,
string Message,
string Severity,
string? ActionType,
string? ActionTargetId,
bool IsRead,
DateTime CreatedAt); DateTime CreatedAt);
public interface IUserNotificationProducer
{
Task<bool> EnqueueAsync(Guid userId, Guid sourceId, NotificationMessage message, CancellationToken ct);
}
public interface INotificationOutboxProcessor
{
Task RecoverStaleAsync(CancellationToken ct);
Task<bool> ProcessNextAsync(CancellationToken ct);
}

View File

@@ -1,5 +1,3 @@
using System.Text.Json;
namespace Health.Application.Notifications; namespace Health.Application.Notifications;
public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService
@@ -12,36 +10,22 @@ public sealed class InAppNotificationService(IInAppNotificationRepository notifi
return records.Select(ToDto).ToList(); return records.Select(ToDto).ToList();
} }
public async Task<IReadOnlyList<InAppNotificationDto>> GetHistoryAsync(Guid userId, CancellationToken ct)
{
var records = await _notifications.GetHistoryAsync(userId, ct);
return records.Select(ToDto).ToList();
}
public Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) => public Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
_notifications.AcknowledgeAsync(userId, notificationId, ct); _notifications.AcknowledgeAsync(userId, notificationId, ct);
private static InAppNotificationDto ToDto(InAppNotificationRecord record) public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
{ _notifications.MarkAllReadAsync(userId, ct);
if (record.Type == "MedicationReminder")
{
using var json = JsonDocument.Parse(record.Payload);
var root = json.RootElement;
var name = root.TryGetProperty("Name", out var nameValue) ? nameValue.GetString() : null;
var dosage = root.TryGetProperty("Dosage", out var dosageValue) ? dosageValue.GetString() : null;
var time = root.TryGetProperty("ScheduledTime", out var timeValue) ? timeValue.GetString() : null;
var details = string.Join(" · ", new[] { dosage, time }.Where(x => !string.IsNullOrWhiteSpace(x)));
return new InAppNotificationDto(
record.Id,
record.Type,
"用药提醒",
$"该服用{name ?? ""}{(details.Length > 0 ? $"{details}" : "")}了",
record.CreatedAt);
}
if (record.Type == "ExerciseReminder") public Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
{ _notifications.DeleteAsync(userId, notificationId, ct);
using var json = JsonDocument.Parse(record.Payload);
var root = json.RootElement;
var type = root.TryGetProperty("ExerciseType", out var typeValue) ? typeValue.GetString() : "运动";
var minutes = root.TryGetProperty("DurationMinutes", out var minutesValue) ? minutesValue.GetInt32() : 30;
return new InAppNotificationDto(record.Id, record.Type, "运动提醒", $"今天的{type}计划还未完成,目标 {minutes} 分钟", record.CreatedAt);
}
return new InAppNotificationDto(record.Id, record.Type, "健康提醒", "您有一条新的健康提醒", record.CreatedAt); private static InAppNotificationDto ToDto(InAppNotificationRecord record) => new(
} record.Id, record.Type, record.Title, record.Message, record.Severity,
record.ActionType, record.ActionTargetId, record.IsRead, record.CreatedAt);
} }

View File

@@ -38,6 +38,18 @@ public sealed class HealthArchive
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!; public User User { get; set; } = null!;
public ICollection<HealthArchiveSurgery> Surgeries { get; set; } = [];
}
public sealed class HealthArchiveSurgery
{
public Guid Id { get; set; }
public Guid HealthArchiveId { get; set; }
public string Type { get; set; } = string.Empty;
public DateOnly? Date { get; set; }
public int SortOrder { get; set; }
public HealthArchive HealthArchive { get; set; } = null!;
} }
/// <summary> /// <summary>
@@ -97,3 +109,20 @@ public sealed class DeviceToken
public User User { get; set; } = null!; public User User { get; set; } = null!;
} }
public sealed class UserNotification
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid SourceId { get; set; }
public string Type { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Severity { get; set; } = "info";
public string? ActionType { get; set; }
public string? ActionTargetId { get; set; }
public bool IsRead { get; set; }
public DateTime? ReadAt { get; set; }
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,7 @@
namespace Health.Domain;
/// <summary>
/// 业务输入校验失败异常——由 Application 层规则抛出,
/// 中间件统一映射为 400 / code 40001message 可安全展示给用户。
/// </summary>
public sealed class ValidationException(string message) : Exception(message);

View File

@@ -55,7 +55,7 @@ public static class CommonAgentHandler
return new return new
{ {
found = true, archive.Diagnosis, archive.SurgeryType, found = true, archive.Diagnosis, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries,
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
}; };
} }

View File

@@ -18,13 +18,11 @@ public static class HealthDataAgentHandler
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool]; public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId, IHealthRecordService? healthRecords = null) public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
{ {
return toolName switch return toolName switch
{ {
"record_health_data" => healthRecords == null "record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args),
? await ExecuteRecordHealthData(db, userId, args)
: await ExecuteRecordHealthData(healthRecords, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" } _ => new { success = false, message = $"未知工具: {toolName}" }
}; };
} }
@@ -62,63 +60,6 @@ public static class HealthDataAgentHandler
}; };
} }
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var record = new HealthRecord
{
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
};
switch (type)
{
case "blood_pressure":
record.MetricType = HealthMetricType.BloodPressure;
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
record.Unit = "mmHg";
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
break;
case "heart_rate":
record.MetricType = HealthMetricType.HeartRate;
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
record.Unit = "次/分";
record.IsAbnormal = record.Value > 100 || record.Value < 60;
break;
case "glucose":
record.MetricType = HealthMetricType.Glucose;
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
record.Unit = "mmol/L";
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
break;
case "spo2":
record.MetricType = HealthMetricType.SpO2;
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
record.Unit = "%";
record.IsAbnormal = record.Value <= 94;
break;
case "weight":
record.MetricType = HealthMetricType.Weight;
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
record.Unit = "kg";
record.IsAbnormal = false;
break;
default:
return new { success = false, message = $"未知指标类型: {type}" };
}
db.HealthRecords.Add(record);
await db.SaveChangesAsync();
var valStr = record.MetricType switch
{
HealthMetricType.BloodPressure => $"{record.Systolic}/{record.Diastolic}",
_ => record.Value?.ToString() ?? ""
};
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
}
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args) private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
{ {
return type switch return type switch

View File

@@ -71,7 +71,8 @@ public static class MedicationAgentHandler
var times = ReadTimes(args); var times = ReadTimes(args);
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0; var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
var startDate = ReadStartDate(args); var startDate = ReadStartDate(args);
var endDate = durationDays > 0 ? startDate.AddDays(durationDays) : (DateOnly?)null; // 结束日为包含式IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
var endDate = durationDays > 0 ? startDate.AddDays(durationDays - 1) : (DateOnly?)null;
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest( var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
name, name,

View File

@@ -31,7 +31,7 @@ public sealed class AiToolExecutionService(
var root = json.RootElement; var root = json.RootElement;
return await (toolName switch return await (toolName switch
{ {
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, _db, userId, _healthRecords), "record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), "query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), "check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct),

View File

@@ -1,166 +0,0 @@
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Data;
public sealed class DatabaseSchemaMigrator(AppDbContext db, ILogger<DatabaseSchemaMigrator> logger)
{
private readonly AppDbContext _db = db;
private readonly ILogger<DatabaseSchemaMigrator> _logger = logger;
public async Task ApplyAsync(CancellationToken ct = default)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
await _db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "__AppSchemaMigrations" (
"Id" varchar(128) PRIMARY KEY,
"AppliedAt" timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS "AiWriteCommands" (
"Id" uuid PRIMARY KEY,
"UserId" uuid NOT NULL,
"ToolName" varchar(64) NOT NULL,
"Arguments" jsonb NOT NULL,
"Status" varchar(32) NOT NULL,
"ExpiresAt" timestamptz NOT NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_AiWriteCommands_UserId_Status_ExpiresAt"
ON "AiWriteCommands" ("UserId", "Status", "ExpiresAt");
CREATE TABLE IF NOT EXISTS "ReportAnalysisTasks" (
"Id" uuid PRIMARY KEY,
"ReportId" uuid NOT NULL,
"FilePath" text NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_Status_AvailableAt"
ON "ReportAnalysisTasks" ("Status", "AvailableAt");
CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_ReportId"
ON "ReportAnalysisTasks" ("ReportId");
CREATE TABLE IF NOT EXISTS "DietImageAnalysisTasks" (
"Id" uuid PRIMARY KEY,
"FilePaths" jsonb NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"ResultCode" integer NULL,
"ResultData" text NULL,
"ResultMessage" text NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_DietImageAnalysisTasks_Status_AvailableAt"
ON "DietImageAnalysisTasks" ("Status", "AvailableAt");
CREATE TABLE IF NOT EXISTS "MedicationReminderTasks" (
"Id" uuid PRIMARY KEY,
"UserId" uuid NOT NULL,
"MedicationId" uuid NOT NULL,
"Name" text NOT NULL,
"Dosage" text NULL,
"ScheduledDate" date NOT NULL,
"ScheduledTime" time without time zone NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_Status_AvailableAt"
ON "MedicationReminderTasks" ("Status", "AvailableAt");
CREATE UNIQUE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_MedicationId_ScheduledDate_ScheduledTime"
ON "MedicationReminderTasks" ("MedicationId", "ScheduledDate", "ScheduledTime");
CREATE TABLE IF NOT EXISTS "NotificationOutbox" (
"Id" uuid PRIMARY KEY,
"UserId" uuid NOT NULL,
"SourceTaskId" uuid NOT NULL,
"Type" varchar(64) NOT NULL,
"Payload" jsonb NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_NotificationOutbox_SourceTaskId"
ON "NotificationOutbox" ("SourceTaskId");
CREATE INDEX IF NOT EXISTS "IX_NotificationOutbox_Status_AvailableAt"
ON "NotificationOutbox" ("Status", "AvailableAt");
ALTER TABLE IF EXISTS "ExercisePlans"
ADD COLUMN IF NOT EXISTS "StartDate" date NULL,
ADD COLUMN IF NOT EXISTS "EndDate" date NULL,
ADD COLUMN IF NOT EXISTS "ReminderTime" time without time zone NOT NULL DEFAULT TIME '19:00';
ALTER TABLE IF EXISTS "ExercisePlanItems"
ADD COLUMN IF NOT EXISTS "ScheduledDate" date NULL;
DO $exercise_migration$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'ExercisePlans' AND column_name = 'WeekStartDate'
) THEN
UPDATE "ExercisePlans"
SET "StartDate" = COALESCE("StartDate", "WeekStartDate")
WHERE "StartDate" IS NULL;
WITH ranked AS (
SELECT i."Id", p."StartDate",
ROW_NUMBER() OVER (PARTITION BY i."PlanId" ORDER BY i."Id") - 1 AS day_offset
FROM "ExercisePlanItems" i
JOIN "ExercisePlans" p ON p."Id" = i."PlanId"
WHERE i."ScheduledDate" IS NULL
)
UPDATE "ExercisePlanItems" i
SET "ScheduledDate" = ranked."StartDate" + ranked.day_offset::integer
FROM ranked
WHERE i."Id" = ranked."Id";
UPDATE "ExercisePlans" p
SET "EndDate" = COALESCE(
p."EndDate",
(SELECT MAX(i."ScheduledDate") FROM "ExercisePlanItems" i WHERE i."PlanId" = p."Id"),
p."StartDate"
)
WHERE p."EndDate" IS NULL;
END IF;
END $exercise_migration$;
ALTER TABLE IF EXISTS "ExercisePlans"
ALTER COLUMN "StartDate" SET NOT NULL,
ALTER COLUMN "EndDate" SET NOT NULL;
ALTER TABLE IF EXISTS "ExercisePlanItems"
ALTER COLUMN "ScheduledDate" SET NOT NULL;
CREATE INDEX IF NOT EXISTS "IX_ExercisePlans_UserId_StartDate_EndDate"
ON "ExercisePlans" ("UserId", "StartDate", "EndDate");
CREATE UNIQUE INDEX IF NOT EXISTS "IX_ExercisePlanItems_PlanId_ScheduledDate"
ON "ExercisePlanItems" ("PlanId", "ScheduledDate");
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
VALUES ('20260619_01_persistent_ai_commands_and_report_tasks', now())
ON CONFLICT ("Id") DO NOTHING;
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
VALUES ('20260619_02_persistent_diet_and_medication_tasks', now())
ON CONFLICT ("Id") DO NOTHING;
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
VALUES ('20260620_03_date_based_exercise_plans', now())
ON CONFLICT ("Id") DO NOTHING;
""", ct);
await transaction.CommitAsync(ct);
_logger.LogInformation("数据库结构迁移已检查: 20260620_03");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,946 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AiWriteCommands",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ToolName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Arguments = table.Column<string>(type: "jsonb", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AiWriteCommands", x => x.Id);
});
migrationBuilder.CreateTable(
name: "DietImageAnalysisTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FilePaths = table.Column<string>(type: "jsonb", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ResultCode = table.Column<int>(type: "integer", nullable: true),
ResultData = table.Column<string>(type: "text", nullable: true),
ResultMessage = table.Column<string>(type: "text", nullable: true),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DietImageAnalysisTasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Doctors",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: true),
Department = table.Column<string>(type: "text", nullable: true),
Phone = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
ProfessionalDirection = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: true),
Introduction = table.Column<string>(type: "text", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Doctors", x => x.Id);
});
migrationBuilder.CreateTable(
name: "MedicationReminderTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Dosage = table.Column<string>(type: "text", nullable: true),
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MedicationReminderTasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NotificationOutbox",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
SourceTaskId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Payload = table.Column<string>(type: "jsonb", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationOutbox", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RefreshTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Token = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsRevoked = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ReportAnalysisTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ReportId = table.Column<Guid>(type: "uuid", nullable: false),
FilePath = table.Column<string>(type: "text", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReportAnalysisTasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserNotifications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
SourceId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
Severity = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
ActionType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ActionTargetId = table.Column<string>(type: "text", nullable: true),
IsRead = table.Column<bool>(type: "boolean", nullable: false),
ReadAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserNotifications", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VerificationCodes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Phone = table.Column<string>(type: "text", nullable: false),
Code = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsUsed = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VerificationCodes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Phone = table.Column<string>(type: "text", nullable: false),
Role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "User"),
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
Name = table.Column<string>(type: "text", nullable: true),
Gender = table.Column<string>(type: "text", nullable: true),
BirthDate = table.Column<DateOnly>(type: "date", nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
table.ForeignKey(
name: "FK_Users_Doctors_DoctorId",
column: x => x.DoctorId,
principalTable: "Doctors",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "Consultations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
DoctorId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
Month = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Consultations", x => x.Id);
table.ForeignKey(
name: "FK_Consultations_Doctors_DoctorId",
column: x => x.DoctorId,
principalTable: "Doctors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Consultations_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Conversations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
AgentType = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: true),
Summary = table.Column<string>(type: "text", nullable: true),
MessageCount = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Conversations", x => x.Id);
table.ForeignKey(
name: "FK_Conversations_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DeviceTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Platform = table.Column<string>(type: "text", nullable: false),
PushToken = table.Column<string>(type: "text", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DeviceTokens", x => x.Id);
table.ForeignKey(
name: "FK_DeviceTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DietRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MealType = table.Column<string>(type: "text", nullable: false),
TotalCalories = table.Column<int>(type: "integer", nullable: true),
HealthScore = table.Column<int>(type: "integer", nullable: true),
RecordedAt = table.Column<DateOnly>(type: "date", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DietRecords", x => x.Id);
table.ForeignKey(
name: "FK_DietRecords_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DoctorProfiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
Name = table.Column<string>(type: "text", nullable: true),
Title = table.Column<string>(type: "text", nullable: true),
Department = table.Column<string>(type: "text", nullable: true),
Hospital = table.Column<string>(type: "text", nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: true),
IsOnline = table.Column<bool>(type: "boolean", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DoctorProfiles", x => x.Id);
table.ForeignKey(
name: "FK_DoctorProfiles_Doctors_DoctorId",
column: x => x.DoctorId,
principalTable: "Doctors",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_DoctorProfiles_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExercisePlans",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
ReminderTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExercisePlans", x => x.Id);
table.ForeignKey(
name: "FK_ExercisePlans_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FollowUps",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
DoctorName = table.Column<string>(type: "text", nullable: true),
Department = table.Column<string>(type: "text", nullable: true),
ScheduledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Notes = table.Column<string>(type: "text", nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FollowUps", x => x.Id);
table.ForeignKey(
name: "FK_FollowUps_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HealthArchives",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Diagnosis = table.Column<string>(type: "text", nullable: true),
SurgeryType = table.Column<string>(type: "text", nullable: true),
SurgeryDate = table.Column<DateOnly>(type: "date", nullable: true),
Allergies = table.Column<List<string>>(type: "text[]", nullable: false),
DietRestrictions = table.Column<List<string>>(type: "text[]", nullable: false),
ChronicDiseases = table.Column<List<string>>(type: "text[]", nullable: false),
FamilyHistory = table.Column<string>(type: "text", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HealthArchives", x => x.Id);
table.ForeignKey(
name: "FK_HealthArchives_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HealthRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MetricType = table.Column<string>(type: "text", nullable: false),
Systolic = table.Column<int>(type: "integer", nullable: true),
Diastolic = table.Column<int>(type: "integer", nullable: true),
Value = table.Column<decimal>(type: "numeric", nullable: true),
Unit = table.Column<string>(type: "text", nullable: true),
Source = table.Column<string>(type: "text", nullable: false),
IsAbnormal = table.Column<bool>(type: "boolean", nullable: false),
RecordedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HealthRecords", x => x.Id);
table.ForeignKey(
name: "FK_HealthRecords_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Medications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Dosage = table.Column<string>(type: "text", nullable: true),
Frequency = table.Column<string>(type: "text", nullable: false),
TimeOfDay = table.Column<List<TimeOnly>>(type: "time[]", nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: true),
EndDate = table.Column<DateOnly>(type: "date", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
Source = table.Column<string>(type: "text", nullable: false),
Notes = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Medications", x => x.Id);
table.ForeignKey(
name: "FK_Medications_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "NotificationPreferences",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MedicationReminder = table.Column<bool>(type: "boolean", nullable: false),
FollowUpReminder = table.Column<bool>(type: "boolean", nullable: false),
DoctorReply = table.Column<bool>(type: "boolean", nullable: false),
AbnormalAlert = table.Column<bool>(type: "boolean", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationPreferences", x => x.Id);
table.ForeignKey(
name: "FK_NotificationPreferences_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Reports",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
FileUrl = table.Column<string>(type: "text", nullable: false),
FileType = table.Column<string>(type: "text", nullable: false),
Category = table.Column<string>(type: "text", nullable: false),
AiSummary = table.Column<string>(type: "text", nullable: true),
AiIndicators = table.Column<string>(type: "jsonb", nullable: true),
Status = table.Column<string>(type: "text", nullable: false),
Severity = table.Column<string>(type: "text", nullable: true),
DoctorComment = table.Column<string>(type: "text", nullable: true),
DoctorRecommendation = table.Column<string>(type: "text", nullable: true),
DoctorName = table.Column<string>(type: "text", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Reports", x => x.Id);
table.ForeignKey(
name: "FK_Reports_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ConsultationMessages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ConsultationId = table.Column<Guid>(type: "uuid", nullable: false),
SenderType = table.Column<string>(type: "text", nullable: false),
SenderName = table.Column<string>(type: "text", nullable: true),
Content = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConsultationMessages", x => x.Id);
table.ForeignKey(
name: "FK_ConsultationMessages_Consultations_ConsultationId",
column: x => x.ConsultationId,
principalTable: "Consultations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ConversationMessages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "text", nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
Intent = table.Column<string>(type: "text", nullable: true),
MetadataJson = table.Column<string>(type: "jsonb", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConversationMessages", x => x.Id);
table.ForeignKey(
name: "FK_ConversationMessages_Conversations_ConversationId",
column: x => x.ConversationId,
principalTable: "Conversations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DietFoodItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DietRecordId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Portion = table.Column<string>(type: "text", nullable: true),
Calories = table.Column<int>(type: "integer", nullable: true),
ProteinGrams = table.Column<decimal>(type: "numeric", nullable: true),
CarbsGrams = table.Column<decimal>(type: "numeric", nullable: true),
FatGrams = table.Column<decimal>(type: "numeric", nullable: true),
Warning = table.Column<string>(type: "text", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DietFoodItems", x => x.Id);
table.ForeignKey(
name: "FK_DietFoodItems_DietRecords_DietRecordId",
column: x => x.DietRecordId,
principalTable: "DietRecords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExercisePlanItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
PlanId = table.Column<Guid>(type: "uuid", nullable: false),
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
ExerciseType = table.Column<string>(type: "text", nullable: false),
DurationMinutes = table.Column<int>(type: "integer", nullable: false),
IsCompleted = table.Column<bool>(type: "boolean", nullable: false),
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsRestDay = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExercisePlanItems", x => x.Id);
table.ForeignKey(
name: "FK_ExercisePlanItems_ExercisePlans_PlanId",
column: x => x.PlanId,
principalTable: "ExercisePlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HealthArchiveSurgeries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
HealthArchiveId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Date = table.Column<DateOnly>(type: "date", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HealthArchiveSurgeries", x => x.Id);
table.ForeignKey(
name: "FK_HealthArchiveSurgeries_HealthArchives_HealthArchiveId",
column: x => x.HealthArchiveId,
principalTable: "HealthArchives",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MedicationLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
ConfirmedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MedicationLogs", x => x.Id);
table.ForeignKey(
name: "FK_MedicationLogs_Medications_MedicationId",
column: x => x.MedicationId,
principalTable: "Medications",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AiWriteCommands_UserId_Status_ExpiresAt",
table: "AiWriteCommands",
columns: new[] { "UserId", "Status", "ExpiresAt" });
migrationBuilder.CreateIndex(
name: "IX_ConsultationMessages_ConsultationId_CreatedAt",
table: "ConsultationMessages",
columns: new[] { "ConsultationId", "CreatedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_Consultations_DoctorId",
table: "Consultations",
column: "DoctorId");
migrationBuilder.CreateIndex(
name: "IX_Consultations_UserId",
table: "Consultations",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_ConversationMessages_ConversationId_CreatedAt",
table: "ConversationMessages",
columns: new[] { "ConversationId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Conversations_UserId_UpdatedAt",
table: "Conversations",
columns: new[] { "UserId", "UpdatedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_DeviceTokens_UserId",
table: "DeviceTokens",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_DietFoodItems_DietRecordId",
table: "DietFoodItems",
column: "DietRecordId");
migrationBuilder.CreateIndex(
name: "IX_DietImageAnalysisTasks_Status_AvailableAt",
table: "DietImageAnalysisTasks",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_DietRecords_UserId_RecordedAt",
table: "DietRecords",
columns: new[] { "UserId", "RecordedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_DoctorProfiles_DoctorId",
table: "DoctorProfiles",
column: "DoctorId");
migrationBuilder.CreateIndex(
name: "IX_DoctorProfiles_UserId",
table: "DoctorProfiles",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExercisePlanItems_PlanId_ScheduledDate",
table: "ExercisePlanItems",
columns: new[] { "PlanId", "ScheduledDate" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExercisePlans_UserId_StartDate_EndDate",
table: "ExercisePlans",
columns: new[] { "UserId", "StartDate", "EndDate" });
migrationBuilder.CreateIndex(
name: "IX_FollowUps_UserId",
table: "FollowUps",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_HealthArchives_UserId",
table: "HealthArchives",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_HealthArchiveSurgeries_HealthArchiveId_SortOrder",
table: "HealthArchiveSurgeries",
columns: new[] { "HealthArchiveId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_HealthRecords_UserId_MetricType",
table: "HealthRecords",
columns: new[] { "UserId", "MetricType" });
migrationBuilder.CreateIndex(
name: "IX_HealthRecords_UserId_RecordedAt",
table: "HealthRecords",
columns: new[] { "UserId", "RecordedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_MedicationLogs_MedicationId_CreatedAt",
table: "MedicationLogs",
columns: new[] { "MedicationId", "CreatedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_MedicationReminderTasks_MedicationId_ScheduledDate_Schedule~",
table: "MedicationReminderTasks",
columns: new[] { "MedicationId", "ScheduledDate", "ScheduledTime" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MedicationReminderTasks_Status_AvailableAt",
table: "MedicationReminderTasks",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_Medications_UserId_IsActive",
table: "Medications",
columns: new[] { "UserId", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_NotificationOutbox_SourceTaskId",
table: "NotificationOutbox",
column: "SourceTaskId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NotificationOutbox_Status_AvailableAt",
table: "NotificationOutbox",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_NotificationPreferences_UserId",
table: "NotificationPreferences",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ReportAnalysisTasks_ReportId",
table: "ReportAnalysisTasks",
column: "ReportId");
migrationBuilder.CreateIndex(
name: "IX_ReportAnalysisTasks_Status_AvailableAt",
table: "ReportAnalysisTasks",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_Reports_UserId",
table: "Reports",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserNotifications_SourceId",
table: "UserNotifications",
column: "SourceId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserNotifications_UserId_IsDeleted_IsRead_CreatedAt",
table: "UserNotifications",
columns: new[] { "UserId", "IsDeleted", "IsRead", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Users_DoctorId",
table: "Users",
column: "DoctorId");
migrationBuilder.CreateIndex(
name: "IX_Users_Phone",
table: "Users",
column: "Phone",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VerificationCodes_Phone_CreatedAt",
table: "VerificationCodes",
columns: new[] { "Phone", "CreatedAt" },
descending: new[] { false, true });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AiWriteCommands");
migrationBuilder.DropTable(
name: "ConsultationMessages");
migrationBuilder.DropTable(
name: "ConversationMessages");
migrationBuilder.DropTable(
name: "DeviceTokens");
migrationBuilder.DropTable(
name: "DietFoodItems");
migrationBuilder.DropTable(
name: "DietImageAnalysisTasks");
migrationBuilder.DropTable(
name: "DoctorProfiles");
migrationBuilder.DropTable(
name: "ExercisePlanItems");
migrationBuilder.DropTable(
name: "FollowUps");
migrationBuilder.DropTable(
name: "HealthArchiveSurgeries");
migrationBuilder.DropTable(
name: "HealthRecords");
migrationBuilder.DropTable(
name: "MedicationLogs");
migrationBuilder.DropTable(
name: "MedicationReminderTasks");
migrationBuilder.DropTable(
name: "NotificationOutbox");
migrationBuilder.DropTable(
name: "NotificationPreferences");
migrationBuilder.DropTable(
name: "RefreshTokens");
migrationBuilder.DropTable(
name: "ReportAnalysisTasks");
migrationBuilder.DropTable(
name: "Reports");
migrationBuilder.DropTable(
name: "UserNotifications");
migrationBuilder.DropTable(
name: "VerificationCodes");
migrationBuilder.DropTable(
name: "Consultations");
migrationBuilder.DropTable(
name: "Conversations");
migrationBuilder.DropTable(
name: "DietRecords");
migrationBuilder.DropTable(
name: "ExercisePlans");
migrationBuilder.DropTable(
name: "HealthArchives");
migrationBuilder.DropTable(
name: "Medications");
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.DropTable(
name: "Doctors");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -28,11 +28,13 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
public DbSet<DoctorProfile> DoctorProfiles => Set<DoctorProfile>(); public DbSet<DoctorProfile> DoctorProfiles => Set<DoctorProfile>();
public DbSet<FollowUp> FollowUps => Set<FollowUp>(); public DbSet<FollowUp> FollowUps => Set<FollowUp>();
public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>(); public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>();
public DbSet<HealthArchiveSurgery> HealthArchiveSurgeries => Set<HealthArchiveSurgery>();
// 支撑表 // 支撑表
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>(); public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>(); public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>(); public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>(); public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>(); public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
@@ -154,12 +156,33 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
e.HasIndex(n => n.UserId).IsUnique(); e.HasIndex(n => n.UserId).IsUnique();
}); });
builder.Entity<UserNotification>(e =>
{
e.ToTable("UserNotifications");
e.HasIndex(n => n.SourceId).IsUnique();
e.HasIndex(n => new { n.UserId, n.IsDeleted, n.IsRead, n.CreatedAt });
e.Property(n => n.Type).HasMaxLength(64);
e.Property(n => n.Severity).HasMaxLength(16);
e.Property(n => n.ActionType).HasMaxLength(64);
});
// ---- HealthArchive ---- // ---- HealthArchive ----
builder.Entity<HealthArchive>(e => builder.Entity<HealthArchive>(e =>
{ {
e.HasIndex(a => a.UserId).IsUnique(); e.HasIndex(a => a.UserId).IsUnique();
}); });
builder.Entity<HealthArchiveSurgery>(e =>
{
e.ToTable("HealthArchiveSurgeries");
e.HasIndex(s => new { s.HealthArchiveId, s.SortOrder });
e.Property(s => s.Type).HasMaxLength(256);
e.HasOne(s => s.HealthArchive)
.WithMany(a => a.Surgeries)
.HasForeignKey(s => s.HealthArchiveId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<AiWriteCommandRecord>(e => builder.Entity<AiWriteCommandRecord>(e =>
{ {
e.ToTable("AiWriteCommands"); e.ToTable("AiWriteCommands");

View File

@@ -1,13 +0,0 @@
namespace Health.Infrastructure.Data;
/// <summary>
/// 数据库种子数据
/// </summary>
public static class DataSeeder
{
public static async Task SeedAsync(AppDbContext db)
{
// 医生不再使用种子数据,通过注册创建
await Task.CompletedTask;
}
}

View File

@@ -1,177 +0,0 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
using Microsoft.Extensions.Configuration;
namespace Health.Infrastructure.Data;
/// <summary>
/// 开发环境测试数据填充。生产环境不要调用!
/// 开关DEVDATA_ENABLED=true 才会执行
/// </summary>
public static class DevDataSeeder
{
public static async Task SeedIfEnabled(AppDbContext db, IConfiguration config)
{
// 通过环境变量控制DEVDATA_ENABLED=true 才填充测试数据
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
if (enabled != "true") return;
// 已有任何真实用户时跳过(避免污染真实数据)
if (db.Users.Any(u => u.Phone != "13800000001")) return;
// 检查是否已有测试用户(避免重复填充)
if (db.Users.Any(u => u.Phone == "13800000001")) return;
// ---- 种子医生数据 ----
if (!db.Doctors.Any())
{
var doctor1 = new Doctor
{
Id = Guid.NewGuid(), Name = "张明", Title = "主任医师",
Department = "心脏康复科", Phone = "13800000002",
ProfessionalDirection = "冠心病康复、术后管理",
IsActive = true, CreatedAt = DateTime.UtcNow,
};
var doctor2 = new Doctor
{
Id = Guid.NewGuid(), Name = "李芳", Title = "副主任医师",
Department = "营养科", Phone = "13800000003",
ProfessionalDirection = "糖尿病管理、饮食指导",
IsActive = true, CreatedAt = DateTime.UtcNow,
};
var doctor3 = new Doctor
{
Id = Guid.NewGuid(), Name = "王建国", Title = "主任医师",
Department = "心血管内科", Phone = "13800000004",
ProfessionalDirection = "高血压管理、PCI术后随访",
IsActive = true, CreatedAt = DateTime.UtcNow,
};
db.Doctors.AddRange(doctor1, doctor2, doctor3);
await db.SaveChangesAsync();
}
// ---- 创建测试患者 ----
var doctorWang = db.Doctors.FirstOrDefault(d => d.Name == "王建国");
var user = new User
{
Id = Guid.NewGuid(), Phone = "13800000001", Name = "张三",
Gender = "男", BirthDate = new DateOnly(1970, 3, 15),
DoctorId = doctorWang?.Id,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
db.Users.Add(user);
await db.SaveChangesAsync();
// ---- 健康档案 ----
db.HealthArchives.Add(new HealthArchive
{
Id = Guid.NewGuid(), UserId = user.Id,
Diagnosis = "冠心病", SurgeryType = "PCI支架植入术",
SurgeryDate = new DateOnly(2026, 3, 15),
Allergies = ["青霉素"], DietRestrictions = ["低盐", "低脂"],
ChronicDiseases = ["高血压", "高血脂"],
FamilyHistory = "父亲冠心病",
UpdatedAt = DateTime.UtcNow,
});
// ---- 健康数据(过去 7 天)----
var random = new Random(42);
for (int i = 7; i >= 0; i--)
{
var date = DateTime.UtcNow.AddDays(-i);
// 血压
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
Systolic = 120 + random.Next(-5, 15), Diastolic = 75 + random.Next(-5, 10),
Unit = "mmHg", Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
// 心率
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.HeartRate,
Value = 68 + random.Next(-5, 10), Unit = "次/分",
Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
// 血糖
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.Glucose,
Value = 5.0m + (decimal)(random.NextDouble() * 1.5),
Unit = "mmol/L", Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
// 血氧
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.SpO2,
Value = 96 + random.Next(0, 3), Unit = "%",
Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
}
// 一条异常血压
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
Systolic = 148, Diastolic = 92, Unit = "mmHg",
Source = HealthRecordSource.AiEntry, IsAbnormal = true,
RecordedAt = DateTime.UtcNow.AddDays(-1),
});
// ---- 用药计划 ----
db.Medications.Add(new Medication
{
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿司匹林", Dosage = "100mg",
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)],
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
});
db.Medications.Add(new Medication
{
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿托伐他汀", Dosage = "20mg",
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(20, 0)],
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
});
// ---- 运动计划 ----
var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(6), ReminderTime = new TimeOnly(19, 0) };
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), ExerciseType = "散步", DurationMinutes = 30 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(3), IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(4), ExerciseType = "太极", DurationMinutes = 40 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(5), IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(6), ExerciseType = "散步", DurationMinutes = 30 });
db.ExercisePlans.Add(plan);
// ---- 饮食记录 ----
var lunch = new DietRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch,
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
};
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 });
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 });
db.DietRecords.Add(lunch);
// ---- 复查计划 ----
db.FollowUps.Add(new FollowUp
{
Id = Guid.NewGuid(), UserId = user.Id, Title = "心内科复查",
DoctorName = "王建国", Department = "心血管内科",
ScheduledAt = DateTime.UtcNow.AddDays(3), Status = FollowUpStatus.Upcoming,
});
db.FollowUps.Add(new FollowUp
{
Id = Guid.NewGuid(), UserId = user.Id, Title = "术后3周复查",
DoctorName = "王建国", Department = "心血管内科",
ScheduledAt = DateTime.UtcNow.AddDays(-14), Status = FollowUpStatus.Completed,
});
await db.SaveChangesAsync();
Console.WriteLine($"[DEV] 测试数据已填充:用户 {user.Phone}");
}
}

View File

@@ -25,15 +25,28 @@ public sealed class DietImageAnalysisQueue(AppDbContext db) : IDietImageAnalysis
return task.Id; return task.Id;
} }
public Task RecoverStaleAsync(CancellationToken ct) public async Task RecoverStaleAsync(CancellationToken ct)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
return _db.DietImageAnalysisTasks var staleCutoff = now.AddMinutes(-5);
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
// 超时但仍有重试余量 → 重新排队
await _db.DietImageAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters .ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending") .SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now) .SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct); .SetProperty(x => x.UpdatedAt, now), ct);
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing也让 WaitForResultAsync 能尽快返回失败)
await _db.DietImageAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.ResultCode, 50001)
.SetProperty(x => x.ResultMessage, "食物识别超时且已达最大重试次数")
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
} }
public async Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct) public async Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct)

View File

@@ -1,12 +1,13 @@
using System.Text.Json; using System.Text.Json;
using Health.Application.Exercises; using Health.Application.Exercises;
using Health.Infrastructure.Data.Records; using Health.Application.Notifications;
namespace Health.Infrastructure.Exercises; namespace Health.Infrastructure.Exercises;
public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminderProducer public sealed class ExerciseReminderProducer(AppDbContext db, IUserNotificationProducer notifications) : IExerciseReminderProducer
{ {
private readonly AppDbContext _db = db; private readonly AppDbContext _db = db;
private readonly IUserNotificationProducer _notifications = notifications;
public async Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct) public async Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct)
{ {
@@ -17,28 +18,22 @@ public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminde
.Include(x => x.Plan) .Include(x => x.Plan)
.Where(x => x.ScheduledDate == today && !x.IsCompleted && !x.IsRestDay && x.Plan.ReminderTime <= currentTime) .Where(x => x.ScheduledDate == today && !x.IsCompleted && !x.IsRestDay && x.Plan.ReminderTime <= currentTime)
.ToListAsync(ct); .ToListAsync(ct);
var sourceIds = dueItems.Select(x => x.Id).ToList(); var created = 0;
var existing = await _db.NotificationOutbox.AsNoTracking() foreach (var item in dueItems)
.Where(x => sourceIds.Contains(x.SourceTaskId))
.Select(x => x.SourceTaskId)
.ToListAsync(ct);
var existingSet = existing.ToHashSet();
var now = DateTime.UtcNow;
foreach (var item in dueItems.Where(x => !existingSet.Contains(x.Id)))
{ {
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord if (await _notifications.EnqueueAsync(
{ item.Plan.UserId,
Id = Guid.NewGuid(), UserId = item.Plan.UserId, SourceTaskId = item.Id, item.Id,
Type = "ExerciseReminder", new NotificationMessage(
Payload = JsonSerializer.Serialize(new "ExerciseReminder",
{ "今日运动计划",
item.ExerciseType, item.DurationMinutes, item.ScheduledDate, $"今天安排了 {item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
ReminderTime = item.Plan.ReminderTime, "info",
}), "exercise",
Status = "AwaitingProvider", AvailableAt = now, CreatedAt = now, UpdatedAt = now, item.PlanId.ToString()),
}, ct); ct))
created++;
} }
if (_db.ChangeTracker.HasChanges()) await _db.SaveChangesAsync(ct); return created;
return dueItems.Count - existingSet.Count;
} }
} }

View File

@@ -7,7 +7,9 @@ public sealed class EfHealthArchiveRepository(AppDbContext db) : IHealthArchiveR
private readonly AppDbContext _db = db; private readonly AppDbContext _db = db;
public Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct) => public Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct) =>
_db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct); _db.HealthArchives
.Include(a => a.Surgeries)
.FirstOrDefaultAsync(a => a.UserId == userId, ct);
public async Task AddAsync(HealthArchive archive, CancellationToken ct) => public async Task AddAsync(HealthArchive archive, CancellationToken ct) =>
await _db.HealthArchives.AddAsync(archive, ct); await _db.HealthArchives.AddAsync(archive, ct);

View File

@@ -56,12 +56,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit
.Where(m => m.IsActive && m.TimeOfDay.Count > 0) .Where(m => m.IsActive && m.TimeOfDay.Count > 0)
.ToListAsync(ct); .ToListAsync(ct);
public Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.AnyAsync(l =>
l.UserId == userId && l.MedicationId == medicationId
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
&& l.Status == MedicationLogStatus.Taken, ct);
public async Task AddMedicationAsync(Medication medication, CancellationToken ct) => public async Task AddMedicationAsync(Medication medication, CancellationToken ct) =>
await _db.Medications.AddAsync(medication, ct); await _db.Medications.AddAsync(medication, ct);

View File

@@ -45,15 +45,26 @@ public sealed class MedicationReminderQueue(AppDbContext db) : IMedicationRemind
} }
} }
public Task RecoverStaleAsync(CancellationToken ct) public async Task RecoverStaleAsync(CancellationToken ct)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
return _db.MedicationReminderTasks var staleCutoff = now.AddMinutes(-5);
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
// 超时但仍有重试余量 → 重新排队
await _db.MedicationReminderTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters .ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending") .SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now) .SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct); .SetProperty(x => x.UpdatedAt, now), ct);
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing
await _db.MedicationReminderTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
} }
public async Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct) public async Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct)

View File

@@ -1,43 +1,35 @@
using System.Text.Json; using System.Text.Json;
using Health.Application.Medications; using Health.Application.Medications;
using Health.Infrastructure.Data.Records; using Health.Application.Notifications;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Medications; namespace Health.Infrastructure.Medications;
public sealed class OutboxMedicationReminderDispatcher( public sealed class OutboxMedicationReminderDispatcher(
AppDbContext db, IUserNotificationProducer notifications,
ILogger<OutboxMedicationReminderDispatcher> logger) : IMedicationReminderDispatcher ILogger<OutboxMedicationReminderDispatcher> logger) : IMedicationReminderDispatcher
{ {
private readonly AppDbContext _db = db; private readonly IUserNotificationProducer _notifications = notifications;
private readonly ILogger<OutboxMedicationReminderDispatcher> _logger = logger; private readonly ILogger<OutboxMedicationReminderDispatcher> _logger = logger;
public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct) public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct)
{ {
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == task.TaskId, ct)) var details = string.Join(" · ", new[]
return;
var now = DateTime.UtcNow;
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
{ {
Id = Guid.NewGuid(), task.Dosage,
UserId = task.UserId, task.ScheduledTime.ToString("HH:mm")
SourceTaskId = task.TaskId, }.Where(x => !string.IsNullOrWhiteSpace(x)));
Type = "MedicationReminder", await _notifications.EnqueueAsync(
Payload = JsonSerializer.Serialize(new task.UserId,
{ task.TaskId,
task.MedicationId, new NotificationMessage(
task.Name, "MedicationReminder",
task.Dosage, "用药时间到了",
task.ScheduledDate, $"请按计划服用{task.Name}{(details.Length > 0 ? $"{details}" : "")},服用后记得打卡。",
task.ScheduledTime, "info",
}), "medication",
Status = "AwaitingProvider", task.MedicationId.ToString()),
AvailableAt = now, ct);
CreatedAt = now,
UpdatedAt = now,
}, ct);
await _db.SaveChangesAsync(ct);
_logger.LogInformation( _logger.LogInformation(
"用药提醒已写入通知 Outbox等待推送服务: {TaskId} {UserId}", "用药提醒已写入通知 Outbox等待推送服务: {TaskId} {UserId}",

View File

@@ -7,20 +7,46 @@ public sealed class EfInAppNotificationRepository(AppDbContext db) : IInAppNotif
private readonly AppDbContext _db = db; private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct) => public async Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct) =>
await _db.NotificationOutbox.AsNoTracking() await _db.UserNotifications.AsNoTracking()
.Where(x => x.UserId == userId && x.Status == "AwaitingProvider") .Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted)
.OrderBy(x => x.CreatedAt) .OrderBy(x => x.CreatedAt)
.Take(10) .Take(10)
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Payload, x.CreatedAt)) .Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Title, x.Message,
x.Severity, x.ActionType, x.ActionTargetId, x.IsRead, x.CreatedAt))
.ToListAsync(ct);
public async Task<IReadOnlyList<InAppNotificationRecord>> GetHistoryAsync(Guid userId, CancellationToken ct) =>
await _db.UserNotifications.AsNoTracking()
.Where(x => x.UserId == userId && !x.IsDeleted)
.OrderByDescending(x => x.CreatedAt)
.Take(100)
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Title, x.Message,
x.Severity, x.ActionType, x.ActionTargetId, x.IsRead, x.CreatedAt))
.ToListAsync(ct); .ToListAsync(ct);
public async Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) public async Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct)
{ {
var updated = await _db.NotificationOutbox var updated = await _db.UserNotifications
.Where(x => x.Id == notificationId && x.UserId == userId && x.Status == "AwaitingProvider") .Where(x => x.Id == notificationId && x.UserId == userId && !x.IsRead && !x.IsDeleted)
.ExecuteUpdateAsync(setters => setters .ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "InAppDelivered") .SetProperty(x => x.IsRead, true)
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); .SetProperty(x => x.ReadAt, DateTime.UtcNow), ct);
return updated > 0; return updated > 0;
} }
public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
_db.UserNotifications
.Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.IsRead, true)
.SetProperty(x => x.ReadAt, DateTime.UtcNow), ct);
public async Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct)
{
var deleted = await _db.UserNotifications
.Where(x => x.Id == notificationId && x.UserId == userId && !x.IsDeleted)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.IsDeleted, true), ct);
return deleted > 0;
}
} }

View File

@@ -0,0 +1,139 @@
using System.Text.Json;
using Health.Application.Notifications;
using Health.Infrastructure.Data.Records;
using Npgsql;
namespace Health.Infrastructure.Notifications;
public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificationProducer
{
private readonly AppDbContext _db = db;
public async Task<bool> EnqueueAsync(
Guid userId,
Guid sourceId,
NotificationMessage message,
CancellationToken ct)
{
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
return false;
var now = DateTime.UtcNow;
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
{
Id = Guid.NewGuid(),
UserId = userId,
SourceTaskId = sourceId,
Type = message.Type,
Payload = JsonSerializer.Serialize(message),
Status = "Pending",
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
try
{
await _db.SaveChangesAsync(ct);
return true;
}
catch (DbUpdateException ex) when (
ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
_db.ChangeTracker.Clear();
return false;
}
}
}
public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificationOutboxProcessor
{
private const int MaxAttempts = 5;
private readonly AppDbContext _db = db;
public async Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
await _db.NotificationOutbox
.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);
await _db.NotificationOutbox
.Where(x => x.Status == "Processing" &&
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<bool> ProcessNextAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.NotificationOutbox.AsNoTracking()
.Where(x => x.Status == "Pending" &&
x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return false;
var claimed = await _db.NotificationOutbox
.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);
if (claimed == 0) return true;
try
{
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
?? throw new InvalidOperationException("通知消息内容为空");
if (!await _db.UserNotifications.AsNoTracking()
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
{
await _db.UserNotifications.AddAsync(new UserNotification
{
Id = Guid.NewGuid(),
UserId = candidate.UserId,
SourceId = candidate.SourceTaskId,
Type = message.Type,
Title = message.Title,
Message = message.Message,
Severity = message.Severity,
ActionType = message.ActionType,
ActionTargetId = message.ActionTargetId,
CreatedAt = candidate.CreatedAt,
}, ct);
// Persist the inbox item first. If completion fails, stale-task
// recovery can safely retry because SourceId is idempotent.
await _db.SaveChangesAsync(ct);
}
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.LastError, (string?)null)
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
catch (Exception ex)
{
var attempts = candidate.Attempts + 1;
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
.SetProperty(x => x.AvailableAt,
DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5))
.SetProperty(x => x.LastError,
ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message)
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
return true;
}
}

View File

@@ -1,12 +1,16 @@
using Health.Application.Reports; using Health.Application.Reports;
using Health.Application.Notifications;
using Health.Infrastructure.Data.Records; using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Reports; namespace Health.Infrastructure.Reports;
public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueue public sealed class EfReportAnalysisQueue(
AppDbContext db,
IUserNotificationProducer? notifications = null) : IReportAnalysisQueue
{ {
private const int MaxAttempts = 3; private const int MaxAttempts = 3;
private readonly AppDbContext _db = db; private readonly AppDbContext _db = db;
private readonly IUserNotificationProducer? _notifications = notifications;
public async Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default) public async Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default)
{ {
@@ -25,15 +29,26 @@ public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueu
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
public Task RecoverStaleAsync(CancellationToken ct) public async Task RecoverStaleAsync(CancellationToken ct)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
return _db.ReportAnalysisTasks var staleCutoff = now.AddMinutes(-5);
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
// 超时但仍有重试余量 → 重新排队
await _db.ReportAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters .ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending") .SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now) .SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct); .SetProperty(x => x.UpdatedAt, now), ct);
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing
await _db.ReportAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
} }
public async Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct) public async Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct)
@@ -82,5 +97,27 @@ public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueu
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5); task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
} }
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
if (task.Status == "Failed" && _notifications != null)
{
var report = await _db.Reports.AsNoTracking()
.Where(x => x.Id == task.ReportId)
.Select(x => new { x.Id, x.UserId })
.FirstOrDefaultAsync(ct);
if (report != null)
{
await _notifications.EnqueueAsync(
report.UserId,
task.Id,
new NotificationMessage(
"ReportAnalysisFailed",
"报告分析未完成",
"这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
"warning",
"report",
report.Id.ToString()),
ct);
}
}
} }
} }

View File

@@ -1,4 +1,5 @@
using Health.Application.Reports; using Health.Application.Reports;
using Health.Application.Notifications;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -8,11 +9,13 @@ public sealed class ReportAnalysisService(
IReportRepository reports, IReportRepository reports,
VisionClient vision, VisionClient vision,
DeepSeekClient llm, DeepSeekClient llm,
IUserNotificationProducer notifications,
ILogger<ReportAnalysisService> logger) : IReportAnalysisService ILogger<ReportAnalysisService> logger) : IReportAnalysisService
{ {
private readonly IReportRepository _reports = reports; private readonly IReportRepository _reports = reports;
private readonly VisionClient _vision = vision; private readonly VisionClient _vision = vision;
private readonly DeepSeekClient _llm = llm; private readonly DeepSeekClient _llm = llm;
private readonly IUserNotificationProducer _notifications = notifications;
private readonly ILogger<ReportAnalysisService> _logger = logger; private readonly ILogger<ReportAnalysisService> _logger = logger;
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct) public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
@@ -32,6 +35,7 @@ public sealed class ReportAnalysisService(
report.AiIndicators = "[]"; report.AiIndicators = "[]";
report.Status = ReportStatus.AnalysisFailed; report.Status = ReportStatus.AnalysisFailed;
await _reports.SaveChangesAsync(ct); await _reports.SaveChangesAsync(ct);
await NotifyAsync(report, job.TaskId, false, ct);
return; return;
} }
@@ -44,8 +48,14 @@ public sealed class ReportAnalysisService(
report.Status = ReportStatus.PendingDoctor; report.Status = ReportStatus.PendingDoctor;
await _reports.SaveChangesAsync(ct); await _reports.SaveChangesAsync(ct);
await NotifyAsync(report, job.TaskId, true, ct);
_logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId); _logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId);
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// 进程停机导致的取消:不算失败,不消耗重试,交还给恢复机制
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId); _logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId);
@@ -53,6 +63,10 @@ public sealed class ReportAnalysisService(
report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。"; report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。";
report.AiIndicators = "[]"; report.AiIndicators = "[]";
await _reports.SaveChangesAsync(ct); await _reports.SaveChangesAsync(ct);
// 重新抛出,使 Worker 进入 RetryAsync指数退避重试超限后任务置 Failed
// 若为可恢复的瞬时故障,重试成功会把状态改回 PendingDoctor。
throw;
} }
} }
@@ -91,7 +105,8 @@ public sealed class ReportAnalysisService(
ct: ct); ct: ct);
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? ""; var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
_logger.LogInformation("报告 VLM 返回: {Content}", content[..Math.Min(content.Length, 200)]); // 不记录 VLM 返回正文:可能包含检验指标等敏感医疗信息,生产日志只记录长度
_logger.LogInformation("报告 VLM 返回长度: {Length}", content.Length);
var json = TryParseJson(content); var json = TryParseJson(content);
return json ?? throw new InvalidOperationException("报告识别结果格式异常"); return json ?? throw new InvalidOperationException("报告识别结果格式异常");
@@ -162,4 +177,17 @@ public sealed class ReportAnalysisService(
try { return JsonDocument.Parse(content).RootElement; } try { return JsonDocument.Parse(content).RootElement; }
catch (JsonException) { return null; } catch (JsonException) { return null; }
} }
private Task NotifyAsync(Report report, Guid sourceId, bool succeeded, CancellationToken ct) =>
_notifications.EnqueueAsync(
report.UserId,
sourceId,
new NotificationMessage(
succeeded ? "ReportAnalysisCompleted" : "ReportAnalysisFailed",
succeeded ? "报告分析完成" : "报告分析未完成",
succeeded ? "您的报告已完成 AI 预解读,点击查看分析结果。" : "这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
succeeded ? "success" : "warning",
"report",
report.Id.ToString()),
ct);
} }

View File

@@ -40,6 +40,8 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
await _db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct); await _db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct);
await _db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct); await _db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct); await _db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
await _db.UserNotifications.Where(n => n.UserId == userId).ExecuteDeleteAsync(ct);
await _db.NotificationOutbox.Where(n => n.UserId == userId).ExecuteDeleteAsync(ct);
await _db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct); await _db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct); await _db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct);
await _db.AiWriteCommands.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct); await _db.AiWriteCommands.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);

View File

@@ -0,0 +1,37 @@
using Health.Application.Notifications;
namespace Health.WebApi.BackgroundServices;
public sealed class NotificationOutboxWorker(
IServiceScopeFactory scopeFactory,
ILogger<NotificationOutboxWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var nextRecovery = DateTime.MinValue;
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<INotificationOutboxProcessor>();
if (DateTime.UtcNow >= nextRecovery)
{
await processor.RecoverStaleAsync(stoppingToken);
nextRecovery = DateTime.UtcNow.AddMinutes(1);
}
if (!await processor.ProcessNextAsync(stoppingToken))
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "通知 Outbox 消费失败");
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
}

View File

@@ -0,0 +1,52 @@
using Health.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Health.WebApi.Data;
/// <summary>
/// 设计时 DbContext 工厂——仅供 dotnet-efmigrations / database update使用。
/// 不会启动 Web 服务或后台 Worker只负责构建 AppDbContext 的连接配置。
/// 连接串优先级DB_CONNECTION 环境变量 → backend/.env 文件 → 本地默认。
/// </summary>
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
private const string FallbackConnection =
"Host=localhost;Database=health_manager;Username=postgres;Password=postgres123";
public AppDbContext CreateDbContext(string[] args)
{
var connection = Environment.GetEnvironmentVariable("DB_CONNECTION")
?? ReadConnectionFromEnvFile()
?? FallbackConnection;
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(connection)
.Options;
return new AppDbContext(options);
}
// 从 backend 目录的 .env 读取 DB_CONNECTION设计时不走 Program.cs 的加载逻辑)
private static string? ReadConnectionFromEnvFile()
{
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
for (var i = 0; i < 6 && dir != null; i++, dir = dir.Parent)
{
var envPath = Path.Combine(dir.FullName, ".env");
if (!File.Exists(envPath)) continue;
foreach (var line in File.ReadAllLines(envPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0 || trimmed.StartsWith('#')) continue;
var eq = trimmed.IndexOf('=');
if (eq <= 0) continue;
if (trimmed[..eq].Trim() != "DB_CONNECTION") continue;
var value = trimmed[(eq + 1)..].Trim();
return value.Length > 0 ? value : null;
}
}
return null;
}
}

View File

@@ -132,6 +132,7 @@ public static class DoctorEndpoints
var user = await db.Users var user = await db.Users
.Include(u => u.HealthArchive) .Include(u => u.HealthArchive)
.ThenInclude(a => a!.Surgeries)
.FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId); .FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId);
if (user == null) if (user == null)
return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" }); return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
@@ -179,7 +180,18 @@ public static class DoctorEndpoints
data = new data = new
{ {
profile = new { user.Id, user.Phone, user.Name, user.Gender, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), user.AvatarUrl }, profile = new { user.Id, user.Phone, user.Name, user.Gender, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), user.AvatarUrl },
archive = user.HealthArchive == null ? null : new { user.HealthArchive.Diagnosis, user.HealthArchive.SurgeryType, SurgeryDate = user.HealthArchive.SurgeryDate?.ToString("yyyy-MM-dd"), user.HealthArchive.Allergies, user.HealthArchive.DietRestrictions, user.HealthArchive.ChronicDiseases, user.HealthArchive.FamilyHistory }, archive = user.HealthArchive == null ? null : new
{
user.HealthArchive.Diagnosis,
user.HealthArchive.SurgeryType,
SurgeryDate = user.HealthArchive.SurgeryDate?.ToString("yyyy-MM-dd"),
surgeries = user.HealthArchive.Surgeries.OrderBy(s => s.SortOrder)
.Select(s => new { s.Type, Date = s.Date.HasValue ? s.Date.Value.ToString("yyyy-MM-dd") : null }),
user.HealthArchive.Allergies,
user.HealthArchive.DietRestrictions,
user.HealthArchive.ChronicDiseases,
user.HealthArchive.FamilyHistory
},
latestRecords, latestRecords,
trendRecords, trendRecords,
medications, medications,

View File

@@ -17,6 +17,19 @@ public static class NotificationEndpoints
return Results.Ok(new { code = 0, data = result, message = (string?)null }); return Results.Ok(new { code = 0, data = result, message = (string?)null });
}); });
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var result = await notifications.GetHistoryAsync(userId, ct);
return Results.Ok(new
{
code = 0,
data = new { unreadCount = result.Count(x => !x.IsRead), items = result },
message = (string?)null
});
});
group.MapPost("/{id:guid}/acknowledge", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) => group.MapPost("/{id:guid}/acknowledge", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
@@ -24,6 +37,25 @@ public static class NotificationEndpoints
var acknowledged = await notifications.AcknowledgeAsync(userId, id, ct); var acknowledged = await notifications.AcknowledgeAsync(userId, id, ct);
return Results.Ok(new { code = 0, data = new { acknowledged }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { acknowledged }, message = (string?)null });
}); });
group.MapPost("/read-all", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var count = await notifications.MarkAllReadAsync(userId, ct);
return Results.Ok(new { code = 0, data = new { count }, message = (string?)null });
});
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var deleted = await notifications.DeleteAsync(userId, id, ct);
return deleted
? Results.Ok(new { code = 0, data = new { deleted = true }, message = (string?)null })
: Results.NotFound(new { code = 404, data = (object?)null, message = "通知不存在" });
});
} }
private static Guid GetUserId(HttpContext http) => private static Guid GetUserId(HttpContext http) =>

View File

@@ -36,6 +36,9 @@ public static class UserEndpoints
group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) => group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
{ {
var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null; var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null;
var surgeries = req.Surgeries?.Select(s => new HealthArchiveSurgeryInput(
s.Type,
DateOnly.TryParse(s.Date, out var date) ? date : null)).ToList();
await archives.UpdateAsync( await archives.UpdateAsync(
GetUserId(http), GetUserId(http),
new HealthArchiveUpdateRequest( new HealthArchiveUpdateRequest(
@@ -45,7 +48,8 @@ public static class UserEndpoints
req.Allergies, req.Allergies,
req.DietRestrictions, req.DietRestrictions,
req.ChronicDiseases, req.ChronicDiseases,
req.FamilyHistory), req.FamilyHistory,
surgeries),
ct); ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
@@ -65,4 +69,7 @@ public sealed record UpdateProfileRequest(string? Name, string? Gender, string?
public sealed record UpdateArchiveRequest( public sealed record UpdateArchiveRequest(
string? Diagnosis, string? SurgeryType, string? SurgeryDate, string? Diagnosis, string? SurgeryType, string? SurgeryDate,
List<string>? Allergies, List<string>? DietRestrictions, List<string>? Allergies, List<string>? DietRestrictions,
List<string>? ChronicDiseases, string? FamilyHistory); List<string>? ChronicDiseases, string? FamilyHistory,
List<UpdateArchiveSurgeryRequest>? Surgeries);
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);

View File

@@ -10,6 +10,10 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Drawing.Common" Version="10.0.8" /> <PackageReference Include="System.Drawing.Common" Version="10.0.8" />
</ItemGroup> </ItemGroup>

View File

@@ -16,6 +16,14 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
{ {
await _next(context); await _next(context);
} }
catch (Health.Domain.ValidationException vex)
{
// 业务输入校验失败:返回 400message 为我们自己产生的提示,可安全展示
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
context.Response.ContentType = "application/json";
var result = new { code = 40001, data = (object?)null, message = vex.Message };
await context.Response.WriteAsync(JsonSerializer.Serialize(result));
}
catch (Exception ex) catch (Exception ex)
{ {
// 生产环境不暴露内部异常详情 // 生产环境不暴露内部异常详情

View File

@@ -79,7 +79,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
// ---- 数据库 ---- // ---- 数据库 ----
builder.Services.AddDbContext<AppDbContext>(options => builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default") ?? builder.Configuration["DB_CONNECTION"] ?? "Host=localhost;Database=health_manager;Username=postgres;Password=postgres")); options.UseNpgsql(
builder.Configuration.GetConnectionString("Default") ?? builder.Configuration["DB_CONNECTION"] ?? "Host=localhost;Database=health_manager;Username=postgres;Password=postgres",
npgsql => npgsql.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
// ---- JWT 认证 ---- // ---- JWT 认证 ----
var jwtSecret = builder.Configuration["JWT_SECRET"]; var jwtSecret = builder.Configuration["JWT_SECRET"];
@@ -137,6 +139,8 @@ builder.Services.AddScoped<IMaintenanceService, MaintenanceService>();
builder.Services.AddScoped<IMaintenanceRepository, EfMaintenanceRepository>(); builder.Services.AddScoped<IMaintenanceRepository, EfMaintenanceRepository>();
builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>(); builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>();
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>(); builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
builder.Services.AddScoped<IReportService, ReportService>(); builder.Services.AddScoped<IReportService, ReportService>();
builder.Services.AddScoped<IReportRepository, EfReportRepository>(); builder.Services.AddScoped<IReportRepository, EfReportRepository>();
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>(); builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
@@ -144,7 +148,6 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>(); builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserRepository, EfUserRepository>(); builder.Services.AddScoped<IUserRepository, EfUserRepository>();
builder.Services.AddScoped<DatabaseSchemaMigrator>();
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM---- // ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM----
builder.Services.AddHttpClient<DeepSeekClient>(client => builder.Services.AddHttpClient<DeepSeekClient>(client =>
@@ -167,6 +170,7 @@ builder.Services.AddHostedService<MedicationReminderWorker>();
builder.Services.AddHostedService<CleanupService>(); builder.Services.AddHostedService<CleanupService>();
builder.Services.AddHostedService<ReportAnalysisWorker>(); builder.Services.AddHostedService<ReportAnalysisWorker>();
builder.Services.AddHostedService<DietImageAnalysisWorker>(); builder.Services.AddHostedService<DietImageAnalysisWorker>();
builder.Services.AddHostedService<NotificationOutboxWorker>();
// ---- OpenAPI ---- // ---- OpenAPI ----
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
@@ -202,14 +206,12 @@ app.UseStaticFiles(new StaticFileOptions
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
app.MapOpenApi(); app.MapOpenApi();
// ---- 初始化数据库:创建空库,并用版本化补丁更新已有本地结构 ---- // ---- 数据库迁移EF Core Migrations可用 AUTO_MIGRATE=false 禁用自动迁移)----
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync(); if (app.Configuration.GetValue<bool>("AUTO_MIGRATE", true))
await scope.ServiceProvider.GetRequiredService<DatabaseSchemaMigrator>().ApplyAsync(); await db.Database.MigrateAsync();
await DataSeeder.SeedAsync(db);
await DevDataSeeder.SeedIfEnabled(db, app.Configuration);
} }
// ---- 注册 API 端点 ---- // ---- 注册 API 端点 ----

View File

@@ -1,287 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Infrastructure.AI;
using Health.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Health.Tests;
/// <summary>
/// AI 智能体集成测试 — 模拟真实用户对话,验证 Tool Calling 与数据库写入
/// 运行前需确保后端已启动: dotnet run --project src/Health.WebApi
/// </summary>
public class AiAgentTests
{
private static readonly HttpClient Http = new()
{
BaseAddress = new Uri("http://localhost:5000"),
Timeout = TimeSpan.FromSeconds(120)
};
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
// ==================== PromptManager 单元测试 ====================
[Fact]
public void PromptManager_Default_Should_Contain_HeartKeywords()
{
var pm = new PromptManager();
var prompt = pm.GetSystemPrompt(AgentType.Default);
Assert.Contains("心脏", prompt);
Assert.Contains("健康", prompt);
}
[Fact]
public void PromptManager_Consultation_Should_Contain_TriageRules()
{
var pm = new PromptManager();
var prompt = pm.GetSystemPrompt(AgentType.Consultation);
Assert.Contains("剧烈胸痛", prompt);
Assert.Contains("呼吸困难", prompt);
Assert.Contains("160/100", prompt);
}
[Fact]
public void PromptManager_Health_Should_Contain_NormalRanges()
{
var pm = new PromptManager();
var prompt = pm.GetSystemPrompt(AgentType.Health);
Assert.Contains("139", prompt); // 收缩压上界
Assert.Contains("89", prompt); // 舒张压下界
Assert.Contains("100", prompt); // 心率上界
}
[Fact]
public void PromptManager_Diet_Should_Contain_VlmKeywords()
{
var pm = new PromptManager();
var prompt = pm.GetSystemPrompt(AgentType.Diet);
Assert.Contains("VLM", prompt);
Assert.Contains("能不能吃", prompt);
}
[Fact]
public void PromptManager_Medication_Should_Contain_ParseRules()
{
var pm = new PromptManager();
var prompt = pm.GetSystemPrompt(AgentType.Medication);
Assert.Contains("药名", prompt);
Assert.Contains("剂量", prompt);
}
[Fact]
public void PromptManager_Report_Should_Contain_Disclaimer()
{
var pm = new PromptManager();
var prompt = pm.GetSystemPrompt(AgentType.Report);
Assert.Contains("AI预解读", prompt);
Assert.Contains("医生确认", prompt);
}
[Fact]
public void PromptManager_Exercise_Should_Contain_RehabKeywords()
{
var pm = new PromptManager();
var prompt = pm.GetSystemPrompt(AgentType.Exercise);
Assert.Contains("心脏康复", prompt);
Assert.Contains("循序渐进", prompt);
}
[Fact]
public void PromptManager_All_Agents_Should_Return_NonEmpty()
{
var pm = new PromptManager();
foreach (AgentType agent in Enum.GetValues<AgentType>())
{
var prompt = pm.GetSystemPrompt(agent);
Assert.False(string.IsNullOrWhiteSpace(prompt), $"{agent} 的 SystemPrompt 为空");
}
}
// ==================== DeepSeekClient 连通性测试 ====================
[Fact]
public void DeepSeekClient_ApiKey_Should_Be_Configured()
{
// 仅验证 API Key 已配置(实际调用通过集成测试验证)
CreateDeepSeekClient();
var config = new ConfigurationBuilder().AddEnvironmentVariables().Build();
var apiKey = config["DEEPSEEK_API_KEY"] ?? "";
Assert.False(string.IsNullOrEmpty(apiKey) || apiKey.StartsWith("sk-your-key"),
"DEEPSEEK_API_KEY 未配置或为占位符,请在 backend/.env 中设置真实 Key");
}
// ==================== AI 对话 + Tool Calling 集成测试 ====================
[Fact]
public async Task HealthAgent_RecordBloodPressure_Should_SaveToDb()
{
// 先登录获取 token
var token = await LoginAsync("13800000001");
// 发送对话消息触发 Tool Calling
var events = await SendChatMessage(token, "health", "我刚刚测了血压138/86");
var toolResults = events.Where(e => e.Action == "tool_result").ToList();
Assert.NotEmpty(toolResults);
}
[Fact]
public async Task HealthAgent_RecordHeartRate_Should_SaveToDb()
{
var token = await LoginAsync("13800000001");
var events = await SendChatMessage(token, "health", "心率72");
var toolResults = events.Where(e => e.Action == "tool_result").ToList();
Assert.NotEmpty(toolResults);
}
[Fact]
public async Task MedicationAgent_Query_Should_Return_Medications()
{
var token = await LoginAsync("13800000001");
var events = await SendChatMessage(token, "medication", "我现在在吃什么药?");
var answers = events.Where(e => e.Action == "answer")
.Select(e => e.Data?.ToString() ?? "");
var fullResponse = string.Join("", answers);
Assert.NotEmpty(fullResponse);
// 应该提到阿司匹林或阿托伐他汀
Assert.True(fullResponse.Contains("阿司匹林") || fullResponse.Contains("阿托伐他汀") ||
fullResponse.Contains("Aspirin"));
}
[Fact]
public async Task ConsultationAgent_SymptomCheck_Should_AskFollowUp()
{
var token = await LoginAsync("13800000001");
var events = await SendChatMessage(token, "consultation", "最近胸口有点不舒服");
var answers = events.Where(e => e.Action == "answer")
.Select(e => e.Data?.ToString() ?? "");
var fullResponse = string.Join("", answers);
Assert.NotEmpty(fullResponse);
}
[Fact]
public async Task DefaultAgent_GeneralQuestion_Should_Respond()
{
var token = await LoginAsync("13800000001");
var events = await SendChatMessage(token, "default", "你好,介绍一下你自己");
var answers = events.Where(e => e.Action == "answer")
.Select(e => e.Data?.ToString() ?? "");
var fullResponse = string.Join("", answers);
Assert.NotEmpty(fullResponse);
Assert.True(fullResponse.Length > 10, "默认 Agent 应返回有效回复");
}
// ==================== 辅助方法 ====================
/// <summary>
/// 发送验证码 + 登录,返回 accessToken
/// </summary>
private static async Task<string> LoginAsync(string phone)
{
// 发送验证码
var smsPayload = JsonSerializer.Serialize(new { phone }, JsonOpts);
var smsResp = await Http.PostAsync("/api/auth/send-sms",
new StringContent(smsPayload, Encoding.UTF8, "application/json"));
var smsJson = JsonDocument.Parse(await smsResp.Content.ReadAsStringAsync());
var devCode = smsJson.RootElement.GetProperty("data").GetProperty("devCode").GetString()!;
// 登录
var loginPayload = JsonSerializer.Serialize(new { phone, smsCode = devCode }, JsonOpts);
var loginResp = await Http.PostAsync("/api/auth/login",
new StringContent(loginPayload, Encoding.UTF8, "application/json"));
var loginJson = JsonDocument.Parse(await loginResp.Content.ReadAsStringAsync());
return loginJson.RootElement.GetProperty("data").GetProperty("accessToken").GetString()!;
}
/// <summary>
/// 向指定 Agent 发送消息,返回所有 SSE 事件
/// </summary>
private static async Task<List<SseEvent>> SendChatMessage(string token, string agentType, string message)
{
var url = $"/api/ai/{agentType}/chat?message={Uri.EscapeDataString(message)}&token={Uri.EscapeDataString(token)}";
Http.DefaultRequestHeaders.Authorization = null; // token 走 query string
var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var events = new List<SseEvent>();
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
if (!line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
if (data == "[DONE]") break;
try
{
var parsed = JsonSerializer.Deserialize<SseEvent>(data, JsonOpts);
if (parsed != null) events.Add(parsed);
}
catch { /* 跳过无法解析的 chunk */ }
}
return events;
}
/// <summary>
/// 创建 DeepSeekClient读取 .env 配置)
/// </summary>
private static DeepSeekClient CreateDeepSeekClient()
{
// 从测试输出目录向上 5 级找到 backend/.env
// bin/Debug/net10.0 → Health.Tests → tests → backend
var baseDir = AppContext.BaseDirectory;
var envPath = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", ".env"));
if (File.Exists(envPath))
{
foreach (var envLine in File.ReadAllLines(envPath))
{
var trimmed = envLine.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue;
var eqIdx = trimmed.IndexOf('=');
if (eqIdx <= 0) continue;
var key = trimmed[..eqIdx].Trim();
var value = trimmed[(eqIdx + 1)..].Trim();
Environment.SetEnvironmentVariable(key, value);
}
}
var config = new ConfigurationBuilder().AddEnvironmentVariables().Build();
var apiKey = config["DEEPSEEK_API_KEY"] ?? "";
var baseUrl = (config["DEEPSEEK_BASE_URL"] ?? "https://api.deepseek.com/v1").TrimEnd('/') + "/";
var httpClient = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(120) };
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
return new DeepSeekClient(httpClient, config);
}
}
/// <summary>SSE 事件模型</summary>
public class SseEvent
{
public string? Action { get; set; }
public object? Data { get; set; }
public string? Message { get; set; }
}

View File

@@ -1,6 +1,7 @@
using Health.Application.AI; using Health.Application.AI;
using Health.Application.Calendars; using Health.Application.Calendars;
using Health.Application.HealthArchives; using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Exercises; using Health.Application.Exercises;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums; using Health.Domain.Enums;
@@ -34,6 +35,67 @@ public sealed class ApplicationServiceTests
Assert.Equal(["青霉素"], repository.Archive.Allergies); Assert.Equal(["青霉素"], repository.Archive.Allergies);
} }
[Fact]
public async Task HealthArchive_ManualUpdateStoresMultipleStructuredSurgeries()
{
var userId = Guid.NewGuid();
var repository = new FakeHealthArchiveRepository(new HealthArchive
{
Id = Guid.NewGuid(), UserId = userId,
});
var service = new HealthArchiveService(repository);
var result = await service.UpdateAsync(
userId,
new HealthArchiveUpdateRequest(
null, null, null, null, null, null, null,
[
new HealthArchiveSurgeryInput("PCI", new DateOnly(2025, 3, 1)),
new HealthArchiveSurgeryInput("Appendectomy", new DateOnly(2010, 6, 2)),
]),
CancellationToken.None);
Assert.Equal(2, result.Surgeries.Count);
Assert.Equal("PCI", result.SurgeryType);
Assert.Equal(new DateOnly(2025, 3, 1), result.SurgeryDate);
Assert.Equal([0, 1], repository.Archive!.Surgeries.OrderBy(s => s.SortOrder).Select(s => s.SortOrder));
}
[Fact]
public async Task HealthTrend_365DaysUsesFullYearWindow()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
var before = DateTime.UtcNow;
await service.GetTrendAsync(Guid.NewGuid(), HealthMetricType.Weight, 365, CancellationToken.None);
Assert.NotNull(repository.RecordedAfter);
Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01);
}
[Fact]
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
{
var userId = Guid.NewGuid();
var repository = new FakeHealthArchiveRepository(new HealthArchive
{
Id = Guid.NewGuid(), UserId = userId,
SurgeryType = "Legacy surgery", SurgeryDate = new DateOnly(2010, 1, 2),
});
var service = new HealthArchiveService(repository);
await service.ExecuteAiActionAsync(
userId,
"update_surgery",
new HealthArchiveUpdateRequest(null, "New surgery", new DateOnly(2026, 6, 20), null, null, null, null),
CancellationToken.None);
Assert.Equal(2, repository.Archive!.Surgeries.Count);
Assert.Contains(repository.Archive.Surgeries, s => s.Type == "Legacy surgery");
Assert.Contains(repository.Archive.Surgeries, s => s.Type == "New surgery");
}
[Fact] [Fact]
public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation() public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation()
{ {
@@ -113,6 +175,23 @@ public sealed class ApplicationServiceTests
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
} }
private sealed class CapturingHealthRecordRepository : IHealthRecordRepository
{
public DateTime? RecordedAfter { get; private set; }
public Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<HealthRecord>>([]);
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
public Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
public Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct)
{
RecordedAfter = recordedAfter;
return Task.FromResult<IReadOnlyList<HealthRecord>>([]);
}
public Task AddAsync(HealthRecord record, CancellationToken ct) => Task.CompletedTask;
public void Delete(HealthRecord record) { }
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
}
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
{ {
private readonly Conversation _conversation = conversation; private readonly Conversation _conversation = conversation;

View File

@@ -1,6 +1,11 @@
using Health.Application.Medications;
using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Health.Infrastructure.Data; using Health.Infrastructure.Data;
using Health.Infrastructure.Data.Records; using Health.Infrastructure.Data.Records;
using Health.Infrastructure.Medications;
using Health.Infrastructure.Notifications;
using Health.Infrastructure.Reports; using Health.Infrastructure.Reports;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -57,6 +62,112 @@ public sealed class PersistencePipelineTests
Assert.Equal(2000, task.LastError!.Length); Assert.Equal(2000, task.LastError!.Length);
} }
[Fact]
public async Task ReportTask_OnlyEnqueuesFailureNotificationAtFinalAttempt()
{
await using var db = CreateDbContext();
var userId = Guid.NewGuid();
var report = new Report
{
Id = Guid.NewGuid(),
UserId = userId,
FileUrl = "report.jpg",
Status = ReportStatus.Analyzing,
};
var task = NewReportTask(attempts: 2);
task.ReportId = report.Id;
db.Reports.Add(report);
db.ReportAnalysisTasks.Add(task);
await db.SaveChangesAsync();
var queue = new EfReportAnalysisQueue(db, new EfUserNotificationProducer(db));
await queue.RetryAsync(task.Id, "temporary failure", CancellationToken.None);
Assert.Empty(db.NotificationOutbox);
task.Status = "Processing";
task.Attempts = 3;
await db.SaveChangesAsync();
await queue.RetryAsync(task.Id, "final failure", CancellationToken.None);
var notification = Assert.Single(db.NotificationOutbox);
Assert.Equal(task.Id, notification.SourceTaskId);
Assert.Equal("ReportAnalysisFailed", notification.Type);
Assert.Equal(userId, notification.UserId);
}
[Fact]
public async Task MedicationReminder_MorningDoseDoesNotSuppressEveningDose()
{
await using var db = CreateDbContext();
var userId = Guid.NewGuid();
var medicationId = Guid.NewGuid();
var scanTimeUtc = new DateTime(2026, 6, 20, 12, 2, 0, DateTimeKind.Utc);
db.Medications.Add(new Medication
{
Id = medicationId,
UserId = userId,
Name = "测试药物",
Frequency = MedicationFrequency.TwiceDaily,
TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)],
StartDate = new DateOnly(2026, 6, 20),
IsActive = true,
});
db.MedicationLogs.Add(new MedicationLog
{
Id = Guid.NewGuid(),
UserId = userId,
MedicationId = medicationId,
Status = MedicationLogStatus.Taken,
ScheduledTime = new TimeOnly(8, 0),
ConfirmedAt = new DateTime(2026, 6, 20, 0, 1, 0, DateTimeKind.Utc),
CreatedAt = new DateTime(2026, 6, 20, 0, 1, 0, DateTimeKind.Utc),
});
await db.SaveChangesAsync();
var scanner = new MedicationReminderScanner(new EfMedicationRepository(db));
var tasks = await scanner.ScanAsync(scanTimeUtc, CancellationToken.None);
var reminder = Assert.Single(tasks);
Assert.Equal(new TimeOnly(20, 0), reminder.ScheduledTime);
}
[Fact]
public async Task MedicationReminder_ConfirmedDoseIsNotRemindedAgain()
{
await using var db = CreateDbContext();
var userId = Guid.NewGuid();
var medicationId = Guid.NewGuid();
var confirmedAt = new DateTime(2026, 6, 20, 12, 0, 0, DateTimeKind.Utc);
db.Medications.Add(new Medication
{
Id = medicationId,
UserId = userId,
Name = "测试药物",
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(20, 0)],
StartDate = new DateOnly(2026, 6, 20),
IsActive = true,
});
db.MedicationLogs.Add(new MedicationLog
{
Id = Guid.NewGuid(),
UserId = userId,
MedicationId = medicationId,
Status = MedicationLogStatus.Taken,
ScheduledTime = new TimeOnly(20, 0),
ConfirmedAt = confirmedAt,
CreatedAt = confirmedAt,
});
await db.SaveChangesAsync();
var scanner = new MedicationReminderScanner(new EfMedicationRepository(db));
var tasks = await scanner.ScanAsync(
new DateTime(2026, 6, 20, 12, 2, 0, DateTimeKind.Utc),
CancellationToken.None);
Assert.Empty(tasks);
}
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new() private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),

View File

@@ -113,6 +113,8 @@ AI 问诊、饮食分析、报告解读是项目亮点,但要注意:
### 5.1 API 配置不适合真实移动端 ### 5.1 API 配置不适合真实移动端
> 2026-06-20 更新:已支持 `--dart-define=API_BASE_URL=...`,并保留 `http://localhost:5000` 作为 USB `adb reverse` 本地开发默认值。本节的环境配置问题已处理。
`health_app/lib/core/api_client.dart``baseUrl` 默认是 `http://localhost:5000`。这在 Android/iOS 真机上通常不可用,也不适合测试/生产环境切换。 `health_app/lib/core/api_client.dart``baseUrl` 默认是 `http://localhost:5000`。这在 Android/iOS 真机上通常不可用,也不适合测试/生产环境切换。
建议: 建议:
@@ -174,6 +176,8 @@ AI 问诊、饮食分析、报告解读是项目亮点,但要注意:
### 5.6 错误处理过于安静 ### 5.6 错误处理过于安静
> 2026-06-20 更新:`ApiClient` 已统一识别 `{ code, message }` 业务错误及 HTTP/Dio 网络错误,并转换为可直接展示的 `ApiException`。后端历史 endpoint 的 HTTP 状态码仍需按模块逐步规范,避免一次性破坏现有前端协议。
一些 provider 会 catch 异常后返回 fallback 数据,例如医生列表、运动计划。这个方式能让页面不崩,但会掩盖真实后端错误。 一些 provider 会 catch 异常后返回 fallback 数据,例如医生列表、运动计划。这个方式能让页面不崩,但会掩盖真实后端错误。
建议: 建议:
@@ -481,6 +485,8 @@ static IQueryable<User> ScopePatientsToDoctor(AppDbContext db, Guid doctorId) =>
### 11.7 前端状态和生命周期问题 ### 11.7 前端状态和生命周期问题
> 2026-06-20 更新:健康最新值、用药列表、用药提醒、当前运动计划已改为 `autoDispose`AI 确认写入后会同时刷新这些核心数据。报告和饮食使用各自 Notifier/页面加载流程,尚未强行合并成一个全局缓存。
前端现在能跑起来,但长期运行会有状态残留风险: 前端现在能跑起来,但长期运行会有状态残留风险:
- `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)` - `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)`

View File

@@ -3,8 +3,21 @@ import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'local_database.dart'; import 'local_database.dart';
/// API 基础地址 /// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = 'http://localhost:5000'; // adb reverse 或同WiFi直连时改为PC的IP const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:5000',
);
class ApiException implements Exception {
final int? code;
final String message;
const ApiException(this.message, {this.code});
@override
String toString() => message;
}
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新 /// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
class ApiClient { class ApiClient {
@@ -40,22 +53,22 @@ class ApiClient {
/// 带 token 的 GET 请求 /// 带 token 的 GET 请求
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async { Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
return _dio.get(path, queryParameters: queryParameters); return _request(_dio.get(path, queryParameters: queryParameters));
} }
/// 带 token 的 POST 请求 /// 带 token 的 POST 请求
Future<Response> post(String path, {dynamic data}) async { Future<Response> post(String path, {dynamic data}) async {
return _dio.post(path, data: data); return _request(_dio.post(path, data: data));
} }
/// 带 token 的 PUT 请求 /// 带 token 的 PUT 请求
Future<Response> put(String path, {dynamic data}) async { Future<Response> put(String path, {dynamic data}) async {
return _dio.put(path, data: data); return _request(_dio.put(path, data: data));
} }
/// 带 token 的 DELETE 请求 /// 带 token 的 DELETE 请求
Future<Response> delete(String path) async { Future<Response> delete(String path) async {
return _dio.delete(path); return _request(_dio.delete(path));
} }
/// 上传文件multipart返回文件 URL /// 上传文件multipart返回文件 URL
@@ -63,7 +76,7 @@ class ApiClient {
final formData = FormData.fromMap({ final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last), fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
}); });
final res = await _dio.post(path, data: formData); final res = await _request(_dio.post(path, data: formData));
final data = res.data; final data = res.data;
if (data is Map) { if (data is Map) {
final topUrl = data['url']?.toString(); final topUrl = data['url']?.toString();
@@ -82,6 +95,46 @@ class ApiClient {
} }
return null; return null;
} }
Response _ensureBusinessSuccess(Response response) {
final body = response.data;
if (body is Map && body.containsKey('code')) {
final rawCode = body['code'];
final code = rawCode is int ? rawCode : int.tryParse('$rawCode');
if (code != null && code != 0) {
throw ApiException(
body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: '请求失败',
code: code,
);
}
}
return response;
}
Future<Response> _request(Future<Response> request) async {
try {
return _ensureBusinessSuccess(await request);
} on DioException catch (error) {
final body = error.response?.data;
final message = body is Map && body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout
? '请求超时,请稍后重试'
: error.type == DioExceptionType.connectionError
? '无法连接服务器,请检查网络或后端地址'
: '请求失败,请稍后重试';
final rawCode = body is Map ? body['code'] : null;
throw ApiException(
message,
code: rawCode is int
? rawCode
: int.tryParse('$rawCode') ?? error.response?.statusCode,
);
}
}
} }
/// 认证拦截器:自动注入 token + 401 刷新 /// 认证拦截器:自动注入 token + 401 刷新

View File

@@ -12,6 +12,7 @@ import '../pages/report/ai_analysis_page.dart';
import '../pages/consultation/consultation_pages.dart'; import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart'; import '../pages/settings/settings_pages.dart';
import '../pages/settings/notification_prefs_page.dart'; import '../pages/settings/notification_prefs_page.dart';
import '../pages/notifications/notification_center_page.dart';
import '../pages/profile/profile_page.dart'; import '../pages/profile/profile_page.dart';
import '../pages/diet/diet_capture_page.dart'; import '../pages/diet/diet_capture_page.dart';
import '../pages/device/device_scan_page.dart'; import '../pages/device/device_scan_page.dart';
@@ -97,6 +98,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const SettingsPage(); return const SettingsPage();
case 'notificationPrefs': case 'notificationPrefs':
return const NotificationPrefsPage(); return const NotificationPrefsPage();
case 'notifications':
return const NotificationCenterPage();
case 'staticText': case 'staticText':
return StaticTextPage(type: params['type']!); return StaticTextPage(type: params['type']!);
case 'dietDetail': case 'dietDetail':

View File

@@ -121,7 +121,17 @@ class DoctorPatientDetailPage extends ConsumerWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
if (archive['diagnosis'] != null) if (archive['diagnosis'] != null)
_row('诊断', archive['diagnosis']), _row('诊断', archive['diagnosis']),
if (archive['surgeryType'] != null) if (archive['surgeries'] is List &&
(archive['surgeries'] as List).isNotEmpty)
_row(
'手术史',
(archive['surgeries'] as List).map((item) {
final surgery = item as Map;
final date = surgery['date']?.toString() ?? '';
return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}';
}).join(''),
)
else if (archive['surgeryType'] != null)
_row( _row(
'手术史', '手术史',
'${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}', '${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}',

View File

@@ -12,7 +12,6 @@ import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
import '../../widgets/health_drawer.dart'; import '../../widgets/health_drawer.dart';
import '../../services/in_app_notification_service.dart';
import '../diet/diet_capture_page.dart'; import '../diet/diet_capture_page.dart';
import 'widgets/chat_messages_view.dart'; import 'widgets/chat_messages_view.dart';
@@ -29,15 +28,16 @@ class _HomePageState extends ConsumerState<HomePage> {
String? _pickedImagePath; String? _pickedImagePath;
int _lastMsgCount = 0; int _lastMsgCount = 0;
Timer? _notificationTimer; Timer? _notificationTimer;
bool _checkingNotifications = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkNotifications()); WidgetsBinding.instance.addPostFrameCallback(
(_) => ref.invalidate(notificationUnreadCountProvider),
);
_notificationTimer = Timer.periodic( _notificationTimer = Timer.periodic(
const Duration(seconds: 30), const Duration(seconds: 30),
(_) => _checkNotifications(), (_) => ref.invalidate(notificationUnreadCountProvider),
); );
} }
@@ -50,52 +50,6 @@ class _HomePageState extends ConsumerState<HomePage> {
super.dispose(); super.dispose();
} }
Future<void> _checkNotifications() async {
if (!mounted || _checkingNotifications) return;
if (!ref.read(authProvider).isLoggedIn) return;
_checkingNotifications = true;
try {
final service = InAppNotificationService(ref.read(apiClientProvider));
final notifications = await service.getPending();
for (final notification in notifications) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 5),
content: Row(
children: [
Icon(
notification.type == 'ExerciseReminder'
? Icons.directions_run_outlined
: Icons.medication_outlined,
color: Colors.white,
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(notification.title,
style: const TextStyle(fontWeight: FontWeight.w700)),
Text(notification.message),
],
),
),
],
),
),
);
await service.acknowledge(notification.id);
}
} catch (_) {
// App 内提醒失败不阻断主页使用,下次轮询会重试。
} finally {
_checkingNotifications = false;
}
}
void _sendMessage() { void _sendMessage() {
final text = _textCtrl.text.trim(); final text = _textCtrl.text.trim();
final imagePath = _pickedImagePath; final imagePath = _pickedImagePath;
@@ -169,6 +123,7 @@ class _HomePageState extends ConsumerState<HomePage> {
final name = (user?.name != null && user!.name!.isNotEmpty) final name = (user?.name != null && user!.name!.isNotEmpty)
? user.name ? user.name
: '用户'; : '用户';
final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0;
return Container( return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -221,7 +176,8 @@ class _HomePageState extends ConsumerState<HomePage> {
), ),
_HeaderIconButton( _HeaderIconButton(
icon: LucideIcons.bell, icon: LucideIcons.bell,
onTap: () => pushRoute(ref, 'notificationPrefs'), badgeCount: unreadCount,
onTap: () => pushRoute(ref, 'notifications'),
), ),
], ],
), ),
@@ -565,22 +521,55 @@ class _HomePageState extends ConsumerState<HomePage> {
class _HeaderIconButton extends StatelessWidget { class _HeaderIconButton extends StatelessWidget {
final IconData icon; final IconData icon;
final VoidCallback onTap; final VoidCallback onTap;
const _HeaderIconButton({required this.icon, required this.onTap}); final int badgeCount;
const _HeaderIconButton({
required this.icon,
required this.onTap,
this.badgeCount = 0,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Stack(
width: 38, clipBehavior: Clip.none,
height: 38, children: [
decoration: BoxDecoration( Container(
gradient: AppColors.surfaceGradient, width: 38,
borderRadius: BorderRadius.circular(14), height: 38,
border: Border.all(color: AppColors.borderLight), decoration: BoxDecoration(
boxShadow: AppColors.cardShadowLight, gradient: AppColors.surfaceGradient,
), borderRadius: BorderRadius.circular(14),
child: Icon(icon, size: 20, color: AppColors.textPrimary), border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Icon(icon, size: 20, color: AppColors.textPrimary),
),
if (badgeCount > 0)
Positioned(
right: -5,
top: -5,
child: Container(
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 2),
),
alignment: Alignment.center,
child: Text(
badgeCount > 99 ? '99+' : '$badgeCount',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w800,
),
),
),
),
],
), ),
); );
} }

View File

@@ -1464,13 +1464,13 @@ class ChatMessagesView extends ConsumerWidget {
? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}' ? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}'
: null; : null;
final hr = healthData['HeartRate']; final hr = healthData['HeartRate'];
final hrText = hr is int ? '$hr' : null; final hrText = hr is Map && hr['value'] is num ? '${hr['value']}' : null;
final bs = healthData['BloodSugar']; final bs = healthData['Glucose'];
final bsText = bs is num ? '$bs' : null; final bsText = bs is Map && bs['value'] is num ? '${bs['value']}' : null;
final bo = healthData['BloodOxygen']; final bo = healthData['SpO2'];
final boText = bo is num ? '$bo' : null; final boText = bo is Map && bo['value'] is num ? '${bo['value']}' : null;
final wt = healthData['Weight']; final wt = healthData['Weight'];
final wtText = wt is num ? '$wt' : null; final wtText = wt is Map && wt['value'] is num ? '${wt['value']}' : null;
final allNull = final allNull =
bpText == null && bpText == null &&
hrText == null && hrText == null &&

View File

@@ -5,6 +5,7 @@ import '../../core/navigation_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
import '../../widgets/app_error_state.dart';
class MedicationCheckInPage extends ConsumerStatefulWidget { class MedicationCheckInPage extends ConsumerStatefulWidget {
final String? medId; // 可选指定药品null 显示全部 final String? medId; // 可选指定药品null 显示全部
@@ -43,6 +44,9 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
if (snap.connectionState == ConnectionState.waiting) { if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(color: AppTheme.primary)); return const Center(child: CircularProgressIndicator(color: AppTheme.primary));
} }
if (snap.hasError) {
return AppErrorState(title: '用药信息加载失败', onRetry: _load);
}
final reminders = snap.data ?? []; final reminders = snap.data ?? [];
if (reminders.isEmpty) { if (reminders.isEmpty) {
return Center( return Center(

View File

@@ -5,6 +5,7 @@ import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
import '../../widgets/app_empty_state.dart'; import '../../widgets/app_empty_state.dart';
import '../../widgets/app_future_view.dart';
import '../../widgets/common_widgets.dart'; import '../../widgets/common_widgets.dart';
class MedicationListPage extends ConsumerStatefulWidget { class MedicationListPage extends ConsumerStatefulWidget {
@@ -85,10 +86,11 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage>
), ),
body: Container( body: Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient), decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: FutureBuilder<List<Map<String, dynamic>>>( child: AppFutureView<List<Map<String, dynamic>>>(
future: _future, future: _future,
builder: (ctx, snap) { onRetry: _load,
final list = snap.data ?? []; errorTitle: '用药信息加载失败',
onData: (ctx, list) {
if (list.isEmpty) { if (list.isEmpty) {
return const AppEmptyState( return const AppEmptyState(
icon: Icons.medication_outlined, icon: Icons.medication_outlined,

View File

@@ -0,0 +1,593 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../services/in_app_notification_service.dart';
class NotificationCenterPage extends ConsumerStatefulWidget {
const NotificationCenterPage({super.key});
@override
ConsumerState<NotificationCenterPage> createState() =>
_NotificationCenterPageState();
}
class _NotificationCenterPageState
extends ConsumerState<NotificationCenterPage> {
InAppNotificationHistory? _history;
bool _loading = true;
bool _markingAll = false;
String? _error;
InAppNotificationService get _service =>
ref.read(inAppNotificationServiceProvider);
@override
void initState() {
super.initState();
Future.microtask(_load);
}
Future<void> _load() async {
if (mounted) {
setState(() {
_loading = true;
_error = null;
});
}
try {
final history = await _service.getHistory();
if (!mounted) return;
setState(() {
_history = history;
_loading = false;
});
ref.invalidate(notificationUnreadCountProvider);
} catch (error) {
if (mounted) {
setState(() {
_error = '$error';
_loading = false;
});
}
}
}
Future<void> _open(InAppNotification item) async {
if (!item.isRead) {
await _service.acknowledge(item.id);
if (!mounted) return;
setState(() {
final current = _history;
if (current == null) return;
_history = InAppNotificationHistory(
unreadCount: (current.unreadCount - 1).clamp(0, current.unreadCount),
items: current.items
.map(
(value) =>
value.id == item.id ? value.copyWith(isRead: true) : value,
)
.toList(),
);
});
ref.invalidate(notificationUnreadCountProvider);
}
if (!mounted || item.actionType == null) return;
switch (item.actionType) {
case 'medication':
pushRoute(ref, 'medCheckIn', params: {'id': item.actionTargetId ?? ''});
return;
case 'exercise':
pushRoute(ref, 'exercisePlan');
return;
case 'health':
pushRoute(ref, 'trend', params: {'type': item.actionTargetId ?? ''});
return;
case 'report':
final reportId = item.actionTargetId;
if (reportId != null && reportId.isNotEmpty) {
pushRoute(ref, 'aiAnalysis', params: {'id': reportId});
}
return;
}
}
Future<void> _markAllRead() async {
if (_markingAll || (_history?.unreadCount ?? 0) == 0) return;
setState(() => _markingAll = true);
try {
await _service.markAllRead();
if (!mounted) return;
final current = _history;
if (current != null) {
setState(
() => _history = InAppNotificationHistory(
unreadCount: 0,
items: current.items
.map((item) => item.copyWith(isRead: true))
.toList(),
),
);
}
ref.invalidate(notificationUnreadCountProvider);
} finally {
if (mounted) setState(() => _markingAll = false);
}
}
Future<void> _delete(InAppNotification item) async {
try {
await _service.delete(item.id);
if (!mounted) return;
final current = _history;
if (current != null) {
setState(
() => _history = InAppNotificationHistory(
unreadCount: item.isRead
? current.unreadCount
: (current.unreadCount - 1).clamp(0, current.unreadCount),
items: current.items.where((value) => value.id != item.id).toList(),
),
);
}
ref.invalidate(notificationUnreadCountProvider);
} catch (_) {
if (mounted) await _load();
}
}
@override
Widget build(BuildContext context) {
final items = _history?.items ?? const <InAppNotification>[];
return Scaffold(
body: AppBackground(
safeArea: true,
child: Column(
children: [
_Header(
unreadCount: _history?.unreadCount ?? 0,
markingAll: _markingAll,
onBack: () => popRoute(ref),
onMarkAllRead: _markAllRead,
),
Expanded(
child: RefreshIndicator(
onRefresh: _load,
color: const Color(0xFF438CE1),
child: _buildBody(items),
),
),
],
),
),
);
}
Widget _buildBody(List<InAppNotification> items) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return _MessageState(
icon: LucideIcons.wifiOff,
title: '通知加载失败',
message: '请检查网络连接后重试',
buttonText: '重新加载',
onPressed: _load,
);
}
if (items.isEmpty) {
return const _MessageState(
icon: LucideIcons.bellOff,
title: '暂时没有通知',
message: '用药、运动、健康指标和报告消息会显示在这里',
);
}
final today = <InAppNotification>[];
final earlier = <InAppNotification>[];
final now = DateTime.now();
for (final item in items) {
final local = item.createdAt.toLocal();
final isToday =
local.year == now.year &&
local.month == now.month &&
local.day == now.day;
(isToday ? today : earlier).add(item);
}
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 6, 16, 32),
children: [
if (today.isNotEmpty) ...[
const _SectionTitle('今天'),
...today.map(_buildDismissible),
],
if (earlier.isNotEmpty) ...[
const SizedBox(height: 10),
const _SectionTitle('更早'),
...earlier.map(_buildDismissible),
],
],
);
}
Widget _buildDismissible(InAppNotification item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Dismissible(
key: ValueKey(item.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(18),
),
child: const Icon(LucideIcons.trash2, color: Colors.white),
),
onDismissed: (_) => _delete(item),
child: _NotificationCard(item: item, onTap: () => _open(item)),
),
);
}
class _Header extends StatelessWidget {
final int unreadCount;
final bool markingAll;
final VoidCallback onBack;
final VoidCallback onMarkAllRead;
const _Header({
required this.unreadCount,
required this.markingAll,
required this.onBack,
required this.onMarkAllRead,
});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 12, 10),
child: Row(
children: [
IconButton(
tooltip: '返回',
onPressed: onBack,
icon: const Icon(LucideIcons.chevronLeft),
),
const SizedBox(width: 2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'通知中心',
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
unreadCount == 0 ? '所有消息均已读' : '$unreadCount 条未读消息',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
if (unreadCount > 0)
TextButton.icon(
onPressed: markingAll ? null : onMarkAllRead,
icon: markingAll
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(LucideIcons.checkCheck, size: 17),
label: const Text('全部已读'),
),
],
),
);
}
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 4, 10),
child: Text(
text,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
),
);
}
class _NotificationCard extends StatelessWidget {
final InAppNotification item;
final VoidCallback onTap;
const _NotificationCard({required this.item, required this.onTap});
@override
Widget build(BuildContext context) {
final visual = _NotificationVisual.of(item);
return Material(
color: item.isRead
? Colors.white.withValues(alpha: 0.72)
: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(18),
elevation: item.isRead ? 0 : 1,
shadowColor: visual.color.withValues(alpha: 0.12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: item.isRead
? const Color(0xFFE9EEF5)
: visual.color.withValues(alpha: 0.18),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [visual.lightColor, Colors.white],
),
borderRadius: BorderRadius.circular(14),
),
child: Icon(visual.icon, size: 22, color: visual.color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.title,
style: TextStyle(
fontSize: 15,
fontWeight: item.isRead
? FontWeight.w600
: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
if (!item.isRead)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: visual.color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: visual.color.withValues(alpha: 0.3),
blurRadius: 5,
),
],
),
),
],
),
const SizedBox(height: 5),
Text(
item.message,
style: const TextStyle(
fontSize: 13,
height: 1.45,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 9),
Row(
children: [
Text(
_formatTime(item.createdAt),
style: const TextStyle(
fontSize: 11,
color: AppColors.textHint,
),
),
const Spacer(),
if (item.actionType != null)
Row(
children: [
Text(
'查看详情',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: visual.color,
),
),
const SizedBox(width: 2),
Icon(
LucideIcons.chevronRight,
size: 14,
color: visual.color,
),
],
),
],
),
],
),
),
],
),
),
),
);
}
static String _formatTime(DateTime value) {
final local = value.toLocal();
final now = DateTime.now();
final time =
'${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
if (local.year == now.year &&
local.month == now.month &&
local.day == now.day) {
return time;
}
if (local.year == now.year) return '${local.month}${local.day}$time';
return '${local.year}${local.month}${local.day}$time';
}
}
class _NotificationVisual {
final IconData icon;
final Color color;
final Color lightColor;
const _NotificationVisual(this.icon, this.color, this.lightColor);
factory _NotificationVisual.of(InAppNotification item) {
switch (item.actionType) {
case 'exercise':
return const _NotificationVisual(
LucideIcons.activity,
Color(0xFF2C9A70),
Color(0xFFDDF7EC),
);
case 'health':
if (item.severity == 'critical') {
return const _NotificationVisual(
LucideIcons.triangleAlert,
Color(0xFFE55353),
Color(0xFFFFE7E7),
);
}
return const _NotificationVisual(
LucideIcons.heartPulse,
Color(0xFFE8863A),
Color(0xFFFFEEDC),
);
case 'report':
if (item.severity == 'error') {
return const _NotificationVisual(
LucideIcons.fileWarning,
Color(0xFFE55353),
Color(0xFFFFE7E7),
);
}
return const _NotificationVisual(
LucideIcons.fileCheck2,
Color(0xFF7B68CE),
Color(0xFFEDE9FF),
);
default:
return const _NotificationVisual(
LucideIcons.pill,
Color(0xFF468AD8),
Color(0xFFE4F0FF),
);
}
}
}
class _MessageState extends StatelessWidget {
final IconData icon;
final String title;
final String message;
final String? buttonText;
final VoidCallback? onPressed;
const _MessageState({
required this.icon,
required this.title,
required this.message,
this.buttonText,
this.onPressed,
});
@override
Widget build(BuildContext context) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(28, 120, 28, 32),
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 34),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withValues(alpha: 0.95),
const Color(0xFFF1F7FF).withValues(alpha: 0.9),
],
),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFE5EDF7)),
),
child: Column(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: const Color(0xFFE5F0FF),
borderRadius: BorderRadius.circular(20),
),
child: Icon(icon, size: 29, color: const Color(0xFF5D91CF)),
),
const SizedBox(height: 18),
Text(
title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 7),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
height: 1.5,
color: AppColors.textSecondary,
),
),
if (buttonText != null) ...[
const SizedBox(height: 20),
FilledButton(onPressed: onPressed, child: Text(buttonText!)),
],
],
),
),
],
);
}

View File

@@ -6,6 +6,8 @@ import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/data_providers.dart'; import '../providers/data_providers.dart';
import '../widgets/common_widgets.dart'; import '../widgets/common_widgets.dart';
import '../widgets/app_error_state.dart';
import '../widgets/app_future_view.dart';
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除) /// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
class DietRecordListPage extends ConsumerStatefulWidget { class DietRecordListPage extends ConsumerStatefulWidget {
@@ -722,10 +724,11 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
), ),
body: Container( body: Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient), decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: FutureBuilder<List<Map<String, dynamic>>>( child: AppFutureView<List<Map<String, dynamic>>>(
future: _future, future: _future,
builder: (ctx, snap) { onRetry: _load,
final plans = snap.data ?? []; errorTitle: '运动计划加载失败',
onData: (ctx, plans) {
if (plans.isEmpty) return _empty(context, '运动计划', '暂无计划,点击右下角新建'); if (plans.isEmpty) return _empty(context, '运动计划', '暂无计划,点击右下角新建');
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -1083,6 +1086,9 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
child: CircularProgressIndicator(color: AppTheme.primaryLight), child: CircularProgressIndicator(color: AppTheme.primaryLight),
); );
} }
if (snap.hasError) {
return AppErrorState(title: '随访加载失败', onRetry: _load);
}
final list = snap.data ?? []; final list = snap.data ?? [];
if (list.isEmpty) { if (list.isEmpty) {
return Center( return Center(
@@ -1288,10 +1294,18 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
final a = await srv.getHealthArchive(); final a = await srv.getHealthArchive();
if (a != null && mounted) { if (a != null && mounted) {
_diagnosisCtrl.text = a['diagnosis'] ?? ''; _diagnosisCtrl.text = a['diagnosis'] ?? '';
final st = a['surgeryType'] as String?; final surgeries = a['surgeries'] as List?;
final sd = a['surgeryDate'] as String?; if (surgeries != null && surgeries.isNotEmpty) {
if (st != null && st.isNotEmpty) { _surgeries.addAll(surgeries.whereType<Map>().map((item) => {
_surgeries.add({'type': st, 'date': sd ?? ''}); 'type': item['type']?.toString() ?? '',
'date': item['date']?.toString() ?? '',
}));
} else {
final st = a['surgeryType'] as String?;
final sd = a['surgeryDate'] as String?;
if (st != null && st.isNotEmpty) {
_surgeries.add({'type': st, 'date': sd ?? ''});
}
} }
_allergiesCtrl.text = (a['allergies'] as List?)?.join('') ?? ''; _allergiesCtrl.text = (a['allergies'] as List?)?.join('') ?? '';
_chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('') ?? ''; _chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('') ?? '';
@@ -1333,18 +1347,19 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
gender: _genderCtrl.text, gender: _genderCtrl.text,
birthDate: _birthDate, birthDate: _birthDate,
); );
final surgeryType = _surgeries final surgeries = _surgeries
.map((s) => s['type'] ?? '') .where((s) => (s['type'] ?? '').trim().isNotEmpty)
.where((s) => s.isNotEmpty) .map((s) => {
.join(''); 'type': s['type']!.trim(),
final surgeryDate = _surgeries 'date': (s['date'] ?? '').trim().isEmpty ? null : s['date']!.trim(),
.map((s) => s['date'] ?? '') })
.where((s) => s.isNotEmpty) .toList();
.join(''); final firstSurgery = surgeries.isEmpty ? null : surgeries.first;
await srv.updateHealthArchive({ await srv.updateHealthArchive({
'diagnosis': _diagnosisCtrl.text, 'diagnosis': _diagnosisCtrl.text,
'surgeryType': surgeryType, 'surgeryType': firstSurgery?['type'],
'surgeryDate': surgeryDate, 'surgeryDate': firstSurgery?['date'],
'surgeries': surgeries,
'allergies': _allergiesCtrl.text 'allergies': _allergiesCtrl.text
.split('') .split('')
.where((s) => s.isNotEmpty) .where((s) => s.isNotEmpty)
@@ -1359,6 +1374,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
.toList(), .toList(),
'familyHistory': _familyCtrl.text, 'familyHistory': _familyCtrl.text,
}); });
await ref.read(authProvider.notifier).refreshProfile();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(

View File

@@ -85,6 +85,8 @@ class AuthNotifier extends Notifier<AuthState> {
} }
} }
Future<void> refreshProfile() => _loadProfile();
/// 发送验证码 /// 发送验证码
Future<({String? error, String? devCode})> sendSms(String phone) async { Future<({String? error, String? devCode})> sendSms(String phone) async {
try { try {

View File

@@ -119,7 +119,9 @@ class ChatNotifier extends Notifier<ChatState> {
msgs[i].confirmed = true; msgs[i].confirmed = true;
state = state.copyWith(messages: msgs); state = state.copyWith(messages: msgs);
ref.invalidate(medicationListProvider); ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.invalidate(latestHealthProvider); ref.invalidate(latestHealthProvider);
ref.invalidate(currentExercisePlanProvider);
return null; return null;
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart'; import 'auth_provider.dart';
import '../services/health_service.dart'; import '../services/health_service.dart';
import '../services/admin_service.dart'; import '../services/admin_service.dart';
import '../services/in_app_notification_service.dart';
final exerciseServiceProvider = Provider<ExerciseService>((ref) { final exerciseServiceProvider = Provider<ExerciseService>((ref) {
return ExerciseService(ref.watch(apiClientProvider)); return ExerciseService(ref.watch(apiClientProvider));
@@ -36,8 +37,25 @@ final adminServiceProvider = Provider<AdminService>((ref) {
return AdminService(ref.watch(apiClientProvider)); return AdminService(ref.watch(apiClientProvider));
}); });
final inAppNotificationServiceProvider = Provider<InAppNotificationService>((
ref,
) {
return InAppNotificationService(ref.watch(apiClientProvider));
});
final notificationUnreadCountProvider = FutureProvider.autoDispose<int>((
ref,
) async {
final history = await ref
.watch(inAppNotificationServiceProvider)
.getHistory();
return history.unreadCount;
});
/// 最新健康数据 Provider /// 最新健康数据 Provider
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async { final latestHealthProvider = FutureProvider.autoDispose<Map<String, dynamic>>((
ref,
) async {
final service = ref.watch(healthServiceProvider); final service = ref.watch(healthServiceProvider);
return service.getLatest(); return service.getLatest();
}); });
@@ -49,48 +67,61 @@ void refreshHealthData(WidgetRef ref) {
} }
/// 用药列表 Provider /// 用药列表 Provider
final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { final medicationListProvider =
final service = ref.watch(medicationServiceProvider); FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
return service.getList(); final service = ref.watch(medicationServiceProvider);
}); return service.getList();
});
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { final medicationReminderProvider =
final service = ref.watch(medicationServiceProvider); FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
return service.getReminders(); final service = ref.watch(medicationServiceProvider);
}); return service.getReminders();
});
/// 医生列表 Provider /// 医生列表 Provider
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
final service = ref.watch(consultationServiceProvider); final service = ref.watch(consultationServiceProvider);
return service.getDoctors().timeout(const Duration(seconds: 8)); return service.getDoctors().timeout(const Duration(seconds: 8));
}); });
/// 问诊配额 Provider /// 问诊配额 Provider
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async { final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((
ref,
) async {
final service = ref.watch(consultationServiceProvider); final service = ref.watch(consultationServiceProvider);
return service.getQuota(); return service.getQuota();
}); });
/// 当前运动计划 Provider /// 当前运动计划 Provider
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async { final currentExercisePlanProvider =
final service = ref.watch(exerciseServiceProvider); FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {
return service.getCurrentPlan().timeout(const Duration(seconds: 8)); final service = ref.watch(exerciseServiceProvider);
}); return service.getCurrentPlan().timeout(const Duration(seconds: 8));
});
/// 拍照/相册直接触发(无需跳转页面) /// 拍照/相册直接触发(无需跳转页面)
final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(CameraActionNotifier.new); final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(
CameraActionNotifier.new,
);
class CameraActionNotifier extends Notifier<String?> { class CameraActionNotifier extends Notifier<String?> {
@override String? build() => null; @override
String? build() => null;
void trigger(String action) => state = action; void trigger(String action) => state = action;
void clear() => state = null; void clear() => state = null;
} }
/// 拍饮食动作触发(不用跳多余页面) /// 拍饮食动作触发(不用跳多余页面)
final dietActionProvider = NotifierProvider<DietActionNotifier, String?>(DietActionNotifier.new); final dietActionProvider = NotifierProvider<DietActionNotifier, String?>(
DietActionNotifier.new,
);
class DietActionNotifier extends Notifier<String?> { class DietActionNotifier extends Notifier<String?> {
@override String? build() => null; @override
String? build() => null;
void trigger(String action) => state = action; void trigger(String action) => state = action;
void clear() => state = null; void clear() => state = null;
} }

View File

@@ -5,12 +5,22 @@ class InAppNotification {
final String type; final String type;
final String title; final String title;
final String message; final String message;
final String severity;
final String? actionType;
final String? actionTargetId;
final bool isRead;
final DateTime createdAt;
const InAppNotification({ const InAppNotification({
required this.id, required this.id,
required this.type, required this.type,
required this.title, required this.title,
required this.message, required this.message,
required this.severity,
this.actionType,
this.actionTargetId,
required this.isRead,
required this.createdAt,
}); });
factory InAppNotification.fromJson(Map<String, dynamic> json) => factory InAppNotification.fromJson(Map<String, dynamic> json) =>
@@ -19,7 +29,36 @@ class InAppNotification {
type: json['type']?.toString() ?? '', type: json['type']?.toString() ?? '',
title: json['title']?.toString() ?? '健康提醒', title: json['title']?.toString() ?? '健康提醒',
message: json['message']?.toString() ?? '', message: json['message']?.toString() ?? '',
severity: json['severity']?.toString() ?? 'info',
actionType: json['actionType']?.toString(),
actionTargetId: json['actionTargetId']?.toString(),
isRead: json['isRead'] == true,
createdAt:
DateTime.tryParse(json['createdAt']?.toString() ?? '') ??
DateTime.now(),
); );
InAppNotification copyWith({bool? isRead}) => InAppNotification(
id: id,
type: type,
title: title,
message: message,
severity: severity,
actionType: actionType,
actionTargetId: actionTargetId,
isRead: isRead ?? this.isRead,
createdAt: createdAt,
);
}
class InAppNotificationHistory {
final int unreadCount;
final List<InAppNotification> items;
const InAppNotificationHistory({
required this.unreadCount,
required this.items,
});
} }
class InAppNotificationService { class InAppNotificationService {
@@ -32,7 +71,9 @@ class InAppNotificationService {
final items = response.data['data'] as List? ?? const []; final items = response.data['data'] as List? ?? const [];
return items return items
.whereType<Map>() .whereType<Map>()
.map((item) => InAppNotification.fromJson(Map<String, dynamic>.from(item))) .map(
(item) => InAppNotification.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.id.isNotEmpty) .where((item) => item.id.isNotEmpty)
.toList(); .toList();
} }
@@ -40,4 +81,29 @@ class InAppNotificationService {
Future<void> acknowledge(String id) async { Future<void> acknowledge(String id) async {
await _api.post('/api/notifications/$id/acknowledge'); await _api.post('/api/notifications/$id/acknowledge');
} }
Future<void> markAllRead() async {
await _api.post('/api/notifications/read-all');
}
Future<InAppNotificationHistory> getHistory() async {
final response = await _api.get('/api/notifications');
final data = response.data['data'] as Map? ?? const {};
final rawItems = data['items'] as List? ?? const [];
return InAppNotificationHistory(
unreadCount: data['unreadCount'] as int? ?? 0,
items: rawItems
.whereType<Map>()
.map(
(item) =>
InAppNotification.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.id.isNotEmpty)
.toList(),
);
}
Future<void> delete(String id) async {
await _api.delete('/api/notifications/$id');
}
} }

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
/// 统一的"加载失败"状态——与 AppEmptyState 风格一致,区别在于明确表达"出错了,可重试"
/// 避免把网络/服务失败误显示成"暂无数据"。
class AppErrorState extends StatelessWidget {
final String title;
final String? subtitle;
final VoidCallback? onRetry;
const AppErrorState({
super.key,
this.title = '加载失败',
this.subtitle = '网络异常或服务暂时不可用,请稍后重试',
this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: AppColors.softGlassGradient,
borderRadius: BorderRadius.circular(40),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: const Icon(
Icons.cloud_off_outlined,
size: 34,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 18),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle!,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
],
if (onRetry != null) ...[
const SizedBox(height: 20),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重试'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryDark,
side: const BorderSide(color: AppColors.borderLight),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import 'app_error_state.dart';
/// 基于 FutureBuilder 的四态封装:统一处理"加载中 / 失败"
/// "空数据 / 有数据"交由 onData 自行判断(不同页面空态文案不同)。
///
/// onData 拿到非空数据后,自行判断 isEmpty 决定显示空态还是列表。
class AppFutureView<T> extends StatelessWidget {
final Future<T>? future;
final Widget Function(BuildContext context, T data) onData;
final VoidCallback? onRetry;
final Widget? loading;
final String? errorTitle;
const AppFutureView({
super.key,
required this.future,
required this.onData,
this.onRetry,
this.loading,
this.errorTitle,
});
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: future,
builder: (ctx, snap) {
// 失败优先于其它判断,避免把错误显示成"暂无数据"
if (snap.hasError) {
return AppErrorState(
title: errorTitle ?? '加载失败',
onRetry: onRetry,
);
}
// 首次加载、尚无数据 → 转圈
if (snap.connectionState == ConnectionState.waiting && !snap.hasData) {
return loading ??
const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
}
if (!snap.hasData) {
return loading ??
const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
}
return onData(ctx, snap.data as T);
},
);
}
}