Compare commits
2 Commits
aa44d6f0f0
...
13714d9ed8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13714d9ed8 | ||
|
|
b57d0d16f4 |
13
backend/dotnet-tools.json
Normal file
13
backend/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.8",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}");
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} 不合理");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
7
backend/src/Health.Domain/ValidationException.cs
Normal file
7
backend/src/Health.Domain/ValidationException.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Health.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 业务输入校验失败异常——由 Application 层规则抛出,
|
||||||
|
/// 中间件统一映射为 400 / code 40001,message 可安全展示给用户。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ValidationException(string message) : Exception(message);
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1443
backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs
generated
Normal file
1443
backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace Health.Infrastructure.Data;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数据库种子数据
|
|
||||||
/// </summary>
|
|
||||||
public static class DataSeeder
|
|
||||||
{
|
|
||||||
public static async Task SeedAsync(AppDbContext db)
|
|
||||||
{
|
|
||||||
// 医生不再使用种子数据,通过注册创建
|
|
||||||
await Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
UserId = task.UserId,
|
|
||||||
SourceTaskId = task.TaskId,
|
|
||||||
Type = "MedicationReminder",
|
|
||||||
Payload = JsonSerializer.Serialize(new
|
|
||||||
{
|
|
||||||
task.MedicationId,
|
|
||||||
task.Name,
|
|
||||||
task.Dosage,
|
task.Dosage,
|
||||||
task.ScheduledDate,
|
task.ScheduledTime.ToString("HH:mm")
|
||||||
task.ScheduledTime,
|
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
}),
|
await _notifications.EnqueueAsync(
|
||||||
Status = "AwaitingProvider",
|
task.UserId,
|
||||||
AvailableAt = now,
|
task.TaskId,
|
||||||
CreatedAt = now,
|
new NotificationMessage(
|
||||||
UpdatedAt = now,
|
"MedicationReminder",
|
||||||
}, ct);
|
"用药时间到了",
|
||||||
await _db.SaveChangesAsync(ct);
|
$"请按计划服用{task.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。",
|
||||||
|
"info",
|
||||||
|
"medication",
|
||||||
|
task.MedicationId.ToString()),
|
||||||
|
ct);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"用药提醒已写入通知 Outbox,等待推送服务: {TaskId} {UserId}",
|
"用药提醒已写入通知 Outbox,等待推送服务: {TaskId} {UserId}",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
backend/src/Health.WebApi/Data/AppDbContextFactory.cs
Normal file
52
backend/src/Health.WebApi/Data/AppDbContextFactory.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设计时 DbContext 工厂——仅供 dotnet-ef(migrations / 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
|||||||
{
|
{
|
||||||
await _next(context);
|
await _next(context);
|
||||||
}
|
}
|
||||||
|
catch (Health.Domain.ValidationException vex)
|
||||||
|
{
|
||||||
|
// 业务输入校验失败:返回 400,message 为我们自己产生的提示,可安全展示
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
// 生产环境不暴露内部异常详情
|
// 生产环境不暴露内部异常详情
|
||||||
|
|||||||
@@ -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 端点 ----
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)`。
|
||||||
|
|||||||
@@ -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 刷新
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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'] ?? ''}',
|
||||||
|
|||||||
@@ -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,13 +521,21 @@ 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(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
width: 38,
|
width: 38,
|
||||||
height: 38,
|
height: 38,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -582,6 +546,31 @@ class _HeaderIconButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Icon(icon, size: 20, color: AppColors.textPrimary),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
593
health_app/lib/pages/notifications/notification_center_page.dart
Normal file
593
health_app/lib/pages/notifications/notification_center_page.dart
Normal 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!)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,11 +1294,19 @@ 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 surgeries = a['surgeries'] as List?;
|
||||||
|
if (surgeries != null && surgeries.isNotEmpty) {
|
||||||
|
_surgeries.addAll(surgeries.whereType<Map>().map((item) => {
|
||||||
|
'type': item['type']?.toString() ?? '',
|
||||||
|
'date': item['date']?.toString() ?? '',
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
final st = a['surgeryType'] as String?;
|
final st = a['surgeryType'] as String?;
|
||||||
final sd = a['surgeryDate'] as String?;
|
final sd = a['surgeryDate'] as String?;
|
||||||
if (st != null && st.isNotEmpty) {
|
if (st != null && st.isNotEmpty) {
|
||||||
_surgeries.add({'type': st, 'date': sd ?? ''});
|
_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('、') ?? '';
|
||||||
_dietCtrl.text = (a['dietRestrictions'] as List?)?.join('、') ?? '';
|
_dietCtrl.text = (a['dietRestrictions'] 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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
|
||||||
final service = ref.watch(medicationServiceProvider);
|
final service = ref.watch(medicationServiceProvider);
|
||||||
return service.getList();
|
return service.getList();
|
||||||
});
|
});
|
||||||
|
|
||||||
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
final medicationReminderProvider =
|
||||||
|
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
|
||||||
final service = ref.watch(medicationServiceProvider);
|
final service = ref.watch(medicationServiceProvider);
|
||||||
return service.getReminders();
|
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 =
|
||||||
|
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {
|
||||||
final service = ref.watch(exerciseServiceProvider);
|
final service = ref.watch(exerciseServiceProvider);
|
||||||
return service.getCurrentPlan().timeout(const Duration(seconds: 8));
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
health_app/lib/widgets/app_error_state.dart
Normal file
82
health_app/lib/widgets/app_error_state.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
health_app/lib/widgets/app_future_view.dart
Normal file
54
health_app/lib/widgets/app_future_view.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user