Compare commits

8 Commits

Author SHA1 Message Date
MingNian
3b5cec10a6 feat: AI 提示词模块化 + AI 同意门控 + AI 草稿存储 + 意图路由 + 医疗引用知识库 + 多页面 UI 优化
- AI 提示词拆分为 markdown 模块(Prompts/global + modules + rag + router)
- 新增 AI 同意门控(用户需同意后才能使用 AI 功能)
- 新增 AI 录入草稿存储(AiEntryDraftContracts/EfAiEntryDraftStore/AiEntryDraftRecord)
- 新增 AI 意图路由(ai_intent_router)
- 新增医疗引用知识库(MedicalCitationKnowledge)
- 重构 prompt_manager 和 ai_chat_endpoints
- 优化聊天/趋势/档案/抽屉/医生端等多个页面 UI
- 新增 agent 插画和趋势指标图标资源
- 删除 HANDOFF-2026-07-17.md
2026-07-27 16:53:20 +08:00
sccsbc
ad93e38b7e fix: 移除 BLE 蓝牙依赖,消除 CoreLocation API 引用
- 注释 flutter_blue_plus 和 permission_handler 依赖
- 删除蓝牙设备页、BLE service、omron provider 等 6 个源文件
- 移除路由和设备设置入口
- 清理 2 个 BLE 相关测试文件
- 解决 Transporter 报 NSLocationWhenInUseUsageDescription 缺失

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 17:19:39 +08:00
MingNian
39c32f842b feat: 去除"问诊"字眼 + "药管家"改名 + 删除AI对话欢迎卡片紫色框
- "AI问诊" -> "AI对话"(胶囊名、首页标签、页面标题、提示词)
- "药管家" -> "药提醒"(胶囊名、枚举注释、处理器注释)
- AI对话欢迎卡片删除紫色提示框(与底部提示重复)
- 底部提示文字去掉"观察或就医建议",改为"帮您记录和整理症状信息"
2026-07-22 17:08:16 +08:00
MingNian
5cd3584ae9 feat: iOS 审核阉割版 - iOS 蓝牙功能屏蔽 + Info.plist 删蓝牙权限 + AI 提示词去医疗化(应对 App Store 1.4.1)
- iOS 蓝牙 UI 入口用 Platform.isIOS 屏蔽(抽屉/设置页/记数据卡片/AI 嵌入链接)
- Info.plist 删除 NSBluetoothAlwaysUsageDescription 和 NSBluetoothPeripheralUsageDescription
- AI 提示词重写:只描述客观事实和综合信息,不给医疗建议,不做关联性分析/推测
- 所有"专家/教练/预问诊/预解读"角色改为"记录/整理/结构化提取助手"
- 去掉基于疾病(冠心病/糖尿病等)的饮食运动建议
- 保留正常值参考范围用于客观描述"超出范围",不作诊断
2026-07-21 11:57:54 +08:00
MingNian
28f704c98e feat: 长辈模式(大字大按钮 + 偏好按账号隔离)+ 语音输入(按住说话 + 实时转写 + 后端代理 DashScope ASR + 患者端专用)+ 健康仪表盘背景渐变 2026-07-21 10:53:16 +08:00
MingNian
0cb5b8e85a feat: AI Agent 处理器扩展 + 服药打卡确认链路 + AI 气泡配色微调 + 数据刷新 providers 抽离 + 多端页面细节调整 2026-07-20 17:12:00 +08:00
MingNian
9cea41705e feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固
- 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越
- LocalReportFileStorage: 文件存储路径安全加固
- local_account_file_cleanup: 账号删除时文件清理逻辑增强
- AuthService: 认证逻辑增强
- file_endpoints / report_endpoints: 文件访问接口安全加固
- ai_chat_endpoints / doctor_endpoints: 接口安全调整
- Program.cs: 服务注册调整

## 前端认证与媒体
- 新增 authenticated_network_image.dart: 带认证的图片加载组件
- auth_provider: 认证状态管理大幅增强(+173)
- api_client: 网络客户端增强(+124)
- chat_provider: 聊天 provider 重构(+76)
- omron_device_provider: 蓝牙设备 provider 增强(+53)
- sse_handler: SSE 处理增强(+35)
- consultation_provider / data_providers / conversation_history_provider: 调整

## 页面调整
- remaining_pages: 健康档案/饮食记录等页面增强(+115)
- home_page / chat_messages_view: 主页微调
- doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports)
- report_pages / settings_pages / notification_prefs_page: 微调
- device_scan_page / diet_capture_page / admin_home_page: 微调

## 测试
- 新增 file_path_security_tests: 文件路径安全测试
- 新增 protected_media_url_test: 媒体URL保护测试
- 新增 user_session_identity_test: 用户会话身份测试
- account_deletion_tests / application_service_tests / auth_tests: 更新
2026-07-20 10:19:01 +08:00
MingNian
0d4fd88ce7 feat: 三端抽屉重构 + 配色系统更新 + 后台管理页精调 + 键盘抬起组件
## 抽屉重构
- admin_drawer / doctor_drawer / health_drawer 三端抽屉全部重做
- drawer_shell 增强

## 配色系统
- app_colors / app_module_visuals / app_theme 更新
- app.dart 启动流程调整

## 后台管理页
- admin_doctors_page 大幅重构(+251)
- admin_home / doctor_dashboard / doctor_reports / doctor_home / doctor_followups / doctor_settings 精调

## 患者端
- home_page / chat_messages_view / medication_list / notification_center / remaining_pages / exercise_plan / device / diet / consultation 微调

## 新增
- keyboard_lift.dart: 键盘抬起处理组件
- AGENTS.md: agent 指引文档

## 其他
- api_client IP 适配
- app_future_view 增强
- data_providers 调整
- secondary_page_visuals_test 更新
2026-07-19 19:11:30 +08:00
171 changed files with 10059 additions and 5073 deletions

57
AGENTS.md Normal file
View File

@@ -0,0 +1,57 @@
# 项目协作准则
本文件适用于整个 `health_project` 仓库。处理任务时按改动风险分级,验证强度与风险匹配,避免小改动套用重型流程。
## 默认规则
- 默认不执行 `git add``git commit``git push`、合并、回滚或创建分支;只有用户明确要求时才进行。
- 保留工作区中用户已有的未提交改动,不覆盖、不清理、不顺带重构。
- 数据库、上传文件、密钥、生产配置和用户数据不视为普通文件;任何写入、迁移、删除或恢复操作必须先获得用户明确确认。
- 只修改用户指定的端和功能范围。发现相邻问题可以说明,但不要擅自扩大修改范围。
## 小改动:快速处理
适用于颜色、字号、间距、圆角、文案、图标、单个组件简单布局等不涉及状态、接口和数据的调整。
1. 确认具体文件和修改范围。
2. 直接完成修改。
3. 执行格式化。
4. 进行一次快速编译或静态检查,二者按实际需要选择。
5. 简要说明改动结果。
不运行完整测试套件,不额外编写计划文档,不提交 Git。
## 中等改动:针对性验证
适用于页面交互、Provider、路由、表单逻辑、接口调用、跨端数据展示、启动脚本和配置修改。
1. 检查相关代码和现有改动。
2. 修改前简要说明方案。
3. 完成代码修改和格式化。
4. 运行静态检查或编译。
5. 只运行与本次改动直接相关的测试。
6. 必要时启动对应页面或接口验证一次。
默认不提交 Git。
## 大改动:完整验证
适用于完整新功能、多页面联动、登录、聊天、蓝牙、AI 核心流程、后端结构调整和大范围重构。
1. 阅读相关代码、文档和交接资料。
2. 与用户确认范围、交互和成功标准。
3. 分步骤实施,并在关键阶段做针对性检查。
4. 完成后运行 Flutter 和后端相关完整测试。
5. 汇总改动、验证结果和遗留问题。
## 高风险改动:确认后执行
适用于数据库迁移或数据修复、大量删除、用户数据、上传文件、签名、生产配置、部署,以及 Git 提交、推送、合并和回滚。
1. 先进行只读检查,确认精确目标和影响。
2. 向用户说明风险、影响范围和恢复方式。
3. 必要时先制作可验证的备份。
4. 获得用户明确确认后再执行。
5. 执行后进行完整验证并报告结果。
用户在具体任务中的直接指令始终优先于本文件。

View File

@@ -16,6 +16,14 @@ QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
QWEN_API_KEY=sk-your-key-here QWEN_API_KEY=sk-your-key-here
QWEN_VISION_MODEL=qwen-vl-max QWEN_VISION_MODEL=qwen-vl-max
# 阿里云百炼实时语音输入Fun-ASR
DASHSCOPE_ASR_API_KEY=sk-your-dashscope-key-here
DASHSCOPE_ASR_WORKSPACE_ID=your-workspace-id
DASHSCOPE_ASR_MODEL=fun-asr-realtime
DASHSCOPE_ASR_MAX_DURATION_SECONDS=60
# 可选;不填时按 Workspace ID 自动生成北京地域专属地址
# DASHSCOPE_ASR_WEBSOCKET_URL=wss://your-workspace-id.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference
# MinIO # MinIO
MINIO_ENDPOINT=localhost:9000 MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin MINIO_ACCESS_KEY=minioadmin

View File

@@ -0,0 +1,24 @@
namespace Health.Application.AI;
public sealed record AiEntryDraft(
Guid Id,
Guid UserId,
Guid ConversationId,
string EntryType,
string Payload,
string MissingFields,
DateTime ExpiresAt);
public interface IAiEntryDraftStore
{
Task<AiEntryDraft?> GetActiveAsync(Guid userId, Guid conversationId, string entryType, CancellationToken ct);
Task<AiEntryDraft> UpsertAsync(
Guid userId,
Guid conversationId,
string entryType,
string payload,
string missingFields,
TimeSpan lifetime,
CancellationToken ct);
Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct);
}

View File

@@ -16,5 +16,5 @@ public interface IAttachmentContextBuilder
/// <summary> /// <summary>
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。 /// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
/// </summary> /// </summary>
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct); Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct);
} }

View File

@@ -10,10 +10,11 @@ public static class DietCommentaryPolicy
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。"; public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
public const string SystemPrompt = """ public const string SystemPrompt = """
App 23 App 23
1222使 Markdown 1222使 Markdown
5
使 S-DIET-01 S-DIET-02
"""; """;
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) => public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>

View File

@@ -0,0 +1,91 @@
namespace Health.Application.AI;
/// <summary>
/// iOS 审核版内置医学来源目录。
/// 这是固定提示词资料,不依赖在线 RAG 检索。
/// </summary>
public static class MedicalCitationKnowledge
{
public const string Prompt = """
PMIDDOI URL
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
https://pubmed.ncbi.nlm.nih.gov/39210715/
[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes2025
https://pubmed.ncbi.nlm.nih.gov/39651981/
[S-HR-01] All About Heart Rate, American Heart Association
https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse
[S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration
https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
https://pubmed.ncbi.nlm.nih.gov/37128940/
[S-DIET-02] Diet and nutrition in cardiovascular disease prevention
https://pubmed.ncbi.nlm.nih.gov/40504596/
[S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
https://pubmed.ncbi.nlm.nih.gov/38059362/
[S-EX-02] Core Components of Cardiac Rehabilitation Programs: 2024 Update
https://pubmed.ncbi.nlm.nih.gov/39315436/
[S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
https://pubmed.ncbi.nlm.nih.gov/39480983/
[S-REPORT-01] Interpreting Normal Values and Reference Ranges for Laboratory Tests
https://pubmed.ncbi.nlm.nih.gov/40268322/
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
使
https://pubmed.ncbi.nlm.nih.gov/29729142/
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
AI
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
使
-
- /尿
-
- /
-
-
-
- 使/
- AI
""";
public const string FixedDietCitation = """
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
https://pubmed.ncbi.nlm.nih.gov/37128940/
""";
public const string FixedReportCitation = """
[S-REPORT-01] Interpreting Normal Values and Reference Ranges for Laboratory Tests
https://pubmed.ncbi.nlm.nih.gov/40268322/
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
https://pubmed.ncbi.nlm.nih.gov/29729142/
""";
public const string EmergencyCitation = """
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
""";
}

View File

@@ -1,24 +1,20 @@
using System.Text; using System.Text;
using Health.Application.HealthArchives; using Health.Application.HealthArchives;
using Health.Application.HealthRecords; using Health.Application.HealthRecords;
using Health.Application.Medications;
namespace Health.Application.AI; namespace Health.Application.AI;
public sealed class PatientContextService( public sealed class PatientContextService(
IHealthArchiveService archives, IHealthArchiveService archives,
IHealthRecordService healthRecords, IHealthRecordService healthRecords) : IPatientContextService
IMedicationService medications) : IPatientContextService
{ {
private readonly IHealthArchiveService _archives = archives; private readonly IHealthArchiveService _archives = archives;
private readonly IHealthRecordService _healthRecords = healthRecords; private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IMedicationService _medications = medications;
public async Task<string> BuildAsync(Guid userId, CancellationToken ct) public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
{ {
var archive = await _archives.GetAsync(userId, ct); var archive = await _archives.GetAsync(userId, ct);
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList(); var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
var activeMedications = await _medications.ListActiveAsync(userId, ct);
var sb = new StringBuilder(); var sb = new StringBuilder();
if (archive != null) if (archive != null)
@@ -36,23 +32,11 @@ public sealed class PatientContextService(
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}"); if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
} }
if (activeMedications.Count > 0)
{
sb.AppendLine("当前用药:");
foreach (var medication in activeMedications.Take(20))
{
var times = medication.TimeOfDay.Count > 0
? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm")))
: "未设置时间";
sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}");
}
}
if (recentRecords.Count > 0) if (recentRecords.Count > 0)
{ {
sb.AppendLine("近期健康数据:"); sb.AppendLine("近期健康数据:");
foreach (var record in recentRecords) foreach (var record in recentRecords)
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})"); sb.AppendLine($" {record.Type}: {RecordValue(record)} ({ToBeijing(record.RecordedAt):MM-dd HH:mm} 北京时间)");
} }
return sb.ToString(); return sb.ToString();
@@ -67,4 +51,15 @@ public sealed class PatientContextService(
"Weight" => $"{record.Value}kg", "Weight" => $"{record.Value}kg",
_ => "—" _ => "—"
}; };
private static DateTime ToBeijing(DateTime value)
{
var utc = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
};
return utc.AddHours(8);
}
} }

View File

@@ -40,7 +40,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate }) .Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
.ToList(); .ToList();
var followUps = snapshot.FollowUps var followUps = snapshot.FollowUps
.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date) .Where(f => BeijingDate(f.ScheduledAt) == date)
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() }) .Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
.ToList(); .ToList();
@@ -67,7 +67,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate })); entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate }));
} }
foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)) foreach (var followUp in snapshot.FollowUps.Where(f => BeijingDate(f.ScheduledAt) == date))
{ {
entries.Add(new CalendarEntry("followup", new entries.Add(new CalendarEntry("followup", new
{ {
@@ -88,5 +88,14 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
&& (medication.StartDate == null || medication.StartDate <= date) && (medication.StartDate == null || medication.StartDate <= date)
&& (medication.EndDate == null || medication.EndDate >= date); && (medication.EndDate == null || medication.EndDate >= date);
private static DateOnly BeijingDate(DateTime value)
{
// EF 从 timestamptz 读取的是 UTC单元测试及旧内存对象可能是 Unspecified。
var beijing = value.Kind == DateTimeKind.Unspecified
? value
: value.ToUniversalTime().AddHours(8);
return DateOnly.FromDateTime(beijing);
}
private sealed record CalendarEntry(string Type, object Value); private sealed record CalendarEntry(string Type, object Value);
} }

View File

@@ -42,19 +42,19 @@ public interface IExerciseService
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct); Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct); Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct); Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct);
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct); Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct); Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct); Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct); Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct);
} }
public interface IExerciseRepository public interface IExerciseRepository
{ {
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct); Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct); Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct);
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct); Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct); Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
Task AddAsync(ExercisePlan plan, CancellationToken ct); Task AddAsync(ExercisePlan plan, CancellationToken ct);
void Delete(ExercisePlan plan); void Delete(ExercisePlan plan);

View File

@@ -33,8 +33,11 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
{ {
plan.Items.Add(new ExercisePlanItem plan.Items.Add(new ExercisePlanItem
{ {
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType, Id = Guid.NewGuid(),
DurationMinutes = duration, IsRestDay = false, ScheduledDate = date,
ExerciseType = exerciseType,
DurationMinutes = duration,
IsRestDay = false,
}); });
} }
await SaveNewAsync(plan, ct); await SaveNewAsync(plan, ct);
@@ -52,10 +55,13 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
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);
return plans.Select(p => new ExercisePlanSummaryDto( return plans.Select(ToSummaryDto).ToList();
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt, }
p.Items.Count, p.Items.Count(i => i.IsCompleted),
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList(); public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct)
{
var plans = await _exercises.ListAllAsync(userId, ct);
return plans.Select(ToSummaryDto).ToList();
} }
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct) public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
@@ -103,12 +109,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
return true; return true;
} }
public async Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct)
{
var plan = await _exercises.GetLatestAsync(userId, ct);
return plan == null ? null : ToDto(plan);
}
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct) private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
{ {
await _exercises.AddAsync(plan, ct); await _exercises.AddAsync(plan, ct);
@@ -117,15 +117,24 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new() private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
{ {
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate, Id = Guid.NewGuid(),
UserId = userId,
StartDate = startDate,
EndDate = endDate,
ReminderTime = reminderTime ?? DefaultReminderTime, ReminderTime = reminderTime ?? DefaultReminderTime,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
}; };
private static ExercisePlanDto ToDto(ExercisePlan plan) => new( private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt, plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList()); plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
private static ExercisePlanSummaryDto ToSummaryDto(ExercisePlan plan) => new(
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt,
plan.Items.Count, plan.Items.Count(i => i.IsCompleted),
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new( private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes, item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
item.IsCompleted, item.CompletedAt, item.IsRestDay); item.IsCompleted, item.CompletedAt, item.IsRestDay);

View File

@@ -188,6 +188,16 @@ public sealed class HealthRecordService(
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "") _ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
}; };
var citation = record.MetricType switch
{
HealthMetricType.BloodPressure => "参考来源:\n[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension\nhttps://pubmed.ncbi.nlm.nih.gov/39210715/",
HealthMetricType.Glucose => "参考来源:\n[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes—2025\nhttps://pubmed.ncbi.nlm.nih.gov/39651981/",
HealthMetricType.HeartRate => "参考来源:\n[S-HR-01] All About Heart Rate, American Heart Association\nhttps://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse",
HealthMetricType.SpO2 => "参考来源:\n[S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration\nhttps://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics",
_ => string.Empty,
};
if (!string.IsNullOrWhiteSpace(citation)) message = $"{message}\n\n{citation}";
var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks; var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}"); var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
await _notifications.EnqueueAsync( await _notifications.EnqueueAsync(

View File

@@ -45,6 +45,21 @@ public sealed record MedicationReminderDto(
string ScheduledTime, string ScheduledTime,
string Status); string Status);
public sealed record AiMedicationDoseDto(
string ScheduledTime,
string Status);
public sealed record AiMedicationPlanDto(
Guid Id,
string Name,
string? Dosage,
string Frequency,
DateOnly? StartDate,
DateOnly? EndDate,
string Phase,
bool ScheduledOnDate,
IReadOnlyList<AiMedicationDoseDto> Doses);
public sealed record MedicationReminderTask( public sealed record MedicationReminderTask(
Guid TaskId, Guid TaskId,
Guid UserId, Guid UserId,
@@ -60,12 +75,10 @@ public interface IMedicationService
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct); Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct); Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct); Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct); Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct); Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct);
Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct); Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct); Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(Guid userId, DateOnly date, CancellationToken ct);
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
} }
public interface IMedicationRepository public interface IMedicationRepository
@@ -74,8 +87,6 @@ public interface IMedicationRepository
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct); Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct); Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct); Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
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);

View File

@@ -96,23 +96,6 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
return true; return true;
} }
public async Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct)
{
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct);
if (existing != null)
{
_medications.DeleteLog(existing);
await _medications.SaveChangesAsync(ct);
return false;
}
await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct);
return true;
}
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct) public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
{ {
var beijingNow = DateTime.UtcNow.AddHours(8); var beijingNow = DateTime.UtcNow.AddHours(8);
@@ -147,7 +130,14 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct) public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
{ {
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null; var medication = await _medications.GetOwnedAsync(userId, medicationId, ct);
if (medication == null) return null;
var today = BeijingToday();
if (!IsActiveOn(medication, today) ||
!GetDosesForToday(medication, today) ||
!medication.TimeOfDay.Contains(scheduledTime))
return false;
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow(); var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct); var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
@@ -168,18 +158,48 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
return true; return true;
} }
public Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) => public async Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(
ListAsync(userId, "active", ct); Guid userId,
DateOnly date,
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct) CancellationToken ct)
{ {
var result = await ConfirmDoseAsync( var medications = await _medications.ListAsync(userId, null, ct);
userId, var (startUtc, endUtc) = GetBeijingDateUtcWindow(date);
medicationId, var logs = await _medications.ListLogsInWindowAsync(userId, startUtc, endUtc, ct);
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), var today = BeijingToday();
MedicationLogStatus.Taken, var now = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
ct);
return result == true; return medications.Select(medication =>
{
var phase = ResolvePhase(medication, date);
var scheduled = phase == "active" && GetDosesForToday(medication, date);
var doses = scheduled
? medication.TimeOfDay.OrderBy(time => time).Select(time =>
{
var log = logs.FirstOrDefault(item =>
item.MedicationId == medication.Id && item.ScheduledTime == time);
var status = log != null
? log.Status.ToString().ToLowerInvariant()
: date < today
? "missed"
: date > today
? "scheduled"
: MedicationScheduleStatus.Resolve(time, now, null);
return new AiMedicationDoseDto(time.ToString("HH:mm"), status);
}).ToList()
: [];
return new AiMedicationPlanDto(
medication.Id,
medication.Name,
medication.Dosage,
medication.Frequency.ToString(),
medication.StartDate,
medication.EndDate,
phase,
scheduled,
doses);
}).ToList();
} }
private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct) private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct)
@@ -217,9 +237,28 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow() private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
{ {
var beijingToday = DateTime.UtcNow.AddHours(8).Date; return GetBeijingDateUtcWindow(BeijingToday());
var todayStartUtc = beijingToday.AddHours(-8); }
return (todayStartUtc, todayStartUtc.AddDays(1));
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingDateUtcWindow(DateOnly date)
{
var startUtc = DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue).AddHours(-8), DateTimeKind.Utc);
return (startUtc, startUtc.AddDays(1));
}
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
private static bool IsActiveOn(Medication medication, DateOnly date) =>
medication.IsActive &&
(!medication.StartDate.HasValue || medication.StartDate.Value <= date) &&
(!medication.EndDate.HasValue || medication.EndDate.Value >= date);
private static string ResolvePhase(Medication medication, DateOnly date)
{
if (!medication.IsActive) return "inactive";
if (medication.StartDate.HasValue && medication.StartDate.Value > date) return "upcoming";
if (medication.EndDate.HasValue && medication.EndDate.Value < date) return "ended";
return "active";
} }
private static bool GetDosesForToday(Medication med, DateOnly today) private static bool GetDosesForToday(Medication med, DateOnly today)

View File

@@ -94,7 +94,7 @@ public sealed class ReportService(
public static ReportDto ToDto(Report report) => new( public static ReportDto ToDto(Report report) => new(
report.Id, report.Id,
report.UserId, report.UserId,
report.FileUrl, $"/api/reports/{report.Id}/file",
report.FileType.ToString(), report.FileType.ToString(),
report.Category.ToString(), report.Category.ToString(),
report.Status.ToString(), report.Status.ToString(),

View File

@@ -0,0 +1,10 @@
using System.Net.WebSockets;
namespace Health.Application.Speech;
public interface IRealtimeSpeechRecognitionProxy
{
bool IsConfigured { get; }
Task ProxyAsync(WebSocket clientSocket, CancellationToken cancellationToken);
}

View File

@@ -14,7 +14,8 @@ public sealed record UserProfileDto(
public sealed record UserProfileUpdateRequest( public sealed record UserProfileUpdateRequest(
string? Name, string? Name,
string? Gender, string? Gender,
DateOnly? BirthDate); DateOnly? BirthDate,
string? AvatarUrl);
public sealed record AccountFileReferences( public sealed record AccountFileReferences(
IReadOnlyList<string> FileUrls, IReadOnlyList<string> FileUrls,

View File

@@ -28,6 +28,7 @@ public sealed class UserService(
if (request.Name != null) user.Name = request.Name; if (request.Name != null) user.Name = request.Name;
if (request.Gender != null) user.Gender = request.Gender; if (request.Gender != null) user.Gender = request.Gender;
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value; if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl;
user.UpdatedAt = DateTime.UtcNow; user.UpdatedAt = DateTime.UtcNow;
await _users.SaveChangesAsync(ct); await _users.SaveChangesAsync(ct);
return true; return true;

View File

@@ -136,10 +136,10 @@ public enum FollowUpStatus
public enum AgentType public enum AgentType
{ {
Default, // 默认对话 Default, // 默认对话
Consultation, // AI 问诊 Consultation, // AI 对话
Health, // 记数据 Health, // 记数据
Diet, // 拍饮食 Diet, // 拍饮食
Medication, // 药管家 Medication, // 药提醒
Report, // 看报告 Report, // 看报告
Exercise, // 运动计划 Exercise, // 运动计划
Unified // 统一入口(自动路由) Unified // 统一入口(自动路由)

View File

@@ -0,0 +1,49 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Health.Infrastructure.AI.AgentHandlers;
internal static partial class AiDateTime
{
public static DateTime BeijingNow => DateTime.UtcNow.AddHours(8);
public static DateOnly BeijingToday => DateOnly.FromDateTime(BeijingNow);
public static (DateTime StartUtc, DateTime EndUtc) BeijingDateWindowUtc(DateOnly date)
{
var startUtc = DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue).AddHours(-8), DateTimeKind.Utc);
return (startUtc, startUtc.AddDays(1));
}
public static DateTime ToUtc(DateTime value) => value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
};
public static string ToBeijingText(DateTime value) =>
ToUtc(value).AddHours(8).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
public static bool TryParseUserDateTime(string? value, out DateTime utc)
{
utc = default;
if (string.IsNullOrWhiteSpace(value)) return false;
if ((value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || OffsetSuffix().IsMatch(value)) &&
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var offsetValue))
{
utc = offsetValue.UtcDateTime;
return true;
}
if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var beijingValue))
return false;
utc = DateTime.SpecifyKind(beijingValue, DateTimeKind.Unspecified).AddHours(-8);
utc = DateTime.SpecifyKind(utc, DateTimeKind.Utc);
return true;
}
[GeneratedRegex(@"[+-]\d{2}:?\d{2}$")]
private static partial Regex OffsetSuffix();
}

View File

@@ -8,12 +8,47 @@ namespace Health.Infrastructure.AI.AgentHandlers;
/// </summary> /// </summary>
public static class CommonAgentHandler public static class CommonAgentHandler
{ {
public static readonly ToolDefinition SearchMedicalKnowledgeTool = new()
{
Function = new()
{
Name = "search_medical_knowledge",
Description = "按需检索医学知识库。仅用于症状分析、健康建议、疾病/药品/检查指标解释等需要医学资料支撑的回答;健康数据录入、用药或运动计划创建/查询/打卡、普通寒暄不得调用。",
Parameters = new
{
type = "object",
properties = new
{
query = new
{
type = "string",
description = "需要检索的医学问题,保留关键症状、疾病、药品或指标名称",
},
},
required = new[] { "query" },
},
},
};
public static readonly ToolDefinition QueryHealthRecordsTool = new() public static readonly ToolDefinition QueryHealthRecordsTool = new()
{ {
Function = new() Function = new()
{ {
Name = "query_health_records", Description = "查询近期健康数据", Name = "query_health_records",
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } } Description = "查询近期健康数据",
Parameters = new
{
type = "object",
properties = new
{
type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight", "all" }, description = "健康指标类型" },
scope = new { type = "string", @enum = new[] { "today", "yesterday", "date", "recent_days", "range" }, description = "日期范围,默认 recent_days" },
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
start_date = new { type = "string", description = "scope=range 时的北京时间开始日期 yyyy-MM-dd" },
end_date = new { type = "string", description = "scope=range 时的北京时间结束日期 yyyy-MM-dd包含" },
days = new { type = "integer", description = "scope=recent_days 时的天数1到365" },
}
}
} }
}; };
@@ -42,10 +77,67 @@ public static class CommonAgentHandler
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct) private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
{ {
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null; var rawType = args.TryGetProperty("type", out var t) ? t.GetString() : null;
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7; if (!TryNormalizeMetricType(rawType, out var metricType))
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct); return new { success = false, message = $"无法识别健康指标类型: {rawType}" };
return new { count = records.Count, records };
var scope = args.TryGetProperty("scope", out var scopeValue)
? scopeValue.GetString()?.ToLowerInvariant()
: "recent_days";
DateTime? startUtc = null;
DateTime? endUtc = null;
int days;
switch (scope)
{
case "today":
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
days = 2;
break;
case "yesterday":
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday.AddDays(-1));
days = 3;
break;
case "date":
if (!TryReadDate(args, "date", out var date))
return new { success = false, message = "查询指定日期时必须提供有效的 date" };
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
days = DaysSince(startUtc.Value);
break;
case "range":
if (!TryReadDate(args, "start_date", out var startDate) ||
!TryReadDate(args, "end_date", out var endDate) || endDate < startDate)
return new { success = false, message = "必须提供有效的 start_date 和 end_date且结束日期不能早于开始日期" };
startUtc = AiDateTime.BeijingDateWindowUtc(startDate).StartUtc;
endUtc = AiDateTime.BeijingDateWindowUtc(endDate).EndUtc;
days = DaysSince(startUtc.Value);
break;
default:
days = args.TryGetProperty("days", out var d) && d.TryGetInt32(out var parsedDays)
? Math.Clamp(parsedDays, 1, 365)
: 7;
scope = "recent_days";
break;
}
var records = await healthRecords.GetRecordsAsync(userId, metricType, days, ct);
var filtered = records
.Where(record => !startUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) >= startUtc.Value)
.Where(record => !endUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) < endUtc.Value)
.Select(record => new
{
record.Id,
record.Type,
record.Systolic,
record.Diastolic,
record.Value,
record.Unit,
record.Source,
record.IsAbnormal,
recorded_at_beijing = AiDateTime.ToBeijingText(record.RecordedAt),
})
.ToList();
return new { success = true, scope, count = filtered.Count, records = filtered };
} }
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct) private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
@@ -54,55 +146,39 @@ public static class CommonAgentHandler
if (archive == null) return new { found = false }; if (archive == null) return new { found = false };
return new return new
{ {
found = true, archive.Diagnosis, archive.SurgeryType, found = true,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries, archive.Diagnosis,
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Surgeries,
archive.Allergies,
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
}; };
} }
public static readonly ToolDefinition ManageArchiveTool = new() private static bool TryNormalizeMetricType(string? value, out string? normalized)
{ {
Function = new() normalized = value?.Trim().ToLowerInvariant() switch
{ {
Name = "manage_archive", Description = "管理用户健康档案", null or "" or "all" or "全部" => null,
Parameters = new "bloodpressure" or "blood_pressure" or "血压" => "BloodPressure",
{ "heartrate" or "heart_rate" or "心率" or "脉搏" => "HeartRate",
type = "object", "glucose" or "blood_glucose" or "血糖" => "Glucose",
properties = new "spo2" or "blood_oxygen" or "血氧" or "血氧饱和度" => "SpO2",
{ "weight" or "体重" => "Weight",
action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" }, _ => "__invalid__",
diagnosis = new { type = "string" }, };
surgery_type = new { type = "string" }, return normalized != "__invalid__";
surgery_date = new { type = "string" },
allergies = new { type = "array", items = new { type = "string" } },
chronic_diseases = new { type = "array", items = new { type = "string" } },
diet_restrictions = new { type = "array", items = new { type = "string" } },
},
required = new[] { "action" }
}
}
};
public static Task<object> ExecuteManageArchive(
IHealthArchiveService archives,
Guid userId,
JsonElement args,
CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
var request = new HealthArchiveUpdateRequest(
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
ReadStringArray(args, "allergies"),
ReadStringArray(args, "diet_restrictions"),
ReadStringArray(args, "chronic_diseases"),
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
return archives.ExecuteAiActionAsync(userId, action, request, ct);
} }
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) => private static bool TryReadDate(JsonElement args, string name, out DateOnly date)
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array {
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList() date = default;
: null; return args.TryGetProperty(name, out var value) && DateOnly.TryParse(value.GetString(), out date);
}
private static int DaysSince(DateTime startUtc) =>
Math.Clamp((int)Math.Ceiling((DateTime.UtcNow - startUtc).TotalDays) + 1, 1, 3650);
} }

View File

@@ -15,7 +15,9 @@ public static class ExerciseAgentHandler
type = "object", type = "object",
properties = new properties = new
{ {
action = new { type = "string", description = "create/query/checkin" }, action = new { type = "string", @enum = new[] { "create", "query", "checkin" }, description = "create/query/checkin" },
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "all_plans" }, description = "查询时必填:今日任务/指定日期任务/当前计划/即将开始/已结束/全部计划" },
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd默认今天" }, start_date = new { type = "string", description = "开始日期 yyyy-MM-dd默认今天" },
duration_days = new { type = "integer", description = "连续运动天数如一周为7天" }, duration_days = new { type = "integer", description = "连续运动天数如一周为7天" },
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" }, exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
@@ -30,20 +32,50 @@ public static class ExerciseAgentHandler
public static List<ToolDefinition> Tools => [ManageExerciseTool]; public static List<ToolDefinition> Tools => [ManageExerciseTool];
public static string? ValidateWriteArguments(JsonElement args)
{
var action = args.TryGetProperty("action", out var actionValue)
? actionValue.GetString()?.ToLowerInvariant()
: null;
if (action != "create") return null;
if (!args.TryGetProperty("exercise_type", out var typeValue) ||
string.IsNullOrWhiteSpace(typeValue.GetString()))
return "创建运动计划前需要确认运动项目";
if (!args.TryGetProperty("duration_minutes", out var minutesValue) ||
!minutesValue.TryGetInt32(out var minutes) || minutes is <= 0 or > 300)
return "创建运动计划前需要确认合理的单次运动时长";
if (!args.TryGetProperty("duration_days", out var daysValue) ||
!daysValue.TryGetInt32(out var days) || days is <= 0 or > 366)
return "创建运动计划前需要确认计划持续天数";
if (!args.TryGetProperty("start_date", out var startValue) ||
!DateOnly.TryParse(startValue.GetString(), out _))
return "创建运动计划前需要确认开始日期";
if (!args.TryGetProperty("reminder_time", out var reminderValue) ||
!TimeOnly.TryParse(reminderValue.GetString(), out _))
return "创建运动计划前需要确认具体提醒时间";
return null;
}
public static async Task<object> Execute( public static async Task<object> Execute(
string toolName, string toolName,
JsonElement args, JsonElement args,
AppDbContext db,
Guid userId, Guid userId,
IExerciseService? exercises = null, IExerciseService exercises,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (toolName != "manage_exercise" || exercises == null) if (toolName != "manage_exercise")
return new { success = false, message = $"未知工具: {toolName}" }; return new { success = false, message = $"未知工具: {toolName}" };
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query"; var action = args.TryGetProperty("action", out var actionValue)
? actionValue.GetString()?.ToLowerInvariant()
: null;
if (action == "create") if (action == "create")
{ {
var validationError = ValidateWriteArguments(args);
if (validationError != null)
return new { success = false, message = validationError };
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart) var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
? parsedStart ? parsedStart
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
@@ -67,17 +99,111 @@ public static class ExerciseAgentHandler
return success ? new { success = true } : new { success = false, message = "条目不存在" }; return success ? new { success = true } : new { success = false, message = "条目不存在" };
} }
var plan = await exercises.GetLatestAsync(userId, ct); if (action != "query")
return plan == null return new { success = false, message = $"未知操作: {action}" };
? new { found = false }
: new var scope = args.TryGetProperty("scope", out var scopeValue)
? scopeValue.GetString()?.ToLowerInvariant()
: null;
if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "all_plans"))
return new
{ {
found = true, success = false,
plan_id = plan.Id, message = "查询运动计划时必须明确 scopetoday、scheduled_date、current_plans、upcoming_plans、ended_plans 或 all_plans",
plan.StartDate,
plan.EndDate,
plan.ReminderTime,
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
}; };
if (scope == "scheduled_date" &&
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
return new { success = false, message = "查询指定日期的运动安排时必须提供有效的 date" };
var queryDate = scope == "scheduled_date"
? DateOnly.Parse(args.GetProperty("date").GetString()!)
: AiDateTime.BeijingToday;
var isDateScope = scope is "today" or "scheduled_date";
var plans = await exercises.ListAllAsync(userId, ct);
var overview = plans.Select(plan => new
{
plan_id = plan.Id,
plan.StartDate,
plan.EndDate,
phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active",
plan.ReminderTime,
total_items = plan.Items.Count,
completed_items = plan.Items.Count(item => item.IsCompleted),
returned_items = isDateScope
? plan.Items.Count(item => item.ScheduledDate == queryDate)
: Math.Min(plan.Items.Count, 14),
items_truncated = !isDateScope && plan.Items.Count > 14,
items = plan.Items
.Where(item => !isDateScope || item.ScheduledDate == queryDate)
.OrderBy(item => item.ScheduledDate)
.Take(isDateScope ? int.MaxValue : 14)
.Select(item => new
{
item.Id,
item.ScheduledDate,
item.ExerciseType,
item.DurationMinutes,
item.IsCompleted,
item.IsRestDay,
completed_at_beijing = item.CompletedAt.HasValue ? AiDateTime.ToBeijingText(item.CompletedAt.Value) : null,
})
.ToList(),
});
var filtered = scope switch
{
"all_plans" => overview,
"current_plans" => overview.Where(plan => plan.phase == "active"),
"upcoming_plans" => overview
.Where(plan => plan.phase == "upcoming")
.OrderBy(plan => plan.StartDate),
"ended_plans" => overview
.Where(plan => plan.phase == "ended")
.OrderByDescending(plan => plan.EndDate),
_ => overview.Where(plan => plan.phase == "active" && plan.items.Count > 0),
};
var result = filtered.ToList();
var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList();
var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault();
string authoritativeAnswer;
if (result.Count == 0)
{
authoritativeAnswer = isDateScope
? $"没有查到 {queryDate:yyyy-MM-dd} 的运动安排。"
: "当前没有查到符合条件的运动计划。";
}
else if (isDateScope)
{
var taskLines = scheduledTasks.Select(item =>
$"- {item.ExerciseType}{item.DurationMinutes} 分钟,{(item.IsCompleted ? "" : "")}");
authoritativeAnswer =
$"{queryDate:yyyy-MM-dd} 的运动安排:\n{string.Join('\n', taskLines)}";
}
else
{
var planLines = result.Select(plan =>
{
var representative = plan.items.FirstOrDefault(item => !item.IsRestDay);
var exerciseType = representative?.ExerciseType ?? "运动";
var durationMinutes = representative?.DurationMinutes ?? 0;
return $"- {exerciseType}{plan.StartDate:yyyy-MM-dd} 至 {plan.EndDate:yyyy-MM-dd}"
+ $"每天 {durationMinutes} 分钟,提醒时间 {plan.ReminderTime:HH:mm}";
});
authoritativeAnswer =
$"查到 {result.Count} 个符合条件的运动计划:\n{string.Join('\n', planLines)}";
}
return new
{
found = result.Count > 0,
query_date = queryDate.ToString("yyyy-MM-dd"),
scope,
count = result.Count,
all_tasks_completed = isDateScope
? scheduledTasks.Count > 0 && scheduledTasks.All(item => item.IsCompleted)
: (bool?)null,
has_upcoming_plans = nextPlan != null,
next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"),
days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber,
plans = result,
authoritative_answer = authoritativeAnswer,
};
} }
} }

View File

@@ -7,35 +7,162 @@ namespace Health.Infrastructure.AI.AgentHandlers;
/// </summary> /// </summary>
public static class HealthDataAgentHandler public static class HealthDataAgentHandler
{ {
private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"];
public static readonly ToolDefinition RecordHealthDataTool = new() public static readonly ToolDefinition RecordHealthDataTool = new()
{ {
Function = new() Function = new()
{ {
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)", Name = "record_health_data",
Parameters = new { type = "object", properties = new { type = new { type = "string" }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" } }, required = new[] { "type" } } Description = "记录健康数据(血压/心率/血糖/血氧/体重。用户说“脉搏”时按“心率”处理type 使用 heart_rate数值写入 heart_rate。",
Parameters = new { type = "object", properties = new { type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight" } }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" }, recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" } }, required = new[] { "type" } }
} }
}; };
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool]; public static readonly ToolDefinition RecordHealthDataBatchTool = new()
{
Function = new()
{
Name = "record_health_data_batch",
Description = "提交当前这一轮要录入的全部健康指标。一次只调用一次;即使只有一个指标也放入 metrics。血压可先只提供已知的一侧脉搏按心率 heart_rate 处理。后端会整批校验、追问或生成一张确认卡。",
Parameters = new
{
type = "object",
properties = new
{
metrics = new
{
type = "array",
minItems = 1,
description = "当前录入批次的全部指标,不得遗漏;不要带入普通历史对话或上一张已生成卡片的数据",
items = new
{
type = "object",
properties = new
{
type = new { type = "string", @enum = MetricTypes },
systolic = new { type = "integer" },
diastolic = new { type = "integer" },
heart_rate = new { type = "number" },
glucose = new { type = "number" },
spo2 = new { type = "number" },
weight = new { type = "number" },
recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" },
},
required = new[] { "type" },
},
},
},
required = new[] { "metrics" },
},
},
};
public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords) public static List<ToolDefinition> Tools => [RecordHealthDataBatchTool, CommonAgentHandler.QueryHealthRecordsTool];
public static string? ValidateWriteArguments(JsonElement args)
{
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
return type switch
{
"blood_pressure" => ValidateBloodPressure(args),
"heart_rate" => ValidateValue(args, "heart_rate", "心率", 1m, 250m),
"glucose" => ValidateValue(args, "glucose", "血糖", 0.1m, 50m),
"spo2" => ValidateValue(args, "spo2", "血氧", 1m, 100m),
"weight" => ValidateValue(args, "weight", "体重", 1m, 500m),
_ => "缺少有效的健康指标类型",
};
}
public static string? GetUrgentWarning(JsonElement args)
{
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
return type switch
{
"blood_pressure"
when args.TryGetProperty("systolic", out var systolic) &&
systolic.TryGetInt32(out var systolicValue) &&
args.TryGetProperty("diastolic", out var diastolic) &&
diastolic.TryGetInt32(out var diastolicValue) &&
(systolicValue >= 180 || diastolicValue >= 120)
=> "这次血压数值已达到明显危险范围,请不要继续等待录入,尽快联系医生或前往急诊评估。",
"heart_rate" when IsValueOutside(args, "heart_rate", 45m, 130m)
=> "这次心率数值明显异常,请尽快联系医生评估;如伴有胸痛、晕厥或呼吸困难,请立即就医。",
"glucose" when IsValueOutside(args, "glucose", 3.0m, 16.7m)
=> "这次血糖数值属于需要尽快处理的危险范围,请立即联系医生或前往医疗机构评估。",
"spo2"
when args.TryGetProperty("spo2", out var spo2) &&
spo2.TryGetDecimal(out var spo2Value) &&
spo2Value <= 90m
=> "这次血氧数值明显偏低,请尽快就医评估;如有呼吸困难、胸闷或意识异常,请立即拨打当地急救电话。",
_ => null,
};
}
public static IReadOnlyList<JsonElement> GetBatchMetrics(JsonElement args) =>
args.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Array
? metrics.EnumerateArray().Select(metric => metric.Clone()).ToList()
: [];
public static Dictionary<string, object?> CreatePreview(JsonElement args)
{
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() ?? "" : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null)
return [];
var request = new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
null);
return new Dictionary<string, object?>
{
["type"] = metricType.Value.ToString(),
["value"] = metricType == HealthMetricType.BloodPressure
? $"{systolic}/{diastolic}"
: value?.ToString() ?? "",
["unit"] = unit,
["abnormal"] = HealthRecordRules.CheckAbnormal(request),
};
}
public static async Task<object> Execute(
string toolName,
JsonElement args,
Guid userId,
IHealthRecordService healthRecords,
CancellationToken ct)
{ {
return toolName switch return toolName switch
{ {
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args), "record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args, ct),
"record_health_data_batch" => await ExecuteRecordHealthDataBatch(healthRecords, userId, args, ct),
_ => new { success = false, message = $"未知工具: {toolName}" } _ => new { success = false, message = $"未知工具: {toolName}" }
}; };
} }
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args) private static async Task<object> ExecuteRecordHealthData(
IHealthRecordService healthRecords,
Guid userId,
JsonElement args,
CancellationToken ct)
{ {
var validationError = ValidateWriteArguments(args);
if (validationError != null)
return new { success = false, message = validationError };
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : ""; var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args); var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null) if (metricType == null)
return new { success = false, message = $"未知指标类型: {type}" }; return new { success = false, message = $"未知指标类型: {type}" };
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) var recordedAt = args.TryGetProperty("recorded_at", out var ra) &&
? dt AiDateTime.TryParseUserDateTime(ra.GetString(), out var parsedRecordedAt)
? parsedRecordedAt
: DateTime.UtcNow; : DateTime.UtcNow;
var request = new HealthRecordUpsertRequest( var request = new HealthRecordUpsertRequest(
@@ -47,7 +174,7 @@ public static class HealthDataAgentHandler
HealthRecordSource.AiEntry, HealthRecordSource.AiEntry,
recordedAt); recordedAt);
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None); var id = await healthRecords.CreateAsync(userId, request, ct);
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? ""; var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
return new return new
{ {
@@ -60,6 +187,75 @@ public static class HealthDataAgentHandler
}; };
} }
private static async Task<object> ExecuteRecordHealthDataBatch(
IHealthRecordService healthRecords,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var metrics = GetBatchMetrics(args);
if (metrics.Count == 0)
return new { success = false, message = "当前录入批次没有健康指标" };
var requests = new List<HealthRecordUpsertRequest>(metrics.Count);
var previews = new List<Dictionary<string, object?>>(metrics.Count);
foreach (var metric in metrics)
{
var validationError = ValidateWriteArguments(metric);
if (validationError != null)
return new { success = false, message = validationError };
var type = metric.GetProperty("type").GetString()!;
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, metric);
if (metricType == null)
return new { success = false, message = $"未知指标类型: {type}" };
var recordedAt = metric.TryGetProperty("recorded_at", out var recordedAtValue) &&
AiDateTime.TryParseUserDateTime(recordedAtValue.GetString(), out var parsedRecordedAt)
? parsedRecordedAt
: DateTime.UtcNow;
requests.Add(new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
recordedAt));
previews.Add(CreatePreview(metric));
}
var ids = await healthRecords.CreateManyAsync(userId, requests, ct);
return new
{
success = true,
record_ids = ids,
items = previews,
};
}
private static string? ValidateBloodPressure(JsonElement args)
{
if (!args.TryGetProperty("systolic", out var systolicValue) || !systolicValue.TryGetInt32(out var systolic) ||
!args.TryGetProperty("diastolic", out var diastolicValue) || !diastolicValue.TryGetInt32(out var diastolic))
return "录入血压前需要同时提供收缩压和舒张压";
if (systolic is <= 0 or > 260 || diastolic is <= 0 or > 160 || systolic <= diastolic)
return "血压数值明显无效,请核对收缩压和舒张压后重新提供";
return null;
}
private static string? ValidateValue(JsonElement args, string property, string label, decimal minimum, decimal maximum)
{
if (!args.TryGetProperty(property, out var valueElement) || !valueElement.TryGetDecimal(out var value))
return $"录入{label}前需要提供数值";
return value < minimum || value > maximum ? $"{label}数值明显无效,请重新提供" : null;
}
private static bool IsValueOutside(JsonElement args, string property, decimal minimum, decimal maximum) =>
args.TryGetProperty(property, out var valueElement) &&
valueElement.TryGetDecimal(out var value) &&
(value <= minimum || value >= maximum);
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args) private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
{ {
return type switch return type switch

View File

@@ -3,7 +3,7 @@ using Health.Application.Medications;
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
/// 药管家 Agent 工具处理器。 /// 药提醒 Agent 工具处理器。
/// </summary> /// </summary>
public static class MedicationAgentHandler public static class MedicationAgentHandler
{ {
@@ -12,19 +12,23 @@ public static class MedicationAgentHandler
Function = new() Function = new()
{ {
Name = "manage_medication", Name = "manage_medication",
Description = "用药管理(创建/查询/确认服药)", Description = "用药管理(创建计划、按日期查询计划与逐顿服药状态、确认某一顿服药)",
Parameters = new Parameters = new
{ {
type = "object", type = "object",
properties = new properties = new
{ {
action = new { type = "string", description = "create/query/confirm" }, action = new { type = "string", @enum = new[] { "create", "query", "confirm" }, description = "create/query/confirm" },
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "inactive_plans", "all_plans" }, description = "查询时必填:今日安排/指定日期安排/当前计划/即将开始/自然结束/已停用/全部计划" },
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
medication_id = new { type = "string", description = "药品 ID确认服药时使用" }, medication_id = new { type = "string", description = "药品 ID确认服药时使用" },
scheduled_time = new { type = "string", description = "要确认的计划服药时间 HH:mm" },
name = new { type = "string", description = "药品名称" }, name = new { type = "string", description = "药品名称" },
dosage = new { type = "string", description = "剂量,如 100mg" }, dosage = new { type = "string", description = "剂量,如 100mg" },
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" }, frequency = new { type = "string", @enum = new[] { "Daily", "TwiceDaily", "ThreeTimesDaily", "EveryOtherDay", "Weekly", "AsNeeded" }, description = "服药频率" },
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" }, time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
duration_days = new { type = "integer", description = "服用天数" }, duration_days = new { type = "integer", description = "服用天数" },
long_term = new { type = "boolean", description = "是否明确为长期服用;与 duration_days 二选一" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" }, start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
}, },
required = new[] { "action" } required = new[] { "action" }
@@ -49,11 +53,11 @@ public static class MedicationAgentHandler
private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct) private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{ {
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; var action = args.TryGetProperty("action", out var a) ? a.GetString()?.ToLowerInvariant() : null;
return action switch return action switch
{ {
"create" => await CreateMedication(medications, userId, args, ct), "create" => await CreateMedication(medications, userId, args, ct),
"query" => await QueryMedications(medications, userId, ct), "query" => await QueryMedications(medications, userId, args, ct),
"confirm" => await ConfirmMedication(medications, userId, args, ct), "confirm" => await ConfirmMedication(medications, userId, args, ct),
_ => new { success = false, message = $"未知操作: {action}" } _ => new { success = false, message = $"未知操作: {action}" }
}; };
@@ -61,14 +65,19 @@ public static class MedicationAgentHandler
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct) private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{ {
var validationError = ValidateWriteArguments(args);
if (validationError != null)
return new { success = false, message = validationError };
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : ""; var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null; var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequency = ReadFrequency(args); var frequency = ReadFrequency(args);
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 longTerm = args.TryGetProperty("long_term", out var longTermValue) && longTermValue.ValueKind == JsonValueKind.True;
var startDate = ReadStartDate(args); var startDate = ReadStartDate(args);
// 结束日为包含式IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1) // 结束日为包含式IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
var endDate = durationDays > 0 ? startDate.AddDays(durationDays - 1) : (DateOnly?)null; DateOnly? endDate = longTerm ? null : startDate.AddDays(durationDays - 1);
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest( var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
name, name,
@@ -91,27 +100,146 @@ public static class MedicationAgentHandler
time = timeLabels, time = timeLabels,
start_date = startDate.ToString("yyyy-MM-dd"), start_date = startDate.ToString("yyyy-MM-dd"),
duration_days = durationDays, duration_days = durationDays,
long_term = longTerm,
}; };
} }
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct) private static async Task<object> QueryMedications(
IMedicationService medications,
Guid userId,
JsonElement args,
CancellationToken ct)
{ {
var meds = await medications.ListActiveAsync(userId, ct); var scope = args.TryGetProperty("scope", out var scopeValue)
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) }; ? scopeValue.GetString()?.ToLowerInvariant()
: null;
if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "inactive_plans" or "all_plans"))
return new
{
success = false,
message = "查询用药时必须明确 scopetoday、scheduled_date、current_plans、upcoming_plans、ended_plans、inactive_plans 或 all_plans",
};
if (scope == "scheduled_date" &&
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
return new { success = false, message = "查询指定日期的用药安排时必须提供有效的 date" };
var date = scope == "scheduled_date"
? DateOnly.Parse(args.GetProperty("date").GetString()!)
: AiDateTime.BeijingToday;
var isDateScope = scope is "today" or "scheduled_date";
var overview = await medications.GetAiOverviewAsync(userId, date, ct);
var plans = scope switch
{
"all_plans" => overview.ToList(),
"current_plans" => overview.Where(plan => plan.Phase == "active").ToList(),
"upcoming_plans" => overview
.Where(plan => plan.Phase == "upcoming")
.OrderBy(plan => plan.StartDate)
.ToList(),
"ended_plans" => overview
.Where(plan => plan.Phase == "ended")
.OrderByDescending(plan => plan.EndDate)
.ToList(),
"inactive_plans" => overview
.Where(plan => plan.Phase == "inactive")
.OrderByDescending(plan => plan.EndDate)
.ToList(),
_ => overview.Where(plan => plan.ScheduledOnDate).ToList(),
};
var doseStatuses = plans.SelectMany(plan => plan.Doses).ToList();
var nextPlan = overview
.Where(plan => plan.Phase == "upcoming" && plan.StartDate.HasValue)
.OrderBy(plan => plan.StartDate)
.FirstOrDefault();
return new
{
query_date = date.ToString("yyyy-MM-dd"),
scope,
count = plans.Count,
all_doses_completed = isDateScope
? doseStatuses.Count > 0 && doseStatuses.All(dose => dose.Status == "taken")
: (bool?)null,
has_upcoming_plans = nextPlan != null,
next_start_date = nextPlan?.StartDate?.ToString("yyyy-MM-dd"),
days_until_next_start = nextPlan?.StartDate is DateOnly nextStart
? nextStart.DayNumber - date.DayNumber
: (int?)null,
plans,
};
} }
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct) private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{ {
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty; var validationError = ValidateWriteArguments(args);
var success = await medications.ConfirmNowAsync(userId, medId, ct); if (validationError != null)
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" }; return new { success = false, message = validationError };
var medId = args.TryGetProperty("medication_id", out var mid) && mid.TryGetGuid(out var parsedMedicationId)
? parsedMedicationId
: Guid.Empty;
if (!args.TryGetProperty("scheduled_time", out var timeValue) ||
!TimeOnly.TryParse(timeValue.GetString(), out var scheduledTime))
return new { success = false, message = "缺少要确认的计划服药时间" };
var success = await medications.ConfirmDoseAsync(
userId,
medId,
scheduledTime,
MedicationLogStatus.Taken,
ct);
return success == true
? new { success = true, medication_id = medId, scheduled_time = scheduledTime.ToString("HH:mm") }
: new { success = false, message = success == null ? "药品不存在" : "该时间不属于今天的服药安排,或这一顿已经记录" };
}
public static string? ValidateWriteArguments(JsonElement args)
{
var action = args.TryGetProperty("action", out var actionValue)
? actionValue.GetString()?.ToLowerInvariant()
: null;
if (action == "confirm")
{
if (!args.TryGetProperty("medication_id", out var medicationId) || !medicationId.TryGetGuid(out _))
return "确认服药时缺少有效的药品 ID";
if (!args.TryGetProperty("scheduled_time", out var scheduledTime) || !TimeOnly.TryParse(scheduledTime.GetString(), out _))
return "确认服药时缺少具体的计划服药时间";
return null;
}
if (action != "create") return null;
if (!args.TryGetProperty("name", out var name) || string.IsNullOrWhiteSpace(name.GetString()))
return "创建用药计划前需要确认药品名称";
if (!args.TryGetProperty("dosage", out var dosage) || string.IsNullOrWhiteSpace(dosage.GetString()))
return "创建用药计划前需要确认每次剂量";
if (!args.TryGetProperty("frequency", out var frequencyValue) ||
!Enum.TryParse<MedicationFrequency>(frequencyValue.GetString(), ignoreCase: true, out _))
return "创建用药计划前需要确认服药频率";
if (!args.TryGetProperty("time_of_day", out var times) || times.ValueKind != JsonValueKind.Array)
return "创建用药计划前需要确认具体服药时间";
var timeValues = times.EnumerateArray().ToList();
if (timeValues.Count == 0 || timeValues.Any(value => !TimeOnly.TryParse(value.GetString(), out _)))
return "创建用药计划前需要提供有效的具体服药时间";
if (!args.TryGetProperty("start_date", out var startDate) || !DateOnly.TryParse(startDate.GetString(), out _))
return "创建用药计划前需要确认开始日期";
var durationDays = 0;
var hasDuration = args.TryGetProperty("duration_days", out var duration) &&
duration.TryGetInt32(out durationDays) && durationDays > 0;
var isLongTerm = args.TryGetProperty("long_term", out var longTerm) && longTerm.ValueKind == JsonValueKind.True;
if (hasDuration && durationDays > 3650)
return "服用天数不能超过3650天长期服用请明确设置 long_term=true";
if (hasDuration && isLongTerm)
return "服用天数和长期服用不能同时设置";
if (!hasDuration && !isLongTerm)
return "创建用药计划前需要确认服用天数,或明确说明长期服用";
return null;
} }
private static MedicationFrequency ReadFrequency(JsonElement args) private static MedicationFrequency ReadFrequency(JsonElement args)
{ {
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily"; var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily; return Enum.TryParse<MedicationFrequency>(frequencyStr, ignoreCase: true, out var frequency) ? frequency : MedicationFrequency.Daily;
} }
private static DateOnly ReadStartDate(JsonElement args) private static DateOnly ReadStartDate(JsonElement args)

View File

@@ -0,0 +1,257 @@
using Health.Application.Diets;
using Health.Application.Notifications;
using Health.Application.Reports;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 统一助手使用的患者本人业务数据只读工具。
/// </summary>
public static class PatientReadAgentHandler
{
public static readonly ToolDefinition QueryDietRecordsTool = new()
{
Function = new()
{
Name = "query_diet_records",
Description = "查询当前用户指定北京时间日期的饮食记录和总热量",
Parameters = new
{
type = "object",
properties = new
{
date = new { type = "string", description = "北京时间日期 yyyy-MM-dd默认今天" },
meal_type = new { type = "string", @enum = new[] { "Breakfast", "Lunch", "Dinner", "Snack" }, description = "可选餐次" },
}
}
}
};
public static readonly ToolDefinition QueryFollowUpsTool = new()
{
Function = new()
{
Name = "query_followups",
Description = "查询当前用户的复查随访安排",
Parameters = new
{
type = "object",
properties = new
{
scope = new { type = "string", @enum = new[] { "next", "upcoming", "completed", "date", "all" }, description = "查询时必填:下一次/未来安排/已完成/指定日期/全部" },
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
limit = new { type = "integer", description = "最多返回条数1到20" },
}
}
}
};
public static readonly ToolDefinition QueryReportsTool = new()
{
Function = new()
{
Name = "query_reports",
Description = "查询当前用户已经保存的检查报告状态、AI摘要和医生审核结论不读取其他用户报告",
Parameters = new
{
type = "object",
properties = new
{
limit = new { type = "integer", description = "最多返回条数1到10默认5" },
status = new { type = "string", description = "可选报告状态" },
}
}
}
};
public static readonly ToolDefinition QueryNotificationsTool = new()
{
Function = new()
{
Name = "query_notifications",
Description = "查询当前用户的站内通知",
Parameters = new
{
type = "object",
properties = new
{
scope = new { type = "string", @enum = new[] { "unread", "today", "recent" }, description = "默认 unread" },
limit = new { type = "integer", description = "最多返回条数1到20" },
}
}
}
};
public static async Task<object> QueryDietAsync(
IDietService diets,
Guid userId,
JsonElement args,
CancellationToken ct)
{
if (args.TryGetProperty("date", out var suppliedDate) && !DateOnly.TryParse(suppliedDate.GetString(), out _))
return new { success = false, message = "查询饮食记录时提供了无效的 date" };
var date = args.TryGetProperty("date", out var dateValue) && DateOnly.TryParse(dateValue.GetString(), out var parsedDate)
? parsedDate
: AiDateTime.BeijingToday;
var mealType = args.TryGetProperty("meal_type", out var mealValue) ? mealValue.GetString() : null;
var records = await diets.ListAsync(userId, date.ToString("yyyy-MM-dd"), mealType, ct);
return new
{
query_date = date.ToString("yyyy-MM-dd"),
count = records.Count,
total_calories = records.Sum(record => record.TotalCalories ?? 0),
records = records.Select(record => new
{
record.Id,
record.MealType,
record.TotalCalories,
record.HealthScore,
record.RecordedAt,
record.FoodItems,
}),
};
}
public static async Task<object> QueryFollowUpsAsync(
AppDbContext db,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var scope = args.TryGetProperty("scope", out var scopeValue)
? scopeValue.GetString()?.ToLowerInvariant()
: null;
if (scope is not ("next" or "upcoming" or "completed" or "date" or "all"))
return new
{
success = false,
message = "查询复查安排时必须明确 scopenext、upcoming、completed、date 或 all",
};
var limit = ReadLimit(args, 10, 20);
var nowUtc = DateTime.UtcNow;
var query = db.FollowUps.AsNoTracking().Where(item => item.UserId == userId);
if (scope is "next" or "upcoming")
query = query.Where(item => item.Status == FollowUpStatus.Upcoming && item.ScheduledAt >= nowUtc);
else if (scope == "completed")
query = query.Where(item => item.Status == FollowUpStatus.Completed);
else if (scope == "date")
{
if (!args.TryGetProperty("date", out var dateValue) || !DateOnly.TryParse(dateValue.GetString(), out var date))
return new { success = false, message = "按日期查询复查时必须提供有效的 date" };
var (startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
query = query.Where(item => item.ScheduledAt >= startUtc && item.ScheduledAt < endUtc);
}
var take = scope == "next" ? 1 : limit;
var orderedQuery = scope is "all" or "completed"
? query.OrderByDescending(item => item.ScheduledAt)
: query.OrderBy(item => item.ScheduledAt);
var matchingCount = await query.CountAsync(ct);
var items = await orderedQuery
.Take(take)
.Select(item => new
{
item.Id,
item.Title,
item.DoctorName,
item.Department,
item.ScheduledAt,
item.Notes,
item.Status,
})
.ToListAsync(ct);
return new
{
success = true,
scope,
count = items.Count,
matching_count = matchingCount,
results_truncated = matchingCount > items.Count,
followups = items.Select(item => new
{
item.Id,
item.Title,
item.DoctorName,
item.Department,
scheduled_at_beijing = AiDateTime.ToBeijingText(item.ScheduledAt),
item.Notes,
status = item.Status.ToString(),
}),
};
}
public static async Task<object> QueryReportsAsync(
IReportService reports,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var limit = ReadLimit(args, 5, 10);
var status = args.TryGetProperty("status", out var statusValue) ? statusValue.GetString() : null;
var records = await reports.GetReportsAsync(userId, ct);
var filtered = records
.Where(report => string.IsNullOrWhiteSpace(status) || report.Status.Equals(status, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(report => report.CreatedAt)
.Take(limit)
.Select(report => new
{
report.Id,
report.Category,
report.Status,
report.AiStatus,
report.ReviewStatus,
report.Severity,
ai_summary = Truncate(report.AiSummary, 1800),
doctor_comment = Truncate(report.DoctorComment, 1000),
doctor_recommendation = Truncate(report.DoctorRecommendation, 1000),
report.DoctorName,
reviewed_at_beijing = report.ReviewedAt.HasValue ? AiDateTime.ToBeijingText(report.ReviewedAt.Value) : null,
created_at_beijing = AiDateTime.ToBeijingText(report.CreatedAt),
})
.ToList();
return new { count = filtered.Count, reports = filtered };
}
public static async Task<object> QueryNotificationsAsync(
IInAppNotificationService notifications,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var scope = args.TryGetProperty("scope", out var scopeValue)
? scopeValue.GetString()?.ToLowerInvariant() ?? "unread"
: "unread";
if (scope is not ("unread" or "today" or "recent")) scope = "unread";
var limit = ReadLimit(args, 10, 20);
var records = scope == "unread"
? await notifications.GetPendingAsync(userId, ct)
: await notifications.GetHistoryAsync(userId, ct);
var (todayStartUtc, todayEndUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
var filtered = records
.Where(item => scope != "today" ||
(AiDateTime.ToUtc(item.CreatedAt) >= todayStartUtc && AiDateTime.ToUtc(item.CreatedAt) < todayEndUtc))
.OrderByDescending(item => item.CreatedAt)
.Take(limit)
.Select(item => new
{
item.Id,
item.Type,
item.Title,
item.Message,
item.Severity,
item.IsRead,
created_at_beijing = AiDateTime.ToBeijingText(item.CreatedAt),
})
.ToList();
return new { scope, count = filtered.Count, notifications = filtered };
}
private static int ReadLimit(JsonElement args, int defaultValue, int max) =>
args.TryGetProperty("limit", out var value) && value.TryGetInt32(out var parsed)
? Math.Clamp(parsed, 1, max)
: defaultValue;
private static string? Truncate(string? value, int maxLength) =>
string.IsNullOrWhiteSpace(value) || value.Length <= maxLength ? value : value[..maxLength] + "…";
}

View File

@@ -19,13 +19,13 @@ public static class ReportAgentHandler
return new { success = false, message = "缺少报告图片" }; return new { success = false, message = "缺少报告图片" };
var prompt = """ var prompt = """
JSON格式返回 JSON格式返回
{ {
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)", "reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
"indicators": [ "indicators": [
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"} {"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
], ],
"summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生", "summary": "列出异常指标及其数值和参考范围,客观说明超出范围,不解释可能原因,不结合用户档案做关联分析,建议咨询医生解读",
"needsDoctorReview": true/false "needsDoctorReview": true/false
} }
JSON JSON

View File

@@ -1,9 +1,12 @@
using System.Text.Json; using System.Text.Json;
using Health.Application.AI; using Health.Application.AI;
using Health.Application.Exercises; using Health.Application.Exercises;
using Health.Application.Diets;
using Health.Application.HealthArchives; using Health.Application.HealthArchives;
using Health.Application.HealthRecords; using Health.Application.HealthRecords;
using Health.Application.Medications; using Health.Application.Medications;
using Health.Application.Notifications;
using Health.Application.Reports;
using Health.Infrastructure.AI.AgentHandlers; using Health.Infrastructure.AI.AgentHandlers;
namespace Health.Infrastructure.AI; namespace Health.Infrastructure.AI;
@@ -15,6 +18,9 @@ public sealed class AiToolExecutionService(
IHealthRecordService healthRecords, IHealthRecordService healthRecords,
IExerciseService exercises, IExerciseService exercises,
IMedicationService medications, IMedicationService medications,
IDietService diets,
IReportService reports,
IInAppNotificationService notifications,
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
{ {
private readonly AppDbContext _db = db; private readonly AppDbContext _db = db;
@@ -23,6 +29,9 @@ public sealed class AiToolExecutionService(
private readonly IHealthRecordService _healthRecords = healthRecords; private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IExerciseService _exercises = exercises; private readonly IExerciseService _exercises = exercises;
private readonly IMedicationService _medications = medications; private readonly IMedicationService _medications = medications;
private readonly IDietService _diets = diets;
private readonly IReportService _reports = reports;
private readonly IInAppNotificationService _notifications = notifications;
private readonly IAiWriteConfirmationStore _confirmations = confirmations; private readonly IAiWriteConfirmationStore _confirmations = confirmations;
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct) public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
@@ -31,13 +40,17 @@ 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, userId, _healthRecords), "record_health_data" or "record_health_data_batch"
=> HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords, ct),
"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, userId, _medications, ct), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient), "analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct), "manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, userId, _exercises, ct),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct), "query_diet_records" => PatientReadAgentHandler.QueryDietAsync(_diets, userId, root, ct),
"query_followups" => PatientReadAgentHandler.QueryFollowUpsAsync(_db, userId, root, ct),
"query_reports" => PatientReadAgentHandler.QueryReportsAsync(_reports, userId, root, ct),
"query_notifications" => PatientReadAgentHandler.QueryNotificationsAsync(_notifications, userId, root, ct),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }), _ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
}); });
} }

View File

@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Health.Application.AI; using Health.Application.AI;
using Health.Infrastructure.Files;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using UglyToad.PdfPig; using UglyToad.PdfPig;
@@ -19,19 +20,19 @@ public sealed class AttachmentContextBuilder(
private readonly VisionClient _vision = vision; private readonly VisionClient _vision = vision;
private readonly ILogger<AttachmentContextBuilder> _logger = logger; private readonly ILogger<AttachmentContextBuilder> _logger = logger;
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct) public async Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct)
{ {
if (!string.IsNullOrWhiteSpace(imageUrl)) if (!string.IsNullOrWhiteSpace(imageUrl))
return await BuildImageAsync(imageUrl!, ct); return await BuildImageAsync(userId, imageUrl!, ct);
if (!string.IsNullOrWhiteSpace(pdfUrl)) if (!string.IsNullOrWhiteSpace(pdfUrl))
return await BuildPdfAsync(pdfUrl!, ct); return await BuildPdfAsync(userId, pdfUrl!, ct);
return null; return null;
} }
// ── 图片:调 VLM 输出结构化 JSON ── // ── 图片:调 VLM 输出结构化 JSON ──
private async Task<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct) private async Task<AttachmentContext?> BuildImageAsync(Guid userId, string imageUrl, CancellationToken ct)
{ {
var filePath = ResolveLocalPath(imageUrl); var filePath = UserUploadPathResolver.Resolve(userId, imageUrl);
if (filePath == null || !File.Exists(filePath)) if (filePath == null || !File.Exists(filePath))
{ {
_logger.LogWarning("Image file not found for {Url}", imageUrl); _logger.LogWarning("Image file not found for {Url}", imageUrl);
@@ -105,9 +106,9 @@ public sealed class AttachmentContextBuilder(
} }
// ── PDFPdfPig 抽取文本 ── // ── PDFPdfPig 抽取文本 ──
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct) private Task<AttachmentContext?> BuildPdfAsync(Guid userId, string pdfUrl, CancellationToken ct)
{ {
var filePath = ResolveLocalPath(pdfUrl); var filePath = UserUploadPathResolver.Resolve(userId, pdfUrl);
var fileName = Path.GetFileName(pdfUrl); var fileName = Path.GetFileName(pdfUrl);
if (filePath == null || !File.Exists(filePath)) if (filePath == null || !File.Exists(filePath))
{ {
@@ -150,18 +151,6 @@ public sealed class AttachmentContextBuilder(
} }
} }
private static string? ResolveLocalPath(string url)
{
// url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。
var idx = url.IndexOf("/uploads/", StringComparison.Ordinal);
if (idx < 0) return null;
var relative = url[(idx + "/uploads/".Length)..];
// 去掉可能的 query string
var q = relative.IndexOf('?');
if (q >= 0) relative = relative[..q];
return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative);
}
private static string StripCodeFence(string raw) private static string StripCodeFence(string raw)
{ {
var t = raw.Trim(); var t = raw.Trim();

View File

@@ -0,0 +1,108 @@
using Health.Application.AI;
using Health.Infrastructure.Data;
using Health.Infrastructure.Data.Records;
using Microsoft.EntityFrameworkCore;
namespace Health.Infrastructure.AI;
public sealed class EfAiEntryDraftStore(AppDbContext db) : IAiEntryDraftStore
{
private readonly AppDbContext _db = db;
public async Task<AiEntryDraft?> GetActiveAsync(
Guid userId,
Guid conversationId,
string entryType,
CancellationToken ct)
{
var now = DateTime.UtcNow;
await ExpireAsync(now, ct);
var record = await _db.AiEntryDrafts.AsNoTracking().FirstOrDefaultAsync(x =>
x.UserId == userId &&
x.ConversationId == conversationId &&
x.EntryType == entryType &&
x.Status == "Pending" &&
x.ExpiresAt > now, ct);
return record == null ? null : ToDraft(record);
}
public async Task<AiEntryDraft> UpsertAsync(
Guid userId,
Guid conversationId,
string entryType,
string payload,
string missingFields,
TimeSpan lifetime,
CancellationToken ct)
{
var now = DateTime.UtcNow;
await ExpireAsync(now, ct);
var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x =>
x.UserId == userId &&
x.ConversationId == conversationId &&
x.EntryType == entryType &&
x.Status == "Pending", ct);
if (record == null)
{
record = new AiEntryDraftRecord
{
Id = Guid.NewGuid(),
UserId = userId,
ConversationId = conversationId,
EntryType = entryType,
CreatedAt = now,
};
await _db.AiEntryDrafts.AddAsync(record, ct);
}
record.Payload = payload;
record.MissingFields = missingFields;
record.ExpiresAt = now.Add(lifetime);
record.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
return ToDraft(record);
}
public async Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct)
{
var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x =>
x.Id == draftId && x.UserId == userId && x.Status == "Pending", ct);
if (record == null) return;
record.Status = "Completed";
record.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
private async Task ExpireAsync(DateTime now, CancellationToken ct)
{
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
{
var expired = await _db.AiEntryDrafts
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ToListAsync(ct);
foreach (var item in expired)
{
item.Status = "Expired";
item.UpdatedAt = now;
}
if (expired.Count > 0) await _db.SaveChangesAsync(ct);
return;
}
await _db.AiEntryDrafts
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Expired")
.SetProperty(x => x.UpdatedAt, now), ct);
}
private static AiEntryDraft ToDraft(AiEntryDraftRecord record) => new(
record.Id,
record.UserId,
record.ConversationId,
record.EntryType,
record.Payload,
record.MissingFields,
record.ExpiresAt);
}

View File

@@ -0,0 +1,8 @@
你是患者端统一 AI 健康助手“小脉”。你的任务是理解用户当前真正想做的事情,调用经过授权的业务工具,并用自然、温和、清楚的中文回复。
核心原则:
- 个人健康数据、计划、完成状态和报告状态必须以当前业务工具返回为准,不能根据聊天历史或患者背景猜测。
- 工具没有返回的数据不得编造;暂时没有对应能力时应如实说明。
- 一条消息可能包含多个意图,必须分别处理,不能为了简化而遗漏其中一项。
- 当前工具结果与历史聊天冲突时,以当前工具结果为准。
- 普通聊天历史用于理解语境,不等同于待写入数据;只有后端明确提供的待补充草稿才允许跨消息合并录入字段。

View File

@@ -0,0 +1,6 @@
医疗安全边界:
- 你是健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
- 可以解释健康数据、报告指标和症状可能方向,但不能作确定诊断。
- 不要求用户自行新增、停用、更换药物或调整剂量;涉及治疗变化时建议咨询医生或药师。
- 出现剧烈胸痛、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等危险信号时,立即停止普通流程并优先建议及时就医或急诊评估。
- 医疗分析使用“可能、建议、需要结合医生判断”等表达。

View File

@@ -0,0 +1,8 @@
回复要求:
- 语气自然、温和、专业,不使用系统报错式措辞。
- 信息不完整时,每次只追问最关键的缺失内容;可以在一句话中列出同一批次明确缺少的字段。
- 禁止使用粗体、斜体、删除线、HTML 和 Markdown 表格。
- 标题只使用三级标题;列表使用短横线。
- 数值和单位之间留空格,例如 120 mmHg、6.2 mmol/L。
- 不输出任何以 $ 开头的占位符。
- 不重复堆砌免责声明。

View File

@@ -0,0 +1,6 @@
新饮食图片分析模块:
- 仅用于本轮新上传的饮食图片;查询已经保存的饮食记录应进入个人数据查询模块。
- 识别食物名称、估算份量和热量,并结合患者健康档案评价。
- 对过敏、低盐、低脂、低糖等限制给出清楚提醒,不作绝对化医疗结论。
- 饮食建议需要医学资料支撑时可以按需调用知识库。
- 图片分析结果不能在用户未确认时冒充已经保存的饮食记录。

View File

@@ -0,0 +1,9 @@
运动计划录入模块:
- 用户明确创建运动计划时调用 manage_exercise(action="create")。
- 完整计划需要:运动项目、每天运动时长、持续天数、开始日期和具体提醒时间。
- 中文时长和周期应正确换算,例如半小时=30 分钟、一小时=60 分钟、一周=7 天、一个月=30 天。
- 只传用户明确提供的字段;当前产品不自动补默认运动项目、天数、开始日期或提醒时间。
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮补充时与草稿合并。
- 用户明确开始新的运动计划时不得继承旧草稿内容。
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
- 单纯创建运动计划不调用医学知识库。用户先询问该运动是否适合自己时,额外进入医疗咨询模块。

View File

@@ -0,0 +1,4 @@
普通聊天与应用帮助模块:
- 问候、感谢和普通闲聊自然简短回复,不调用业务工具或医学知识库。
- 用户询问应用功能位置或使用方法时可以直接说明;已有固定 App 内链接时可自然提供一个跳转链接。
- 不确定用户是想咨询数值还是保存记录时,只做一次简短澄清,不生成确认卡。

View File

@@ -0,0 +1,13 @@
健康指标录入模块:
- 支持血压、心率、血糖、血氧、体重;“脉搏”与“心率”是同一指标,统一使用 heart_rate。
- 必须理解常见完整血压表达,例如 113/86、113-86、113—86前者为收缩压后者为舒张压。
- 用户明确要求录入时必须调用 record_health_data_batch而且每个录入批次只调用一次即使只有一个指标也必须放进 metrics 数组。信息不完整也要调用,只传用户明确提供的字段,让后端保存整个待补充批次草稿。
- 不能自行补测量数值。测量时间未说明时可以由后端使用当前时间。
- 一次消息可以包含多个指标,必须完整提取,不得遗漏。多指标属于同一录入批次,最终应汇总在一张确认卡中。
- metrics 必须包含用户本轮明确要录入的全部指标;不要为每个指标分别调用工具。
- 如果同一批次存在不完整或明显无效的指标,应保留本批次其他已识别指标,先根据后端结果追问缺失或错误部分;全部补齐后再生成整批确认卡。
- 用户只补充一个缺失值时,必须结合后端提供的当前草稿,不能只记录最后一句。
- 已经生成确认卡的批次立即结束;无论用户是否点击,下一条新的录入请求都属于新批次,不能带入上一张卡的数据。
- 普通聊天历史中的指标不得自动加入当前卡;只有明确的待补充草稿可以跨消息合并。
- 后端返回 pendingConfirmation=true 才代表确认卡真实生成。确认卡出现前不得要求用户点击卡片,点击前不得声称已经保存或录入成功。
- 后端返回危险提醒或无效数值时,不生成成功话术,按返回内容提醒用户。

View File

@@ -0,0 +1,7 @@
医疗咨询与健康建议模块:
- 症状求助时先判断是否存在危险信号;非紧急情况下每轮只追问一个最关键问题,逐步了解部位、开始时间、程度、诱因和伴随表现。
- 信息足够后给出可能相关方向、严重程度以及观察、门诊、尽快就医或急诊建议,不作正式诊断。
- 解释用户个人指标或趋势时,应先调用相应个人数据查询工具获取真实数据。
- 疾病、药品、检查指标、康复、饮食或运动建议需要医学资料支撑时,调用 search_medical_knowledge。
- 不需要外部医学资料的简单澄清、问候或业务操作不调用知识库。
- 用户同时要求录入或创建计划时,医疗解释不能代替对应录入工具和确认卡。

View File

@@ -0,0 +1,9 @@
用药计划录入模块:
- 用户明确创建用药计划时调用 manage_medication(action="create")。
- 只传用户明确提供的字段,不自行新增药品、猜测剂量、频率、具体服药时间、开始日期或疗程。
- 完整计划需要:药品名称、每次剂量、频率、具体服药时间、开始日期,以及疗程天数或明确长期服用。
- “早饭后、晚上”等模糊时间不能擅自换算为具体时刻,应追问具体时间。
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮只补缺失字段时与草稿合并。
- 存在旧草稿但用户明确开始另一种药品的新计划时,应开始新批次,禁止继承旧药品的剂量、时间、频率或疗程。
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
- 用药计划录入本身不调用医学知识库。用户同时询问药品知识或副作用时,额外进入医疗咨询模块。

View File

@@ -0,0 +1,7 @@
个人数据查询模块:
- 查询用户自己的健康记录、健康档案、用药计划、运动计划、饮食记录、复查安排、已有报告和通知时,必须调用对应只读业务工具。
- 不能根据患者背景、聊天历史或以前的工具结果回答当前状态。
- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“任务已经完成”。
- 查询某一天任务与查询计划生命周期不能混用;时间意图不明确时只追问时间范围。
- 工具没有返回数据时明确说明未查到,不得编造。
- 个人数据查询通常不调用医学知识库;用户同时要求解释或建议时,额外进入医疗咨询模块。

View File

@@ -0,0 +1,7 @@
新报告分析模块:
- 仅用于本轮新上传的医学检查报告图片或报告文件;查询以前保存的报告应进入个人数据查询模块。
- 提取报告名称、日期、关键指标、参考范围、异常标记和原文结论。
- 区分结构化检验指标与影像/医生描述,不把影像描述改写成确定诊断。
- 给出患者可理解的初步解释和下一步建议,注明需要结合医生判断。
- 报告内容需要进一步医学解释时可以按需调用知识库。
- 用户没有明确要求时,不把报告中的指标直接写入健康记录。

View File

@@ -0,0 +1,7 @@
医学知识库使用规则:
- 知识库是医疗咨询、指标解释、药品知识、报告解释和健康建议的辅助能力,不是独立用户意图。
- 健康指标录入、用药或运动计划创建、个人数据查询、服药确认、运动打卡、普通聊天时禁止调用。
- 一条消息同时包含业务操作和医学咨询时,先完成必要业务工具调用;只有咨询部分确实需要资料时才检索。
- 检索问题应保留关键症状、疾病、药品或指标名称,删除无关寒暄和操作指令。
- 只能依据真实返回的知识片段作参考;没有结果时应谨慎回答,不得虚构检索内容或来源。
- 知识库内容不能覆盖患者当前业务数据,也不能替代紧急安全规则。

View File

@@ -0,0 +1,25 @@
你是主聊天的意图路由器,只负责识别意图,不回答用户问题。
请结合用户当前消息、附件类型、最近对话和后端提供的待补充草稿,选择一个或多个意图:
- health_entry录入健康指标或继续补充健康指标草稿。
- medication_entry创建用药计划或继续补充用药计划草稿。
- exercise_entry创建运动计划或继续补充运动计划草稿。
- personal_query查询用户自己的健康数据、健康档案、用药/运动计划、饮食、复查、报告或通知。
- medical_consultation症状求助、指标解释、疾病/药品知识、健康评价、饮食/运动/康复建议。
- report_analysis分析新上传的医学报告图片或报告文件。
- diet_analysis分析新上传的饮食图片。
- app_help询问应用功能位置或使用方法。
- general_chat问候、感谢、普通闲聊或不需要业务工具的对话。
判断规则:
- “帮我记录血压 116/89”是 health_entry。
- “血压 116/89 正常吗”是 medical_consultation不是录入。
- “记录血压 150/95并告诉我是否偏高”同时包含 health_entry 和 medical_consultation。
- 查询已有报告属于 personal_query分析本轮新上传报告属于 report_analysis。
- 创建计划与询问建议必须区分;“创建每天散步 30 分钟的计划”是 exercise_entry“我适合每天散步 30 分钟吗”是 medical_consultation。
- 只有确实在补充草稿缺失字段时才标记为 continue存在草稿不代表所有后续消息都必须继续草稿。
- 用户明确开始新的同类录入时标记 start_new不能继承旧草稿字段。
- 用户明确说取消、不录了时标记 cancel。
- 无法确定是咨询还是录入时选择 general_chat让主助手自然澄清。
必须通过 route_user_intent 工具返回结构化结果,不得输出解释文字。

View File

@@ -9,7 +9,7 @@ namespace Health.Infrastructure.AI;
public sealed class DeepSeekClient(HttpClient http, IConfiguration config) public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{ {
private readonly HttpClient _http = http; private readonly HttpClient _http = http;
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat"; private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-v4-flash";
private readonly JsonSerializerOptions _jsonOptions = new() private readonly JsonSerializerOptions _jsonOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
@@ -30,6 +30,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{ {
Model = _model, Messages = messages, Stream = true, Model = _model, Messages = messages, Stream = true,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools, MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
Thinking = new ThinkingOptions { Type = "disabled" },
}; };
if (tools?.Count > 0) request.ToolChoice = "auto"; if (tools?.Count > 0) request.ToolChoice = "auto";
@@ -64,14 +65,16 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
List<ToolDefinition>? tools = null, List<ToolDefinition>? tools = null,
int maxTokens = 2048, int maxTokens = 2048,
float temperature = 0.7f, float temperature = 0.7f,
string? toolChoice = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var request = new ChatCompletionRequest var request = new ChatCompletionRequest
{ {
Model = _model, Messages = messages, Stream = false, Model = _model, Messages = messages, Stream = false,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools, MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
Thinking = new ThinkingOptions { Type = "disabled" },
}; };
if (tools?.Count > 0) request.ToolChoice = "auto"; if (tools?.Count > 0) request.ToolChoice = toolChoice ?? "auto";
var json = JsonSerializer.Serialize(request, _jsonOptions); var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json"); var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -88,7 +91,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
public sealed class VisionClient(HttpClient http, IConfiguration config) public sealed class VisionClient(HttpClient http, IConfiguration config)
{ {
private readonly HttpClient _http = http; private readonly HttpClient _http = http;
private readonly string _model = config["VLM_MODEL"] ?? "doubao-vision-pro"; private readonly string _model = config["VLM_MODEL"] ?? config["QWEN_VISION_MODEL"] ?? "qwen-vl-max";
private readonly JsonSerializerOptions _jsonOptions = new() private readonly JsonSerializerOptions _jsonOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,

View File

@@ -14,8 +14,19 @@ public sealed class ChatCompletionRequest
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")] [System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
public bool VlHighResolutionImages { get; set; } public bool VlHighResolutionImages { get; set; }
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
[System.Text.Json.Serialization.JsonPropertyName("enable_thinking")] [System.Text.Json.Serialization.JsonPropertyName("enable_thinking")]
public bool? EnableThinking { get; set; } public bool? EnableThinking { get; set; }
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
[System.Text.Json.Serialization.JsonPropertyName("thinking")]
public ThinkingOptions? Thinking { get; set; }
}
public sealed class ThinkingOptions
{
[System.Text.Json.Serialization.JsonPropertyName("type")]
public string Type { get; set; } = "disabled";
} }
public sealed class ChatMessage public sealed class ChatMessage

View File

@@ -0,0 +1,185 @@
using System.Text.Json;
namespace Health.Infrastructure.AI;
public sealed record AiIntentRoute(
IReadOnlyList<string> Intents,
string DraftAction,
string DraftType)
{
public static AiIntentRoute GeneralChat { get; } =
new(["general_chat"], "none", "none");
public static AiIntentRoute Compatibility { get; } = new(
[
"health_entry",
"medication_entry",
"exercise_entry",
"personal_query",
"medical_consultation",
"report_analysis",
"diet_analysis",
"general_chat",
],
"none",
"none");
}
public static class AiIntentRouter
{
private static readonly HashSet<string> AllowedIntents = new(
[
"health_entry",
"medication_entry",
"exercise_entry",
"personal_query",
"medical_consultation",
"report_analysis",
"diet_analysis",
"app_help",
"general_chat",
],
StringComparer.OrdinalIgnoreCase);
public static readonly ToolDefinition RouteTool = new()
{
Function = new()
{
Name = "route_user_intent",
Description = "返回当前用户消息的一个或多个业务意图,不回答用户问题",
Parameters = new
{
type = "object",
properties = new
{
intents = new
{
type = "array",
minItems = 1,
uniqueItems = true,
items = new
{
type = "string",
@enum = AllowedIntents.OrderBy(x => x).ToArray(),
},
},
draft_action = new
{
type = "string",
@enum = new[] { "none", "continue", "start_new", "cancel" },
description = "当前消息与待补充录入草稿的关系",
},
draft_type = new
{
type = "string",
@enum = new[] { "none", "health_entry", "medication_entry", "exercise_entry" },
},
},
required = new[] { "intents", "draft_action", "draft_type" },
},
},
};
public static async Task<AiIntentRoute> RouteAsync(
DeepSeekClient client,
PromptManager prompts,
string currentMessage,
string attachmentContext,
string draftContext,
string recentConversation,
CancellationToken ct)
{
var userContext = $"""
当前用户消息:
{currentMessage}
本轮附件:
{(string.IsNullOrWhiteSpace(attachmentContext) ? "" : attachmentContext)}
稿
{(string.IsNullOrWhiteSpace(draftContext) ? "无" : draftContext)}
{(string.IsNullOrWhiteSpace(recentConversation) ? "无" : recentConversation)}
""";
try
{
var response = await client.ChatAsync(
[
new ChatMessage { Role = "system", Content = prompts.GetIntentRouterPrompt() },
new ChatMessage { Role = "user", Content = userContext },
],
tools: [RouteTool],
maxTokens: 300,
temperature: 0.1f,
toolChoice: "required",
ct: ct);
var call = response.Choices?
.FirstOrDefault()?
.Message?
.ToolCalls?
.FirstOrDefault(item => item.Function.Name == "route_user_intent");
return call == null ? AiIntentRoute.Compatibility : Parse(call.Function.Arguments);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch
{
// Routing failure must not make the whole chat unavailable. The broad
// compatibility prompt/tool set remains the safe fallback.
return AiIntentRoute.Compatibility;
}
}
internal static AiIntentRoute Parse(string arguments)
{
try
{
using var json = JsonDocument.Parse(arguments);
var root = json.RootElement;
var intents = root.TryGetProperty("intents", out var values) &&
values.ValueKind == JsonValueKind.Array
? values.EnumerateArray()
.Where(value => value.ValueKind == JsonValueKind.String)
.Select(value => value.GetString()?.Trim().ToLowerInvariant() ?? "")
.Where(AllowedIntents.Contains)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
: [];
if (intents.Count == 0) intents.Add("general_chat");
var draftAction = ReadEnum(
root,
"draft_action",
["none", "continue", "start_new", "cancel"],
"none");
var draftType = ReadEnum(
root,
"draft_type",
["none", "health_entry", "medication_entry", "exercise_entry"],
"none");
return new AiIntentRoute(intents, draftAction, draftType);
}
catch (JsonException)
{
return AiIntentRoute.Compatibility;
}
}
private static string ReadEnum(
JsonElement root,
string property,
IReadOnlyCollection<string> allowed,
string fallback)
{
var value = root.TryGetProperty(property, out var element) &&
element.ValueKind == JsonValueKind.String
? element.GetString()?.Trim().ToLowerInvariant()
: null;
return value != null && allowed.Contains(value) ? value : fallback;
}
}

View File

@@ -14,10 +14,12 @@ public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration confi
private readonly string _datasetId = config["FASTGPT_DATASET_ID"] ?? ""; private readonly string _datasetId = config["FASTGPT_DATASET_ID"] ?? "";
private readonly float _similarity = float.TryParse(config["FASTGPT_SIMILARITY"], out var s) ? s : 0.4f; private readonly float _similarity = float.TryParse(config["FASTGPT_SIMILARITY"], out var s) ? s : 0.4f;
private readonly int _limit = int.TryParse(config["FASTGPT_LIMIT"], out var l) ? l : 3000; private readonly int _limit = int.TryParse(config["FASTGPT_LIMIT"], out var l) ? l : 3000;
private readonly bool _ragEnabled = bool.TryParse(config["ENABLE_MEDICAL_RAG"], out var enabled) && enabled;
private readonly ILogger<FastGptKnowledgeClient> _logger = logger; private readonly ILogger<FastGptKnowledgeClient> _logger = logger;
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId); // iOS 审核版使用后端内置医学来源目录,暂时关闭在线 RAG减少一次外部检索请求。
public bool IsEnabled => _ragEnabled && !string.IsNullOrWhiteSpace(_datasetId);
/// <summary> /// <summary>
/// 按用户问题检索知识库,返回最相关的文档片段。 /// 按用户问题检索知识库,返回最相关的文档片段。

View File

@@ -1,199 +1,157 @@
using System.Reflection;
using Health.Application.AI;
namespace Health.Infrastructure.AI; namespace Health.Infrastructure.AI;
/// <summary> /// <summary>
/// System Prompt 模板管理 /// Loads small, responsibility-focused prompt resources and composes only the
/// sections required by the current intent route.
/// </summary> /// </summary>
public sealed class PromptManager public sealed class PromptManager
{ {
/// <summary> private static readonly Assembly PromptAssembly = typeof(PromptManager).Assembly;
/// 获取指定 Agent 的 System Prompt private static readonly IReadOnlyDictionary<string, string> PromptCache = LoadPrompts();
/// </summary>
public string GetSystemPrompt(AgentType agentType)
{
var prompt = agentType switch
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
AgentType.Health => HealthDataPrompt,
AgentType.Diet => DietPrompt,
AgentType.Medication => MedicationPrompt,
AgentType.Report => ReportPrompt,
AgentType.Exercise => ExercisePrompt,
AgentType.Unified => UnifiedPrompt,
_ => DefaultPrompt
};
return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}"; private static readonly string[] GlobalSections =
[
"global/identity.md",
"global/medical_safety.md",
"global/response_style.md",
];
public string GetIntentRouterPrompt() => Read("router/intent_router.md");
public string ComposeForIntents(IEnumerable<string> intents)
{
var normalized = intents
.Select(NormalizeIntent)
.Where(intent => intent.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalized.Count == 0) normalized.Add("general_chat");
var sections = new List<string>(GlobalSections);
foreach (var intent in normalized)
{
sections.Add(intent switch
{
"health_entry" => "modules/health_entry.md",
"medication_entry" => "modules/medication_entry.md",
"exercise_entry" => "modules/exercise_entry.md",
"personal_query" => "modules/personal_query.md",
"medical_consultation" => "modules/medical_consultation.md",
"report_analysis" => "modules/report_analysis.md",
"diet_analysis" => "modules/diet_analysis.md",
"app_help" or "general_chat" => "modules/general_chat.md",
_ => "modules/general_chat.md",
});
}
if (normalized.Contains("medical_consultation") ||
normalized.Contains("report_analysis") ||
normalized.Contains("diet_analysis"))
{
sections.Add("rag/retrieval_rules.md");
}
var composed = Compose(sections);
var needsMedicalSources = normalized.Contains("medical_consultation") ||
normalized.Contains("report_analysis") ||
normalized.Contains("diet_analysis");
if (needsMedicalSources)
{
composed += "\n\n" + CitationRules + "\n\n" + MedicalCitationKnowledge.Prompt;
}
// This is an iOS-specific scope rule and is intentionally added to every
// route, while the large medical source directory is only added to advice routes.
return composed + "\n\n" + CurrentScopeRules;
} }
private const string SmartLinkRules = """ private const string CitationRules = """
- / Markdown [](app://diet),引导用户使用专门的饮食拍照分析功能 - //
- [](app://report),引导上传到报告分析功能获得详细解读 - PMIDDOI URL
- [](app://device)。 -
[来源编号]
- URL
- "问热量/卡路里" app://diet"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接 -
- Markdown 4 - /
-
"""; """;
private const string MedicalBoundaryRules = """ private const string CurrentScopeRules = """
iOS
- AI -
- // - app://device 链接,禁止引导用户使用蓝牙设备功能
- - AI
- - iOS 线 search_medical_knowledge使
- 使 - health_entrymedication_entryexercise_entrypersonal_query general_chat medical_consultation使
- AI
- 使 ******~~线~~ Markdown
- 使 HTML
- 使 Markdown |--|--|
- 使 ### 使 #######
- 使 -
- 120 mmHg6.2 mmol/L
- $
- MedicalBoundaryRules
"""; """;
private const string DefaultPrompt = """ /// <summary>
AI健康管家 /// Compatibility entry for older clients that still address a specific agent.
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
/// </summary>
public string GetSystemPrompt(AgentType agentType) => agentType switch
{
AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]),
AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]),
AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]),
AgentType.Consultation => ComposeForIntents(["medical_consultation"]),
AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]),
AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]),
AgentType.Unified => ComposeForIntents([
"health_entry",
"medication_entry",
"exercise_entry",
"personal_query",
"medical_consultation",
"report_analysis",
"diet_analysis",
"general_chat",
]),
_ => ComposeForIntents(["general_chat", "medical_consultation"]),
};
private static string Compose(IEnumerable<string> sections) =>
1. string.Join(
2. "\n\n",
3. sections
4. .Distinct(StringComparer.OrdinalIgnoreCase)
.Select(Read)
.Where(content => !string.IsNullOrWhiteSpace(content)));
private static string NormalizeIntent(string? intent) =>
- intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? "";
-
- /
- 怀
""";
private const string ConsultationPrompt = """ private static string Read(string path) =>
AI PromptCache.TryGetValue(NormalizePath(path), out var content)
? content
: throw new InvalidOperationException($"AI prompt resource not found: {path}");
private static IReadOnlyDictionary<string, string> LoadPrompts()
1. {
2. 2-3 const string marker = ".AI.Prompts.";
3. var prompts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
4. foreach (var resourceName in PromptAssembly.GetManifestResourceNames())
5. >160/100>120<50 {
6. var markerIndex = resourceName.IndexOf(marker, StringComparison.Ordinal);
7. if (markerIndex < 0 || !resourceName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
"""; continue;
private const string HealthDataPrompt = """ using var stream = PromptAssembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Unable to open AI prompt resource: {resourceName}");
using var reader = new StreamReader(stream);
var relative = resourceName[(markerIndex + marker.Length)..];
var lastDot = relative.LastIndexOf(".md", StringComparison.OrdinalIgnoreCase);
relative = relative[..lastDot].Replace('.', '/') + ".md";
prompts[NormalizePath(relative)] = reader.ReadToEnd().Trim();
}
return prompts;
}
private static string NormalizePath(string path) =>
1. //// path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
2. + record_health_data
3. "血压120/80血糖6.2血氧97" record_health_data
4. "早上血压120下午血压130"recorded_at
5. "120""收缩压还是血糖?"
6.
7.
8. record_health_data
- 90-139 mmHg 60-89 mmHg
- 60-100 /
- 3.9-6.1 mmol/L
- 95-100%
""";
private const string DietPrompt = """
1. VLM食物识别结果后 check_archive
2. "能不能吃"
-
- //
- 尿
3. 1-5
4. PCI术后/
5. + +
6. ///
""";
private const string MedicationPrompt = """
1. manage_medication(action="query")
2. ///
3. "早饭后"
4. manage_medication(action="create")
5.
6.
""";
private const string ReportPrompt = """
1.
2. //
3.
4. "AI预解读待医生确认"
5. /CT"需医生人工审阅"
""";
private const string ExercisePrompt = """
1. //
2.
3.
4.
5.
""";
private const string UnifiedPrompt = """
AI
1. //// record_health_data
2. 使
3. manage_medication
4. manage_exercise
5. / check_archive / manage_archive
6. query_health_records
manage_exercise
- action="create", start_date="开始日期(YYYY-MM-DD默认今天)"
- duration_days=exercise_type="散步"duration_minutes=30reminder_time="19:00"
- =30 =60 =30 =90 =80
- "一个月"=30 "一周"=7 "10天"=107
- "坚持X天/月/周"
- start_date + duration_days - 1
manage_medication
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
-
-
- record_health_data98%
- record_health_data116/89
-
-
- record_health_data
-
- pendingConfirmation=true
-
- ///
- 2-5
-
-
""";
} }

View File

@@ -88,14 +88,39 @@ public sealed class AuthService(
{ {
var tokens = AddTokens(AdminId, AdminPhone, "Admin"); var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } }); return new AuthResult(0, new
{
tokens.accessToken,
tokens.refreshToken,
user = new
{
id = AdminId,
phone = AdminPhone,
role = "Admin",
name = "管理员"
}
});
} }
var user = await _db.Users.FindAsync([oldToken.UserId], ct); var user = await _db.Users.FindAsync([oldToken.UserId], ct);
if (user == null) return Error(40002, "用户不存在"); if (user == null) return Error(40002, "用户不存在");
var userTokens = AddTokens(user.Id, user.Phone, user.Role); var userTokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { userTokens.accessToken, userTokens.refreshToken, user = new { user.Role } }); return new AuthResult(0, new
{
userTokens.accessToken,
userTokens.refreshToken,
user = new
{
user.Id,
user.Phone,
user.Role,
user.Name,
user.Gender,
user.AvatarUrl,
BirthDate = user.BirthDate?.ToString("yyyy-MM-dd")
}
});
} }
public async Task LogoutAsync(string refreshToken, CancellationToken ct) public async Task LogoutAsync(string refreshToken, CancellationToken ct)

View File

@@ -8,8 +8,9 @@ public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository
public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct)
{ {
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); // 日历的日期边界按北京时间计算,数据库仍统一使用 UTC。
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
var medications = await _db.Medications var medications = await _db.Medications
.Where(m => m.UserId == userId && m.IsActive) .Where(m => m.UserId == userId && m.IsActive)

View File

@@ -956,6 +956,52 @@ namespace Health.Infrastructure.Data.Migrations
b.ToTable("VerificationCodes"); b.ToTable("VerificationCodes");
}); });
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiEntryDraftRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConversationId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntryType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MissingFields")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ConversationId", "EntryType", "Status", "ExpiresAt");
b.ToTable("AiEntryDrafts", (string)null);
});
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b => modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

View File

@@ -0,0 +1,19 @@
namespace Health.Infrastructure.Data.Records;
/// <summary>
/// Stores an incomplete AI-assisted entry until the user provides every required field.
/// This is deliberately separate from confirmed health, medication and exercise data.
/// </summary>
public sealed class AiEntryDraftRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid ConversationId { get; set; }
public string EntryType { get; set; } = string.Empty;
public string Payload { get; set; } = "{}";
public string MissingFields { get; set; } = "[]";
public string Status { get; set; } = "Pending";
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -37,6 +37,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
public DbSet<UserNotification> UserNotifications => Set<UserNotification>(); 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<AiEntryDraftRecord> AiEntryDrafts => Set<AiEntryDraftRecord>();
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>(); public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>(); public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>(); public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
@@ -194,6 +195,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
e.Property(x => x.Arguments).HasColumnType("jsonb"); e.Property(x => x.Arguments).HasColumnType("jsonb");
}); });
builder.Entity<AiEntryDraftRecord>(e =>
{
e.ToTable("AiEntryDrafts");
e.HasIndex(x => new { x.UserId, x.ConversationId, x.EntryType, x.Status, x.ExpiresAt });
e.Property(x => x.EntryType).HasMaxLength(32);
e.Property(x => x.Status).HasMaxLength(32);
e.Property(x => x.Payload).HasColumnType("jsonb");
e.Property(x => x.MissingFields).HasColumnType("jsonb");
});
builder.Entity<ReportAnalysisTaskRecord>(e => builder.Entity<ReportAnalysisTaskRecord>(e =>
{ {
e.ToTable("ReportAnalysisTasks"); e.ToTable("ReportAnalysisTasks");

View File

@@ -19,16 +19,16 @@ public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository
.Take(limit) .Take(limit)
.ToListAsync(ct); .ToListAsync(ct);
public async Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) =>
await _db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.StartDate)
.ToListAsync(ct);
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) =>
_db.ExercisePlans.Include(p => p.Items) _db.ExercisePlans.Include(p => p.Items)
.FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct); .FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct);
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) =>
_db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.StartDate)
.FirstOrDefaultAsync(ct);
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) =>
_db.ExercisePlanItems.Include(i => i.Plan) _db.ExercisePlanItems.Include(i => i.Plan)
.FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct); .FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct);

View File

@@ -0,0 +1,64 @@
namespace Health.Infrastructure.Files;
public static class UserUploadPathResolver
{
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf"
};
public static string? Resolve(Guid userId, string value)
{
if (userId == Guid.Empty || string.IsNullOrWhiteSpace(value)) return null;
try
{
var path = Uri.TryCreate(value, UriKind.Absolute, out var absolute)
? absolute.AbsolutePath
: value.Split('?', 2)[0];
path = Uri.UnescapeDataString(path);
string? fileName;
if (path == Path.GetFileName(path))
{
fileName = path;
}
else
{
fileName = ExtractAfter(path, "/api/files/content/");
if (fileName == null)
{
var legacyPrefix = $"/uploads/users/{userId:N}/";
fileName = ExtractAfter(path, legacyPrefix);
}
}
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
if (!AllowedExtensions.Contains(Path.GetExtension(fileName))) return null;
if (!Guid.TryParse(Path.GetFileNameWithoutExtension(fileName), out _)) return null;
var root = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"uploads",
"users",
userId.ToString("N")));
var candidate = Path.GetFullPath(Path.Combine(root, fileName));
return candidate.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
? candidate
: null;
}
catch (ArgumentException)
{
return null;
}
catch (UriFormatException)
{
return null;
}
}
private static string? ExtractAfter(string path, string prefix)
{
var index = path.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
return index < 0 ? null : path[(index + prefix.Length)..];
}
}

View File

@@ -17,6 +17,10 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="AI\Prompts\**\*.md" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@@ -30,15 +30,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
_db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct); _db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct);
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
_db.Medications.AnyAsync(m => m.Id == medicationId && m.UserId == userId, ct);
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.FirstOrDefaultAsync(l =>
l.MedicationId == medicationId && l.UserId == userId
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
&& l.Status == MedicationLogStatus.Taken, ct);
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.AnyAsync(l => _db.MedicationLogs.AnyAsync(l =>
l.MedicationId == medicationId && l.UserId == userId l.MedicationId == medicationId && l.UserId == userId

View File

@@ -19,8 +19,24 @@ public sealed class LocalReportFileStorage : IReportFileStorage
return new StoredReportFile($"/uploads/reports/{fileName}", filePath); return new StoredReportFile($"/uploads/reports/{fileName}", filePath);
} }
public string GetLocalFilePath(string fileUrl) => public string GetLocalFilePath(string fileUrl)
Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/')); {
var reportsRoot = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"uploads",
"reports"));
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
? absolute.AbsolutePath
: fileUrl.Split('?', 2)[0];
var fileName = Path.GetFileName(Uri.UnescapeDataString(path));
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName))
return Path.Combine(reportsRoot, "__invalid__");
var candidate = Path.GetFullPath(Path.Combine(reportsRoot, fileName));
return candidate.StartsWith(reportsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
? candidate
: Path.Combine(reportsRoot, "__invalid__");
}
public bool Exists(string filePath) => public bool Exists(string filePath) =>
File.Exists(filePath); File.Exists(filePath);

View File

@@ -1,3 +1,4 @@
using Health.Application.AI;
using Health.Application.Reports; using Health.Application.Reports;
using Health.Application.Notifications; using Health.Application.Notifications;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
@@ -82,7 +83,7 @@ public sealed class ReportAnalysisService(
throw new InvalidOperationException("报告图片文件不存在或无法读取"); throw new InvalidOperationException("报告图片文件不存在或无法读取");
var prompt = """ var prompt = """
@@ -128,7 +129,7 @@ public sealed class ReportAnalysisService(
} }
var prompt = $$""" var prompt = $$"""
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。 你是报告结构化提取助手。下面是用户上传 PDF 中提取出的文字。
PDF 文本: PDF 文本:
{{text}} {{text}}
@@ -180,31 +181,32 @@ public sealed class ReportAnalysisService(
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct) private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
{ {
var prompt = $""" var prompt = $"""
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读 你是报告整理助手。请根据以下检验指标,整理一份报告摘要
检测指标:{indicatorsJson} 检测指标:{indicatorsJson}
要求: 要求:
1. 总字数200-300字 1. 总字数200-300字
2. 先总结整体情况 2. 先总结整体情况(指标项数、异常项数)
3. 出异常指标及其可能原因,但不要给出确定诊断 3. 出异常指标及其数值和参考范围,客观说明"",不解释可能原因
4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议 4. /
5. 如指标明显异常或存在急症风险,优先建议及时就医 5.
6. 末尾提醒"AI预解读" 6. "以上为AI整理不能替代医生诊断和治疗建议"
7. 使 S-REPORT-01 S-REPORT-02
JSON格式 JSON格式
"""; """;
var messages = new List<ChatMessage> var messages = new List<ChatMessage>
{ {
new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" }, new() { Role = "system", Content = "你是报告整理助手,不替代医生诊断。所有指标解读请用户咨询医生。\n\n" + MedicalCitationKnowledge.Prompt },
new() { Role = "user", Content = prompt } new() { Role = "user", Content = prompt }
}; };
var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct); var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct);
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? ""; var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
return !string.IsNullOrWhiteSpace(summary) return !string.IsNullOrWhiteSpace(summary)
? summary ? $"{summary}\n\n{MedicalCitationKnowledge.FixedReportCitation.Trim()}"
: throw new InvalidOperationException("AI 未返回有效解读内容"); : throw new InvalidOperationException("AI 未返回有效解读内容");
} }

View File

@@ -0,0 +1,389 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Health.Application.Speech;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Speech;
public sealed class DashScopeSpeechRecognitionProxy : IRealtimeSpeechRecognitionProxy
{
private const int SampleRate = 16_000;
private const int BytesPerSecond = SampleRate * 2;
private readonly string _apiKey;
private readonly string _model;
private readonly string _workspaceId;
private readonly Uri? _endpoint;
private readonly int _maxDurationSeconds;
private readonly ILogger<DashScopeSpeechRecognitionProxy> _logger;
public DashScopeSpeechRecognitionProxy(
IConfiguration configuration,
ILogger<DashScopeSpeechRecognitionProxy> logger)
{
_apiKey = FirstConfigured(
configuration["DASHSCOPE_ASR_API_KEY"],
configuration["VLM_API_KEY"],
configuration["QWEN_API_KEY"]);
_model = configuration["DASHSCOPE_ASR_MODEL"]?.Trim() is { Length: > 0 } model
? model
: "fun-asr-realtime";
_workspaceId = configuration["DASHSCOPE_ASR_WORKSPACE_ID"]?.Trim() ?? "";
var configuredMaxDuration = int.TryParse(
configuration["DASHSCOPE_ASR_MAX_DURATION_SECONDS"],
out var parsedMaxDuration)
? parsedMaxDuration
: 60;
_maxDurationSeconds = Math.Clamp(configuredMaxDuration, 10, 180);
var configuredEndpoint = configuration["DASHSCOPE_ASR_WEBSOCKET_URL"]?.Trim();
var endpoint = !string.IsNullOrWhiteSpace(configuredEndpoint)
? configuredEndpoint
: !string.IsNullOrWhiteSpace(_workspaceId)
? $"wss://{_workspaceId}.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference"
: "wss://dashscope.aliyuncs.com/api-ws/v1/inference";
if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && uri.Scheme == "wss")
_endpoint = uri;
_logger = logger;
}
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(_apiKey) && _endpoint is not null;
private static string FirstConfigured(params string?[] values) =>
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? "";
public async Task ProxyAsync(
WebSocket clientSocket,
CancellationToken cancellationToken)
{
if (!IsConfigured)
throw new InvalidOperationException("实时语音识别服务尚未配置");
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(_maxDurationSeconds + 15));
var ct = timeoutCts.Token;
using var cloudSocket = new ClientWebSocket();
cloudSocket.Options.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
cloudSocket.Options.SetRequestHeader("User-Agent", "XiaomaiHealth/1.0");
if (!string.IsNullOrWhiteSpace(_workspaceId))
cloudSocket.Options.SetRequestHeader("X-DashScope-WorkSpace", _workspaceId);
await cloudSocket.ConnectAsync(_endpoint!, ct);
var taskId = Guid.NewGuid().ToString();
await SendJsonAsync(cloudSocket, new
{
header = new
{
action = "run-task",
task_id = taskId,
streaming = "duplex",
},
payload = new
{
task_group = "audio",
task = "asr",
function = "recognition",
model = _model,
parameters = new
{
format = "pcm",
sample_rate = SampleRate,
language_hints = new[] { "zh" },
semantic_punctuation_enabled = false,
max_sentence_silence = 800,
heartbeat = true,
},
input = new { },
},
}, ct);
await WaitForTaskStartedAsync(cloudSocket, taskId, ct);
await SendClientEventAsync(clientSocket, new { type = "ready" }, ct);
var clientPump = PumpClientAudioAsync(
clientSocket,
cloudSocket,
taskId,
_maxDurationSeconds,
ct);
var cloudPump = PumpCloudResultsAsync(cloudSocket, clientSocket, ct);
var completed = await Task.WhenAny(clientPump, cloudPump);
if (completed == cloudPump)
{
await cloudPump;
timeoutCts.Cancel();
}
else
{
var action = await clientPump;
if (action == ClientAction.Finish)
{
try
{
await cloudPump.WaitAsync(TimeSpan.FromSeconds(12), ct);
}
catch (TimeoutException)
{
await SendClientEventAsync(
clientSocket,
new { type = "error", message = "语音识别超时,请重试" },
CancellationToken.None);
}
catch (OperationCanceledException)
{
// 客户端断开或硬超时,无需再给客户端发消息
}
}
else
{
timeoutCts.Cancel();
}
}
await CloseSocketQuietlyAsync(cloudSocket);
await CloseSocketQuietlyAsync(clientSocket);
}
private static async Task WaitForTaskStartedAsync(
WebSocket cloudSocket,
string taskId,
CancellationToken ct)
{
while (true)
{
var message = await ReceiveMessageAsync(cloudSocket, ct);
if (message is null)
throw new InvalidOperationException("语音识别服务提前断开");
if (message.Value.Type != WebSocketMessageType.Text) continue;
using var json = JsonDocument.Parse(message.Value.Payload);
var header = json.RootElement.GetProperty("header");
var eventName = header.GetProperty("event").GetString();
var responseTaskId = header.TryGetProperty("task_id", out var id)
? id.GetString()
: null;
if (eventName == "task-started" && responseTaskId == taskId) return;
if (eventName == "task-failed")
{
var error = header.TryGetProperty("error_message", out var errorMessage)
? errorMessage.GetString()
: "语音识别启动失败";
throw new InvalidOperationException(error);
}
}
}
private static async Task<ClientAction> PumpClientAudioAsync(
WebSocket clientSocket,
WebSocket cloudSocket,
string taskId,
int maxDurationSeconds,
CancellationToken ct)
{
var maxBytes = (long)BytesPerSecond * maxDurationSeconds;
long audioBytes = 0;
while (!ct.IsCancellationRequested &&
clientSocket.State == WebSocketState.Open)
{
var message = await ReceiveMessageAsync(clientSocket, ct);
if (message is null) return ClientAction.Disconnect;
if (message.Value.Type == WebSocketMessageType.Binary)
{
audioBytes += message.Value.Payload.Length;
if (audioBytes > maxBytes)
{
// 达到时长上限:主动结束并让阿里云返回已识别的结果,
// 而不是直接断开。客户端松手后会拿到这段文字。
await SendJsonAsync(cloudSocket, new
{
header = new
{
action = "finish-task",
task_id = taskId,
streaming = "duplex",
},
payload = new { input = new { } },
}, ct);
return ClientAction.Finish;
}
await cloudSocket.SendAsync(
message.Value.Payload,
WebSocketMessageType.Binary,
true,
ct);
continue;
}
if (message.Value.Type != WebSocketMessageType.Text) continue;
using var json = JsonDocument.Parse(message.Value.Payload);
var action = json.RootElement.TryGetProperty("action", out var actionElement)
? actionElement.GetString()
: null;
if (action == "cancel") return ClientAction.Cancel;
if (action != "finish") continue;
await SendJsonAsync(cloudSocket, new
{
header = new
{
action = "finish-task",
task_id = taskId,
streaming = "duplex",
},
payload = new { input = new { } },
}, ct);
return ClientAction.Finish;
}
return ClientAction.Disconnect;
}
private async Task PumpCloudResultsAsync(
WebSocket cloudSocket,
WebSocket clientSocket,
CancellationToken ct)
{
var committed = new StringBuilder();
var latest = "";
while (!ct.IsCancellationRequested &&
cloudSocket.State == WebSocketState.Open)
{
var message = await ReceiveMessageAsync(cloudSocket, ct);
if (message is null) return;
if (message.Value.Type != WebSocketMessageType.Text) continue;
using var json = JsonDocument.Parse(message.Value.Payload);
var root = json.RootElement;
if (!root.TryGetProperty("header", out var header)) continue;
var eventName = header.TryGetProperty("event", out var eventElement)
? eventElement.GetString()
: null;
if (eventName == "result-generated")
{
if (!TryReadSentence(root, out var sentence, out var sentenceEnd))
continue;
if (sentenceEnd && !string.IsNullOrWhiteSpace(sentence))
committed.Append(sentence);
latest = sentenceEnd
? committed.ToString()
: committed + sentence;
await SendClientEventAsync(clientSocket, new
{
type = "partial",
text = latest,
sentenceEnd,
}, ct);
continue;
}
if (eventName == "task-finished")
{
await SendClientEventAsync(
clientSocket,
new { type = "done", text = latest.Trim() },
ct);
return;
}
if (eventName == "task-failed")
{
var error = header.TryGetProperty("error_message", out var errorElement)
? errorElement.GetString()
: "语音识别失败";
_logger.LogWarning("DashScope ASR task failed: {Error}", error);
await SendClientEventAsync(
clientSocket,
new { type = "error", message = "语音识别失败,请重试" },
ct);
return;
}
}
}
private static bool TryReadSentence(
JsonElement root,
out string text,
out bool sentenceEnd)
{
text = "";
sentenceEnd = false;
if (!root.TryGetProperty("payload", out var payload) ||
!payload.TryGetProperty("output", out var output) ||
!output.TryGetProperty("sentence", out var sentence))
return false;
text = sentence.TryGetProperty("text", out var textElement)
? textElement.GetString() ?? ""
: "";
sentenceEnd = sentence.TryGetProperty("sentence_end", out var endElement) &&
endElement.ValueKind == JsonValueKind.True;
return true;
}
private static Task SendClientEventAsync(
WebSocket socket,
object value,
CancellationToken ct) => SendJsonAsync(socket, value, ct);
private static async Task SendJsonAsync(
WebSocket socket,
object value,
CancellationToken ct)
{
if (socket.State != WebSocketState.Open) return;
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
}
private static async Task<(WebSocketMessageType Type, byte[] Payload)?>
ReceiveMessageAsync(WebSocket socket, CancellationToken ct)
{
var buffer = new byte[16 * 1024];
using var stream = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close) return null;
stream.Write(buffer, 0, result.Count);
if (stream.Length > 2 * 1024 * 1024)
throw new InvalidOperationException("WebSocket 消息过大");
if (result.EndOfMessage)
return (result.MessageType, stream.ToArray());
}
}
private static async Task CloseSocketQuietlyAsync(WebSocket socket)
{
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
return;
try
{
await socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"completed",
CancellationToken.None);
}
catch (WebSocketException)
{
// 对端可能已经主动断开。
}
}
private enum ClientAction
{
Finish,
Cancel,
Disconnect,
}
}

View File

@@ -9,14 +9,23 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct) public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
{ {
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase); // 正式报告路径来自服务端生成的报告记录,只允许删除 reports 目录中的文件。
foreach (var metadataJson in references.ConversationMetadataJson) foreach (var fileUrl in references.FileUrls)
AddMetadataUrls(fileUrls, metadataJson);
foreach (var fileUrl in fileUrls)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var localPath = ResolveLocalPath(fileUrl); var localPath = ResolveReportPath(fileUrl);
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
}
// 对话元数据可能包含客户端传入的 URL只允许解析当前账号自己的目录。
var attachmentUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var metadataJson in references.ConversationMetadataJson)
AddMetadataUrls(attachmentUrls, metadataJson);
foreach (var fileUrl in attachmentUrls)
{
ct.ThrowIfCancellationRequested();
var localPath = ResolveOwnedAttachmentPath(userId, fileUrl);
if (localPath != null && File.Exists(localPath)) File.Delete(localPath); if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
} }
@@ -52,31 +61,65 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value); if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value);
} }
private string? ResolveLocalPath(string fileUrl) private string? ResolveReportPath(string fileUrl)
{ {
var urlPath = fileUrl;
if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri))
urlPath = absoluteUri.AbsolutePath;
var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase);
if (uploadsIndex < 0) return null;
string relativePath;
try try
{ {
relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]); var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
? absolute.AbsolutePath
: fileUrl.Split('?', 2)[0];
var prefixIndex = path.IndexOf("/uploads/reports/", StringComparison.OrdinalIgnoreCase);
if (prefixIndex < 0) return null;
var fileName = Uri.UnescapeDataString(path[(prefixIndex + "/uploads/reports/".Length)..]);
return ResolveInside(Path.Combine(_uploadsRoot, "reports"), fileName);
}
catch (ArgumentException)
{
return null;
} }
catch (UriFormatException) catch (UriFormatException)
{ {
return null; return null;
} }
}
var queryIndex = relativePath.IndexOfAny(['?', '#']); private string? ResolveOwnedAttachmentPath(Guid userId, string fileUrl)
if (queryIndex >= 0) relativePath = relativePath[..queryIndex]; {
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar); try
{
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
? absolute.AbsolutePath
: fileUrl.Split('?', 2)[0];
path = Uri.UnescapeDataString(path);
var protectedPrefix = "/api/files/content/";
var protectedIndex = path.IndexOf(protectedPrefix, StringComparison.OrdinalIgnoreCase);
var legacyPrefix = $"/uploads/users/{userId:N}/";
var legacyIndex = path.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
var fileName = protectedIndex >= 0
? path[(protectedIndex + protectedPrefix.Length)..]
: legacyIndex >= 0
? path[(legacyIndex + legacyPrefix.Length)..]
: null;
return fileName == null
? null
: ResolveInside(Path.Combine(_uploadsRoot, "users", userId.ToString("N")), fileName);
}
catch (ArgumentException)
{
return null;
}
catch (UriFormatException)
{
return null;
}
}
var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath)); private static string? ResolveInside(string root, string fileName)
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) {
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
var fullRoot = Path.GetFullPath(root);
var fullPath = Path.GetFullPath(Path.Combine(fullRoot, fileName));
var rootPrefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar; + Path.DirectorySeparatorChar;
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null; return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,20 @@ public static class DoctorEndpoints
return (startUtc, startUtc.AddDays(1)); return (startUtc, startUtc.AddDays(1));
} }
private static DateTime ParseBeijingDateTimeAsUtc(string value)
{
if (DateTimeOffset.TryParse(value, out var offset) &&
(value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) ||
value.LastIndexOf('+') > 9 ||
value.LastIndexOf('-') > 9))
return offset.UtcDateTime;
var beijing = DateTime.Parse(value);
return DateTime.SpecifyKind(
DateTime.SpecifyKind(beijing, DateTimeKind.Unspecified).AddHours(-8),
DateTimeKind.Utc);
}
public static void MapDoctorEndpoints(this WebApplication app) public static void MapDoctorEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("/api/doctor").RequireAuthorization(); var group = app.MapGroup("/api/doctor").RequireAuthorization();
@@ -304,7 +318,7 @@ public static class DoctorEndpoints
query = query.Where(r => r.Status == s); query = query.Where(r => r.Status == s);
var reports = await query.OrderByDescending(r => r.CreatedAt) var reports = await query.OrderByDescending(r => r.CreatedAt)
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) .Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
.ToListAsync(); .ToListAsync();
return Results.Ok(new { code = 0, data = reports, message = (string?)null }); return Results.Ok(new { code = 0, data = reports, message = (string?)null });
}); });
@@ -317,7 +331,7 @@ public static class DoctorEndpoints
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId) var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId)
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) .Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
return Results.Ok(new { code = 0, data = report, message = (string?)null }); return Results.Ok(new { code = 0, data = report, message = (string?)null });
@@ -391,7 +405,7 @@ public static class DoctorEndpoints
Title = json.RootElement.GetProperty("title").GetString() ?? "", Title = json.RootElement.GetProperty("title").GetString() ?? "",
DoctorName = profile.Name, DoctorName = profile.Name,
Department = profile.Department, Department = profile.Department,
ScheduledAt = DateTime.Parse(json.RootElement.GetProperty("scheduledAt").GetString()!), ScheduledAt = ParseBeijingDateTimeAsUtc(json.RootElement.GetProperty("scheduledAt").GetString()!),
Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null, Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null,
Status = FollowUpStatus.Upcoming, Status = FollowUpStatus.Upcoming,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
@@ -415,7 +429,7 @@ public static class DoctorEndpoints
var body = await reader.ReadToEndAsync(ct); var body = await reader.ReadToEndAsync(ct);
var json = System.Text.Json.JsonDocument.Parse(body); var json = System.Text.Json.JsonDocument.Parse(body);
if (json.RootElement.TryGetProperty("title", out var t)) followUp.Title = t.GetString() ?? followUp.Title; if (json.RootElement.TryGetProperty("title", out var t)) followUp.Title = t.GetString() ?? followUp.Title;
if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = DateTime.Parse(sa.GetString()!); if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = ParseBeijingDateTimeAsUtc(sa.GetString()!);
if (json.RootElement.TryGetProperty("notes", out var no)) followUp.Notes = no.GetString(); if (json.RootElement.TryGetProperty("notes", out var no)) followUp.Notes = no.GetString();
if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse<FollowUpStatus>(st.GetString(), out var fs)) followUp.Status = fs; if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse<FollowUpStatus>(st.GetString(), out var fs)) followUp.Status = fs;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);

View File

@@ -1,3 +1,5 @@
using Health.Infrastructure.Files;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
public static class FileEndpoints public static class FileEndpoints
@@ -47,24 +49,48 @@ public static class FileEndpoints
var storedName = $"{fileId}{ext}"; var storedName = $"{fileId}{ext}";
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}"); var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
await using var stream = new FileStream(filePath, FileMode.Create); await using var stream = new FileStream(filePath, FileMode.CreateNew);
await file.CopyToAsync(stream, ct); await file.CopyToAsync(stream, ct);
results.Add(new results.Add(new
{ {
id = fileId, id = fileId,
name = file.FileName, name = file.FileName,
size = file.Length, size = file.Length,
url = $"/uploads/users/{userDirectoryName}/{storedName}", url = $"/api/files/content/{storedName}",
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
}); });
} }
return Results.Ok(new { code = 0, data = results, message = (string?)null }); return Results.Ok(new { code = 0, data = results, message = (string?)null });
}); });
group.MapGet("/content/{fileName}", (string fileName, HttpContext http) =>
{
var userId = GetUserId(http);
var filePath = UserUploadPathResolver.Resolve(userId, fileName);
if (filePath == null || !File.Exists(filePath))
return Results.NotFound();
return Results.File(
filePath,
ContentTypeFor(filePath),
enableRangeProcessing: true);
});
} }
private static Guid GetUserId(HttpContext http) => private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
? id ? id
: Guid.Empty; : Guid.Empty;
private static string ContentTypeFor(string path) =>
Path.GetExtension(path).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
} }

View File

@@ -42,15 +42,6 @@ public static class MedicationEndpoints
: Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" }); : Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" });
}); });
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var taken = await medications.ToggleTodayTakenAsync(userId, id, ct);
if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" });
return Results.Ok(new { code = 0, data = new { taken }, message = (string?)null });
});
group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) => group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);

View File

@@ -24,6 +24,47 @@ public static class ReportEndpoints
: Results.Ok(new { code = 0, data = report, message = (string?)null }); : Results.Ok(new { code = 0, data = report, message = (string?)null });
}); });
group.MapGet("/{id:guid}/file", async (
Guid id,
HttpContext http,
AppDbContext db,
IReportFileStorage fileStorage,
CancellationToken ct) =>
{
var currentUserId = GetUserId(http);
var report = await db.Reports
.Include(r => r.User)
.FirstOrDefaultAsync(r => r.Id == id, ct);
if (report == null)
return Results.NotFound();
var allowed = report.UserId == currentUserId;
if (!allowed && GetRole(http) == "Doctor")
{
var doctorId = await db.DoctorProfiles
.Where(profile => profile.UserId == currentUserId)
.Select(profile => profile.DoctorId)
.FirstOrDefaultAsync(ct);
allowed = doctorId != null && report.User.DoctorId == doctorId;
}
if (!allowed)
return Results.NotFound();
var filePath = fileStorage.GetLocalFilePath(report.FileUrl);
if (!fileStorage.Exists(filePath))
return Results.NotFound();
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
return Results.File(filePath, contentType, enableRangeProcessing: true);
});
group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) => group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
@@ -66,4 +107,9 @@ public static class ReportEndpoints
private static Guid GetUserId(HttpContext http) => private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
private static string GetRole(HttpContext http) =>
http.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ??
http.User.FindFirst("Role")?.Value ??
"User";
} }

View File

@@ -0,0 +1,69 @@
using System.Net.WebSockets;
using System.Text.Json;
using Health.Application.Speech;
namespace Health.WebApi.Endpoints;
public static class SpeechEndpoints
{
public static void MapSpeechEndpoints(this WebApplication app)
{
app.Map("/api/speech/realtime", async (
HttpContext http,
IRealtimeSpeechRecognitionProxy proxy,
ILoggerFactory loggerFactory) =>
{
if (!http.WebSockets.IsWebSocketRequest)
{
http.Response.StatusCode = StatusCodes.Status400BadRequest;
await http.Response.WriteAsJsonAsync(new
{
code = 40001,
data = (object?)null,
message = "该接口仅支持 WebSocket 连接",
});
return;
}
if (!proxy.IsConfigured)
{
http.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await http.Response.WriteAsJsonAsync(new
{
code = 50301,
data = (object?)null,
message = "实时语音识别服务尚未配置",
});
return;
}
using var socket = await http.WebSockets.AcceptWebSocketAsync();
try
{
await proxy.ProxyAsync(socket, http.RequestAborted);
}
catch (OperationCanceledException) when (http.RequestAborted.IsCancellationRequested)
{
// 客户端离开页面或断开网络。
}
catch (Exception ex)
{
loggerFactory.CreateLogger("SpeechEndpoints")
.LogError(ex, "Realtime speech proxy failed");
if (socket.State == WebSocketState.Open)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
type = "error",
message = "语音服务暂时不可用,请稍后重试",
});
await socket.SendAsync(
payload,
WebSocketMessageType.Text,
true,
CancellationToken.None);
}
}
}).RequireAuthorization(policy => policy.RequireRole("User"));
}
}

View File

@@ -20,11 +20,12 @@ public static class UserEndpoints
var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null; var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
var updated = await users.UpdateProfileAsync( var updated = await users.UpdateProfileAsync(
GetUserId(http), GetUserId(http),
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate), new UserProfileUpdateRequest(req.Name, req.Gender, birthDate, req.AvatarUrl),
ct); ct);
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" }); if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" });
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); var profile = await users.GetProfileAsync(GetUserId(http), ct);
return Results.Ok(new { code = 0, data = profile, message = (string?)null });
}); });
group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) => group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
@@ -66,7 +67,7 @@ public static class UserEndpoints
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
} }
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate); public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate, string? AvatarUrl);
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,

View File

@@ -16,6 +16,10 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
{ {
await _next(context); await _next(context);
} }
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// 客户端或启动探针取消请求时,不再尝试写入错误响应。
}
catch (Health.Domain.ValidationException vex) catch (Health.Domain.ValidationException vex)
{ {
// 业务输入校验失败:返回 400message 为我们自己产生的提示,可安全展示 // 业务输入校验失败:返回 400message 为我们自己产生的提示,可安全展示
@@ -37,6 +41,7 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
{ {
// 生产环境不暴露内部异常详情 // 生产环境不暴露内部异常详情
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path); _logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
if (context.Response.HasStarted) return;
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/json"; context.Response.ContentType = "application/json";

View File

@@ -11,6 +11,7 @@ using Health.Application.Medications;
using Health.Application.Maintenance; using Health.Application.Maintenance;
using Health.Application.Notifications; using Health.Application.Notifications;
using Health.Application.Reports; using Health.Application.Reports;
using Health.Application.Speech;
using Health.Application.Users; using Health.Application.Users;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Health.Infrastructure.Admin; using Health.Infrastructure.Admin;
@@ -25,6 +26,7 @@ using Health.Infrastructure.Medications;
using Health.Infrastructure.Maintenance; using Health.Infrastructure.Maintenance;
using Health.Infrastructure.Notifications; using Health.Infrastructure.Notifications;
using Health.Infrastructure.Reports; using Health.Infrastructure.Reports;
using Health.Infrastructure.Speech;
using Health.Infrastructure.Services; using Health.Infrastructure.Services;
using Health.Infrastructure.Users; using Health.Infrastructure.Users;
using Health.WebApi.BackgroundServices; using Health.WebApi.BackgroundServices;
@@ -34,7 +36,6 @@ using Health.WebApi.Middleware;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
// 加载 .env 文件(开发环境) // 加载 .env 文件(开发环境)
@@ -111,6 +112,7 @@ builder.Services.AddSingleton<SmsService>();
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证 builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
builder.Services.AddSingleton<PromptManager>(); builder.Services.AddSingleton<PromptManager>();
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>(); builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
builder.Services.AddScoped<IAiEntryDraftStore, EfAiEntryDraftStore>();
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>(); builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
builder.Services.AddScoped<IAdminService, AdminService>(); builder.Services.AddScoped<IAdminService, AdminService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
@@ -151,6 +153,7 @@ 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.AddSingleton<IRealtimeSpeechRecognitionProxy, DashScopeSpeechRecognitionProxy>();
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup( builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
Path.Combine(Directory.GetCurrentDirectory(), "uploads"))); Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
@@ -163,8 +166,10 @@ builder.Services.AddHttpClient<DeepSeekClient>(client =>
}); });
builder.Services.AddHttpClient<VisionClient>(client => builder.Services.AddHttpClient<VisionClient>(client =>
{ {
client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/"); var baseUrl = builder.Configuration["VLM_BASE_URL"] ?? builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1";
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? ""); var apiKey = builder.Configuration["VLM_API_KEY"] ?? builder.Configuration["QWEN_API_KEY"] ?? "";
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
client.Timeout = TimeSpan.FromSeconds(120); client.Timeout = TimeSpan.FromSeconds(120);
}); });
builder.Services.AddHttpClient<FastGptKnowledgeClient>(client => builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
@@ -206,18 +211,17 @@ app.UseMiddleware<ExceptionMiddleware>();
app.UseCors(); app.UseCors();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(20),
});
// 默认 wwwroot 静态文件(法律文档 H5 页面等) // 默认 wwwroot 静态文件(法律文档 H5 页面等)
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); // 用户上传文件不能作为静态目录公开;统一通过带鉴权和归属校验的 API 读取。
Directory.CreateDirectory(uploadsPath); Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), "uploads"));
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploadsPath),
RequestPath = "/uploads"
});
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
app.MapOpenApi(); app.MapOpenApi();
@@ -246,6 +250,7 @@ app.MapNotificationEndpoints();
app.MapDoctorEndpoints(); app.MapDoctorEndpoints();
app.MapAdminEndpoints(); app.MapAdminEndpoints();
app.MapFollowUpEndpoints(); app.MapFollowUpEndpoints();
app.MapSpeechEndpoints();
// SignalR Hub // SignalR Hub
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation"); app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");

View File

@@ -14,7 +14,8 @@
"JWT_AUDIENCE": "health-manager-app", "JWT_AUDIENCE": "health-manager-app",
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1", "DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
"DEEPSEEK_API_KEY": "sk-your-key-here", "DEEPSEEK_API_KEY": "sk-your-key-here",
"DEEPSEEK_MODEL": "deepseek-chat", "DEEPSEEK_MODEL": "deepseek-v4-flash",
"ENABLE_MEDICAL_RAG": false,
"QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1", "QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"QWEN_API_KEY": "sk-your-key-here", "QWEN_API_KEY": "sk-your-key-here",
"QWEN_VISION_MODEL": "qwen-vl-max", "QWEN_VISION_MODEL": "qwen-vl-max",

View File

@@ -13,8 +13,10 @@ public sealed class AccountDeletionTests
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var userDirectory = Path.Combine(root, "users", userId.ToString("N")); var userDirectory = Path.Combine(root, "users", userId.ToString("N"));
var reportPath = Path.Combine(root, "reports", "owned-report.pdf"); var reportPath = Path.Combine(root, "reports", "owned-report.pdf");
var chatImagePath = Path.Combine(root, "owned-chat.jpg"); var chatFileName = $"{Guid.NewGuid()}.jpg";
var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().ToString("N"), "keep.jpg"); var chatImagePath = Path.Combine(userDirectory, chatFileName);
var otherUserId = Guid.NewGuid();
var otherUserPath = Path.Combine(root, "users", otherUserId.ToString("N"), "keep.jpg");
var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt"); var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt");
try try
@@ -31,7 +33,7 @@ public sealed class AccountDeletionTests
var cleanup = new LocalAccountFileCleanup(root); var cleanup = new LocalAccountFileCleanup(root);
var references = new AccountFileReferences( var references = new AccountFileReferences(
["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"], ["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"],
["{\"imageUrl\":\"/uploads/owned-chat.jpg\"}"]); [$"{{\"imageUrl\":\"/uploads/users/{userId:N}/{chatFileName}\"}}", $"{{\"imageUrl\":\"/uploads/users/{otherUserId:N}/keep.jpg\"}}"]);
await cleanup.DeleteAsync(userId, references, CancellationToken.None); await cleanup.DeleteAsync(userId, references, CancellationToken.None);

View File

@@ -6,6 +6,8 @@ using Health.Application.Exercises;
using Health.Domain; using Health.Domain;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums; using Health.Domain.Enums;
using Health.Infrastructure.AI.AgentHandlers;
using System.Text.Json;
namespace Health.Tests; namespace Health.Tests;
@@ -95,6 +97,66 @@ public sealed class ApplicationServiceTests
Assert.Equal(1, repository.SaveCount); Assert.Equal(1, repository.SaveCount);
} }
[Fact]
public async Task HealthEntryBatch_ExecutesAllMetricsWithOneSave()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
using var arguments = JsonDocument.Parse(
"""
{
"metrics": [
{ "type": "blood_pressure", "systolic": 113, "diastolic": 86 },
{ "type": "heart_rate", "heart_rate": 80 },
{ "type": "glucose", "glucose": 5.6 }
]
}
""");
var result = await HealthDataAgentHandler.Execute(
"record_health_data_batch",
arguments.RootElement,
Guid.NewGuid(),
service,
CancellationToken.None);
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.True(resultJson.RootElement.GetProperty("success").GetBoolean());
Assert.Equal(3, resultJson.RootElement.GetProperty("record_ids").GetArrayLength());
Assert.Equal(3, resultJson.RootElement.GetProperty("items").GetArrayLength());
Assert.Equal(3, repository.Added.Count);
Assert.Equal(1, repository.SaveCount);
}
[Fact]
public async Task HealthEntryBatch_IncompleteMetricBlocksWholeBatch()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
using var arguments = JsonDocument.Parse(
"""
{
"metrics": [
{ "type": "blood_pressure", "systolic": 113 },
{ "type": "heart_rate", "heart_rate": 80 }
]
}
""");
var result = await HealthDataAgentHandler.Execute(
"record_health_data_batch",
arguments.RootElement,
Guid.NewGuid(),
service,
CancellationToken.None);
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.False(resultJson.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("舒张压", resultJson.RootElement.GetProperty("message").GetString());
Assert.Empty(repository.Added);
Assert.Equal(0, repository.SaveCount);
}
[Fact] [Fact]
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery() public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
{ {
@@ -211,6 +273,24 @@ public sealed class ApplicationServiceTests
Assert.Equal(["medication", "exercise", "followup"], events); Assert.Equal(["medication", "exercise", "followup"], events);
} }
[Fact]
public async Task Calendar_FollowUpUsesBeijingDateForUtcTimestamp()
{
var followUp = new FollowUp
{
Id = Guid.NewGuid(),
ScheduledAt = new DateTime(2026, 6, 18, 17, 0, 0, DateTimeKind.Utc),
Title = "北京时间复查"
};
var service = new CalendarService(new FakeCalendarRepository(
new CalendarDataSnapshot([], [], [followUp])));
var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None);
var target = Assert.Single(result);
Assert.Equal("2026-06-19", target.GetType().GetProperty("date")!.GetValue(target));
}
[Fact] [Fact]
public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates() public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates()
{ {
@@ -252,6 +332,129 @@ public sealed class ApplicationServiceTests
Assert.Equal(2, current.Items.Count); Assert.Equal(2, current.Items.Count);
} }
[Fact]
public async Task AiExerciseQuery_UpcomingScopeReturnsFuturePlanButTodayScopeDoesNot()
{
var userId = Guid.NewGuid();
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var repository = new FakeExerciseRepository();
var plan = new ExercisePlan
{
Id = Guid.NewGuid(),
UserId = userId,
StartDate = today.AddDays(2),
EndDate = today.AddDays(4),
ReminderTime = new TimeOnly(19, 0),
};
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(),
ScheduledDate = today.AddDays(2),
ExerciseType = "散步",
DurationMinutes = 30,
});
repository.SetPlan(plan);
var service = new ExerciseService(repository);
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
using var todayArgs = JsonDocument.Parse("""{"action":"query","scope":"today"}""");
var upcoming = await ExerciseAgentHandler.Execute(
"manage_exercise", upcomingArgs.RootElement, userId, service, CancellationToken.None);
var todayResult = await ExerciseAgentHandler.Execute(
"manage_exercise", todayArgs.RootElement, userId, service, CancellationToken.None);
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
using var todayJson = JsonDocument.Parse(JsonSerializer.Serialize(todayResult, jsonOptions));
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32());
}
[Fact]
public async Task AiExerciseQuery_AuthoritativeAnswerPreservesStoredExerciseType()
{
var userId = Guid.NewGuid();
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var repository = new FakeExerciseRepository();
var plan = new ExercisePlan
{
Id = Guid.NewGuid(),
UserId = userId,
StartDate = today,
EndDate = today.AddDays(6),
ReminderTime = new TimeOnly(15, 0),
};
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(),
ScheduledDate = today,
ExerciseType = "打篮球",
DurationMinutes = 30,
});
repository.SetPlan(plan);
var service = new ExerciseService(repository);
using var arguments = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
var result = await ExerciseAgentHandler.Execute(
"manage_exercise", arguments.RootElement, userId, service, CancellationToken.None);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
var answer = json.RootElement.GetProperty("authoritative_answer").GetString();
Assert.Contains("打篮球", answer);
Assert.DoesNotContain("打毽子", answer);
Assert.Contains("15:00", answer);
}
[Fact]
public void MedicationEntry_AcceptsPillCountAsDosage()
{
using var arguments = JsonDocument.Parse(
"""
{
"action": "create",
"name": "维生素D",
"dosage": "1粒",
"frequency": "Daily",
"time_of_day": ["12:00"],
"start_date": "2026-07-27",
"duration_days": 3
}
""");
var validationError = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
Assert.Null(validationError);
}
[Fact]
public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday()
{
var userId = Guid.NewGuid();
var service = new ExerciseService(new FakeExerciseRepository());
using var args = JsonDocument.Parse("""{"action":"query"}""");
var result = await ExerciseAgentHandler.Execute(
"manage_exercise", args.RootElement, userId, service, CancellationToken.None);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task AiFollowUpQuery_RequiresExplicitScopeInsteadOfAssumingNext()
{
using var args = JsonDocument.Parse("""{}""");
var result = await PatientReadAgentHandler.QueryFollowUpsAsync(
null!, Guid.NewGuid(), args.RootElement, CancellationToken.None);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
}
[Fact] [Fact]
public async Task ExercisePlan_CheckInRejectsNonTodayItem() public async Task ExercisePlan_CheckInRejectsNonTodayItem()
{ {
@@ -360,9 +563,11 @@ public sealed class ApplicationServiceTests
public void SetPlan(ExercisePlan plan) => Plan = plan; public void SetPlan(ExercisePlan plan) => Plan = plan;
public Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>( public Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []); Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []);
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>([]); public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
Plan != null && Plan.UserId == userId ? [Plan] : []);
public Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
Plan != null && Plan.UserId == userId ? [Plan] : []);
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan); public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan);
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) => Task.FromResult(Plan);
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId)); public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId));
public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; } public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; }
public void Delete(ExercisePlan plan) { Plan = null; } public void Delete(ExercisePlan plan) { Plan = null; }

View File

@@ -1,4 +1,6 @@
using System.Text.Json;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Infrastructure.Auth;
using Health.Infrastructure.Data; using Health.Infrastructure.Data;
using Health.Infrastructure.Services; using Health.Infrastructure.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -118,6 +120,47 @@ public class AuthTests
Assert.NotNull(active); Assert.NotNull(active);
} }
[Fact]
public async Task Refresh_Should_Return_Stable_User_Identity()
{
using var db = CreateDbContext();
var config = CreateConfig();
var jwt = new JwtProvider(config);
var user = new User
{
Id = Guid.NewGuid(),
Phone = "13800138000",
Role = "User",
Name = "测试用户",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
var oldRefresh = jwt.GenerateRefreshToken();
db.Users.Add(user);
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = oldRefresh,
ExpiresAt = DateTime.UtcNow.AddDays(30)
});
await db.SaveChangesAsync();
var service = new AuthService(
db,
jwt,
new SmsService(),
new AppleTokenValidator(config));
var result = await service.RefreshAsync(oldRefresh, CancellationToken.None);
Assert.Equal(0, result.Code);
var data = JsonSerializer.SerializeToElement(result.Data);
var refreshedUser = data.GetProperty("user");
Assert.Equal(user.Id, refreshedUser.GetProperty("Id").GetGuid());
Assert.Equal(user.Phone, refreshedUser.GetProperty("Phone").GetString());
Assert.Equal(user.Role, refreshedUser.GetProperty("Role").GetString());
}
[Fact] [Fact]
public async Task VerificationCode_Expired_Should_Fail_Login() public async Task VerificationCode_Expired_Should_Fail_Login()
{ {

View File

@@ -0,0 +1,40 @@
using Health.Infrastructure.Files;
namespace Health.Tests;
public sealed class FilePathSecurityTests
{
private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private readonly string _fileName = "22222222-2222-2222-2222-222222222222.jpg";
[Fact]
public void ProtectedUrl_ResolvesInsideCurrentUserDirectory()
{
var path = UserUploadPathResolver.Resolve(
_userId,
$"/api/files/content/{_fileName}");
Assert.NotNull(path);
Assert.Contains(Path.Combine("users", _userId.ToString("N")), path);
Assert.EndsWith(_fileName, path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void LegacyUrl_OnlyResolvesForItsOwner()
{
var legacy = $"/uploads/users/{_userId:N}/{_fileName}";
Assert.NotNull(UserUploadPathResolver.Resolve(_userId, legacy));
Assert.Null(UserUploadPathResolver.Resolve(Guid.NewGuid(), legacy));
}
[Theory]
[InlineData("/api/files/content/../../secret.jpg")]
[InlineData("/api/files/content/not-a-guid.jpg")]
[InlineData("/api/files/content/22222222-2222-2222-2222-222222222222.exe")]
[InlineData("/uploads/reports/22222222-2222-2222-2222-222222222222.jpg")]
public void UnsafeOrUnownedPath_IsRejected(string value)
{
Assert.Null(UserUploadPathResolver.Resolve(_userId, value));
}
}

View File

@@ -1,5 +1,8 @@
using Health.Application.Medications; using Health.Application.Medications;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Infrastructure.AI.AgentHandlers;
using System.Text.Json;
namespace Health.Tests; namespace Health.Tests;
@@ -32,21 +35,227 @@ public sealed class MedicationUpdateTests
Assert.True(repository.Saved); Assert.True(repository.Saved);
} }
private sealed class FakeMedicationRepository(Medication medication) : IMedicationRepository [Fact]
public async Task AiOverview_DoesNotScheduleFutureMedicationForToday()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "未来用药",
IsActive = true,
StartDate = today.AddDays(2),
EndDate = today.AddDays(10),
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
};
var service = new MedicationService(new FakeMedicationRepository(medication));
var overview = await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None);
var plan = Assert.Single(overview);
Assert.Equal("upcoming", plan.Phase);
Assert.False(plan.ScheduledOnDate);
Assert.Empty(plan.Doses);
}
[Fact]
public async Task AiOverview_ReportsEachDoseInsteadOfAnyTakenAggregate()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "两顿药",
IsActive = true,
StartDate = today.AddDays(-1),
EndDate = today.AddDays(1),
Frequency = MedicationFrequency.TwiceDaily,
TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)],
};
var log = new MedicationLog
{
Id = Guid.NewGuid(),
UserId = medication.UserId,
MedicationId = medication.Id,
ScheduledTime = new TimeOnly(8, 0),
Status = MedicationLogStatus.Taken,
ConfirmedAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
};
var service = new MedicationService(new FakeMedicationRepository(medication, [log]));
var plan = Assert.Single(await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None));
Assert.Equal(2, plan.Doses.Count);
Assert.Equal("taken", plan.Doses.Single(dose => dose.ScheduledTime == "08:00").Status);
Assert.NotEqual("taken", plan.Doses.Single(dose => dose.ScheduledTime == "20:00").Status);
}
[Fact]
public async Task ConfirmDose_RejectsAClockTimeThatIsNotInTodaysPlan()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "阿司匹林",
IsActive = true,
StartDate = today,
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
};
var repository = new FakeMedicationRepository(medication);
var service = new MedicationService(repository);
var result = await service.ConfirmDoseAsync(
medication.UserId,
medication.Id,
new TimeOnly(8, 15),
MedicationLogStatus.Taken,
CancellationToken.None);
Assert.False(result);
Assert.Empty(repository.AddedLogs);
}
[Fact]
public void AiMedicationCreate_RejectsMissingFieldsInsteadOfUsingDefaults()
{
using var arguments = JsonDocument.Parse("""{"action":"create","name":""}""");
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
Assert.Equal("创建用药计划前需要确认每次剂量", error);
}
[Fact]
public void AiMedicationCreate_AcceptsCompleteFinitePlan()
{
using var arguments = JsonDocument.Parse("""
{
"action":"create",
"name":"阿司匹林",
"dosage":"100mg",
"frequency":"Daily",
"time_of_day":["08:00"],
"start_date":"2026-07-20",
"duration_days":30
}
""");
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
Assert.Null(error);
}
[Fact]
public async Task AiMedicationQuery_UpcomingScopeReturnsTomorrowPlanButCurrentScopeDoesNot()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "明天开始的药",
Dosage = "1片",
IsActive = true,
StartDate = today.AddDays(1),
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
};
var service = new MedicationService(new FakeMedicationRepository(medication));
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
using var currentArgs = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
var upcoming = await MedicationAgentHandler.Execute(
"manage_medication", upcomingArgs.RootElement, medication.UserId, service, CancellationToken.None);
var current = await MedicationAgentHandler.Execute(
"manage_medication", currentArgs.RootElement, medication.UserId, service, CancellationToken.None);
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
using var currentJson = JsonDocument.Parse(JsonSerializer.Serialize(current, jsonOptions));
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
Assert.True(upcomingJson.RootElement.GetProperty("has_upcoming_plans").GetBoolean());
Assert.Equal(1, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
Assert.Equal("明天开始的药", upcomingJson.RootElement.GetProperty("plans")[0].GetProperty("name").GetString());
Assert.Equal(0, currentJson.RootElement.GetProperty("count").GetInt32());
}
[Fact]
public async Task AiMedicationQuery_RequiresExplicitScopeInsteadOfAssumingToday()
{
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "测试药物",
IsActive = true,
StartDate = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
};
var service = new MedicationService(new FakeMedicationRepository(medication));
using var args = JsonDocument.Parse("""{"action":"query"}""");
var result = await MedicationAgentHandler.Execute(
"manage_medication", args.RootElement, medication.UserId, service, CancellationToken.None);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task AiMedicationQuery_SeparatesInactivePlansFromNaturallyEndedPlans()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "已停用药物",
IsActive = false,
StartDate = today.AddDays(-10),
EndDate = today.AddDays(10),
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
};
var service = new MedicationService(new FakeMedicationRepository(medication));
using var inactiveArgs = JsonDocument.Parse("""{"action":"query","scope":"inactive_plans"}""");
using var endedArgs = JsonDocument.Parse("""{"action":"query","scope":"ended_plans"}""");
var inactive = await MedicationAgentHandler.Execute(
"manage_medication", inactiveArgs.RootElement, medication.UserId, service, CancellationToken.None);
var ended = await MedicationAgentHandler.Execute(
"manage_medication", endedArgs.RootElement, medication.UserId, service, CancellationToken.None);
using var inactiveJson = JsonDocument.Parse(JsonSerializer.Serialize(inactive));
using var endedJson = JsonDocument.Parse(JsonSerializer.Serialize(ended));
Assert.Equal(1, inactiveJson.RootElement.GetProperty("count").GetInt32());
Assert.Equal(0, endedJson.RootElement.GetProperty("count").GetInt32());
}
private sealed class FakeMedicationRepository(
Medication medication,
IReadOnlyList<MedicationLog>? logs = null) : IMedicationRepository
{ {
public bool Saved { get; private set; } public bool Saved { get; private set; }
public List<MedicationLog> AddedLogs { get; } = [];
public Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]); public Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]); public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<IReadOnlyList<MedicationLog>>([]); public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(logs ?? (IReadOnlyList<MedicationLog>)[]);
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null); public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null);
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId);
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false); public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false);
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null); public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]); public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask; public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask;
public Task AddLogAsync(MedicationLog log, CancellationToken ct) => Task.CompletedTask; public Task AddLogAsync(MedicationLog log, CancellationToken ct) { AddedLogs.Add(log); return Task.CompletedTask; }
public void DeleteMedication(Medication value) { } public void DeleteMedication(Medication value) { }
public void DeleteLog(MedicationLog log) { } public void DeleteLog(MedicationLog log) { }
public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; } public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; }

View File

@@ -15,4 +15,33 @@ public sealed class PromptManagerTests
Assert.Contains("先回答问题,不调用 record_health_data", prompt); Assert.Contains("先回答问题,不调用 record_health_data", prompt);
Assert.Contains("帮我记录血压116/89", prompt); Assert.Contains("帮我记录血压116/89", prompt);
} }
[Fact]
public void UnifiedPrompt_RequiresFreshToolsForPersonalStatusAndKeepsArchiveReadOnly()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
Assert.Contains("必须先调用对应只读工具", prompt);
Assert.Contains("当前工具结果是最高优先级", prompt);
Assert.Contains("聊天中不修改健康档案", prompt);
Assert.DoesNotContain("健康档案查询/修改", prompt);
}
[Fact]
public void UnifiedPrompt_UsesACommonTemporalIntentModelForPlanQueries()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
Assert.Contains("时间意图 + 业务对象", prompt);
Assert.Contains("某一天的任务", prompt);
Assert.Contains("计划所处的生命周期", prompt);
Assert.Contains("不依赖某一句固定问法或某个单独关键词", prompt);
Assert.Contains("scope=\"upcoming_plans\"", prompt);
Assert.Contains("scope=\"ended_plans\"", prompt);
Assert.Contains("scope=\"inactive_plans\"", prompt);
Assert.Contains("scope=\"scheduled_date\"", prompt);
Assert.Contains("复查使用同一语义映射", prompt);
Assert.Contains("scope 必须明确传入", prompt);
Assert.DoesNotContain("患者背景只包含当前日期范围内的部分计划", prompt);
}
} }

View File

@@ -1,219 +0,0 @@
# 小脉健康项目交接报告2026-07-17
## 1. 本轮工作范围
本轮工作集中在以下三项:
1. 对医生端和管理员端进行患者端风格的轻量统一。
2. 检查并修复医生、登录账号、医生资料之间的同步逻辑。
3. 进行源码级逻辑检查和小范围自动化验证。
本轮没有生成 APK、没有进行真机或截图测试、没有启动 Web API、没有部署、没有推送远程代码也没有修改真实数据库数据。
## 2. 医生端与管理员端 UI 改造
### 2.1 共用视觉层
新增后台共用展示组件:
- `health_app/lib/widgets/backoffice_ui.dart`
- `health_app/lib/utils/backoffice_formatters.dart`
- `health_app/lib/providers/backoffice_refresh_providers.dart`
统一内容包括:
- 使用患者端现有的紫蓝渐变和浅色页面背景。
- 使用白色圆角卡片、轻边框和统一阴影。
- 统一加载、空数据、加载失败和重试状态。
- 统一管理员端、医生端侧边栏的选中态。
- 对空姓名头像和不完整时间字符串进行安全展示。
### 2.2 医生端
已覆盖的主要页面:
- 工作台
- 患者列表与患者详情
- 问诊列表
- 报告列表与报告详情
- 随访列表与新增/编辑随访
- 医生资料
- 医生设置
- 医生侧边栏
已修复的页面问题:
- “新建随访”以前会被路由器当成缺少 ID 的详情页,现在可以正常进入新建模式。
- 新增或编辑随访后,返回列表会自动刷新。
- 随访和报告日期不再直接强制截取 16 个字符,避免空值或短字符串导致 `RangeError`
- 空患者姓名不再因为直接读取第一个字符而导致崩溃。
- 患者列表加载失败后不再反复自动请求。
- 医生资料页面重建时不再反复覆盖用户正在编辑的输入内容。
- 停用医生登录后,工作台显示“账号已停用,新患者暂时无法选择您”的提示。
### 2.3 管理员端
已覆盖的主要页面:
- 医生管理
- 患者管理
- 新增医生
- 编辑医生
- 管理员侧边栏
已修复的页面问题:
- 管理员接口失败时不再无限显示加载状态。
- 医生列表增加“编辑”入口,复用新增医生表单。
- 新增或编辑医生后,返回列表会自动刷新。
- 停用和删除失败时显示后端返回的具体原因,不再静默失败。
- 删除确认文案改为安全规则:有绑定患者或问诊记录时只能停用,不能删除。
- 停用医生在管理员列表中继续显示“已停用”状态。
## 3. 医生账号三层数据同步
医生相关数据由以下三部分组成:
- `Doctor`:患者选择、医生展示和业务归属。
- `User(Role=Doctor)`:医生登录账号。
- `DoctorProfile`:医生端资料和医生实体关联。
本轮修改了 `backend/src/Health.Infrastructure/Admin/AdminService.cs`,规则如下。
### 3.1 新增医生
- 一次性创建 `Doctor`、医生登录 `User``DoctorProfile`
- 三者使用相同的手机号和姓名,并建立正确 ID 关联。
- 手机号已被患者、医生或其他账号使用时拒绝新增。
### 3.2 编辑医生
- 手机号同步更新 `Doctor` 和登录 `User`
- 姓名同步更新 `Doctor`、登录 `User``DoctorProfile`
- 职称、科室同步更新 `Doctor``DoctorProfile`
- 专业方向更新到 `Doctor`
- 修改手机号前检查其他账号和医生是否已经占用。
- 医生登录资料缺失时拒绝局部修改,避免继续扩大不一致数据。
### 3.3 停用医生
- 同步更新 `Doctor.IsActive``DoctorProfile.IsActive`
- 医生登录账号保持 `Role=Doctor`,仍可正常登录并服务已绑定患者。
- 患者与医生的原有绑定关系保持不变。
- 公开医生列表原本已经只查询 `Doctor.IsActive == true`,因此新患者注册时不会再看到已停用医生。
- 注册接口原本已经再次校验医生必须有效且启用。
### 3.4 删除医生
- 医生仍有绑定患者时拒绝删除。
- 医生已有问诊记录时拒绝删除。
- 无绑定患者和问诊记录时,删除 `Doctor`、对应登录 `User``DoctorProfile` 和刷新令牌。
- 有历史数据的医生应使用“停用”,不应通过物理删除清理。
## 4. 自动化验证
本轮最终验证结果:
- `flutter analyze`:通过,零问题。
- 后端 `Health.Tests`56 项通过0 项失败0 项跳过。
- 医生/管理员 UI、路由和刷新相关测试通过。
新增或更新的测试包括:
- `backend/tests/Health.Tests/admin_service_tests.cs`
- `health_app/test/backoffice_ui_test.dart`
- `health_app/test/backoffice_formatters_test.dart`
- `health_app/test/backoffice_refresh_test.dart`
- `health_app/test/app_router_test.dart`
说明:`dotnet test` 会自动编译测试程序集,但本轮没有执行发布构建,没有生成 Android APK/AAB也没有执行 iOS 构建。
## 5. 数据库核对结果
本轮只进行了只读连接检查,没有修改数据库。
- 项目本地配置指向 `localhost:5432 / health_manager`
- 本机 PostgreSQL 没有运行,因此无法读取本地实际数据。
- 开发 API `http://10.4.237.12:5000` 当前无法连接。
- 正式 API `https://erpapi.datalumina.cn/xiaomai/api/doctors` 可以访问,但公开接口返回的启用医生数量为 0。
- 交接资料显示正式数据库和备份方案尚未最终配置。
因此目前无法确认数据库中是否已经存在以下旧数据问题:
- `Doctor` 存在但没有医生登录 `User`
- `DoctorProfile` 缺失或关联错误。
- 三层手机号、姓名或启用状态不一致。
- 重复医生手机号。
- 患者 `DoctorId` 指向不存在的医生。
新代码可以防止以后继续产生这些不一致,但不会自动修改已有数据。获得生产 PostgreSQL 只读连接或实际数据库备份后,应单独执行数据核对。
## 6. 仍未解决的高优先级问题
### P0固定管理员验证码
`backend/src/Health.Infrastructure/Auth/AuthService.cs` 仍保留固定管理员手机号和固定验证码 `000000`。上线前必须移除,管理员需要使用受控的创建和认证方式。
### P0短信服务仍是开发实现
`backend/src/Health.Infrastructure/Services/sms_service.cs` 只向服务端控制台输出验证码,没有接入真实短信服务。生产环境不会把验证码返回 App因此普通用户无法正常接收验证码。
### P0问诊 SignalR Hub 未鉴权
`backend/src/Health.WebApi/Hubs/ConsultationHub.cs` 没有强制 JWT 身份验证,也没有校验用户或医生是否属于指定问诊。调用者可以传入 `senderType``senderName`,存在加入其他问诊、伪造医生消息和写入数据库的风险。
### P1医生聊天错误复用患者聊天流程
医生问诊列表进入的 `DoctorChatPage` 复用了患者端 `consultationChatProvider`
- 把问诊 ID 当成医生 ID。
- 调用患者配额接口。
- 尝试创建新问诊。
- 发送消息时使用患者身份。
- 医生需要回复的 `WaitingDoctor` 状态反而不能发送。
医生实时问诊目前不能视为可用功能。需要拆分独立医生聊天 Provider 和页面,并与 Hub 鉴权一起处理。
### P1AI SSE 鉴权和上传文件隐私
此前检查发现:
- AI SSE 接口允许从 query token 中直接读取 JWT claims没有完整验证签名、签发者、受众和有效期。
- 上传的医疗图片和报告通过 `/uploads` 静态公开访问。
- 附件本地路径解析缺少完整的目录边界和文件归属校验。
- CORS 当前允许任意来源并携带凭据。
这些问题应在正式上线前统一修复。
### P1iOS 蓝牙流程仍需修复和真机验证
此前检查发现 iOS 页面仍请求 Android 风格蓝牙权限,并调用仅 Android 支持的 `FlutterBluePlus.turnOn()`。iOS Pods 也需要在 macOS 上重新安装并验证。当前 Windows 环境不能证明 iOS 构建和真机蓝牙可用。
## 7. Apple 医疗硬件审核准备
如果首版明确只支持欧姆龙 J735建议准备
- J735 当前有效的医疗器械注册或批准材料。
- 小脉健康 App 与 J735 的联调测试报告。
- 真实 iPhone、J735 和当前版本 App 的完整配对及测量演示视频。
- App、审核说明、兼容设备清单和宣传文案全部明确首版支持范围。
注册证只能证明血压计本身合规,不能替代 App 与硬件联调测试报告和演示视频。
## 8. 建议后续顺序
1. 修复固定管理员验证码并接入正式短信服务。
2. 拆分医生聊天流程并为 SignalR Hub 增加 JWT、角色和问诊归属校验。
3. 修复 AI SSE 鉴权、上传文件访问控制、路径边界和 CORS。
4. 获得真实数据库只读连接,核对并修复旧医生账号数据。
5. 在 macOS 和真实 iPhone 上修复并验证 iOS 蓝牙流程。
6. 准备 J735 监管材料、联调报告和真实设备演示视频。
7. 完成正式服务器、数据库、短信、对象存储、域名和 HTTPS 配置后,再进入发布构建和应用商店提交。
## 9. 当前操作边界
- 本轮代码修改和本交接报告均未进行新的 Git 提交或远程推送。
- 没有执行数据库迁移。
- 没有写入、更新或删除真实数据库数据。
- 没有生成或上传发布安装包。
- 没有启动需要额外关闭的前端或后端常驻服务。

View File

@@ -0,0 +1,526 @@
# 小脉健康医疗引用资料库 V3
> 仅用于 AI 医疗信息和健康建议的来源引用。
>
> 当前 App 相关主题:手动健康数据记录、饮食识别、运动计划、用药提醒、医学报告预整理、危险症状安全提醒。
---
## 血压和血压记录
### [SOURCE:S-BP-01]
来源标题2024 ESC Guidelines for the management of elevated blood pressure and hypertension
来源机构European Society of Cardiology Scientific Document Group
PubMedhttps://pubmed.ncbi.nlm.nih.gov/39210715/
PMID39210715
可引用内容:
- 血压解释应结合多次记录、测量条件、症状和临床评估;
- 单次血压记录不能单独诊断高血压;
- 家庭或院外血压记录可以帮助观察一段时间内的变化;
- 血压记录应交由医生结合个人情况解释;
- 不应根据一次读数自行改变降压药物。
适用回答:
- “这次血压记录怎么看”;
- “血压偏高/偏低怎么办”;
- “需要不要复测”;
- “血压趋势如何理解”。
不支持:
- 根据一次读数确诊高血压;
- 给出个人治疗目标;
- 建议加药、减药或停药。
推荐引用格式:
```text
参考来源:
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
https://pubmed.ncbi.nlm.nih.gov/39210715/
```
---
## 血糖和低血糖
### [SOURCE:S-GLU-01]
来源标题6. Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes-2025
来源机构American Diabetes Association Professional Practice Committee
PubMedhttps://pubmed.ncbi.nlm.nih.gov/39651981/
PMID39651981
可引用内容:
- 血糖目标需要结合个人情况、低血糖风险、支持条件和治疗目标;
- 不能把同一个血糖目标直接套用到所有用户;
- 血糖结果应结合测量时间、空腹或餐后状态、既往疾病和用药情况理解;
- 血糖异常记录应交给医生结合个人情况评估;
- AI 不能根据一次血糖记录诊断糖尿病或决定调整药物。
适用回答:
- “这次血糖值怎么理解”;
- “空腹/餐后血糖记录”;
- “血糖目标是否适合我”;
- “血糖偏低或偏高”。
不支持:
- 给所有用户设定统一目标;
- 诊断糖尿病;
- 指导自行调整降糖药。
推荐引用格式:
```text
参考来源:
[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes-2025
https://pubmed.ncbi.nlm.nih.gov/39651981/
```
---
## 血氧
### [SOURCE:S-OXY-01]
来源标题Pulse Oximeter Basics
来源机构U.S. Food and Drug Administration
来源https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
可引用内容:
- 血氧仪读数是对血氧水平的估计,不是完整的临床诊断;
- 读数应结合症状和变化趋势理解,不能只看单个数字;
- 手部温度、活动、循环、皮肤色素、指甲油和设备因素可能影响读数;
- 呼吸困难、胸痛、嘴唇或指甲发青等症状需要及时联系医疗服务;
- 不能仅凭血氧读数自行调整药物或氧疗。
适用回答:
- “血氧记录怎么理解”;
- “血氧偏低是否需要复测”;
- “血氧数值和症状的关系”;
- “为什么同一个人不同时间读数不同”。
不支持:
- 仅凭一个血氧值诊断或排除疾病;
- 指导自行吸氧、调氧或调药;
- 宣称某个单独数值对所有人都绝对安全或危险。
推荐引用格式:
```text
参考来源:
[S-OXY-01] Pulse Oximeter Basics, U.S. FDA
https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
```
---
## 饮食和饮食图片识别
### [SOURCE:S-DIET-01]
来源标题Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
来源机构American Heart Association Council on Lifestyle and Cardiometabolic Health
PubMedhttps://pubmed.ncbi.nlm.nih.gov/37128940/
PMID37128940
可引用内容:
- 地中海饮食、DASH 饮食、素食和鱼素饮食等模式总体上与心血管健康饮食原则较为一致;
- 饮食模式应考虑个人偏好、文化、预算和长期可执行性;
- 饮食图片不能准确确定食物重量、实际份量、烹调油、调味料和完整配方;
- AI 识别出的盐、糖、脂肪和热量只能作为一般估计;
- 一般饮食信息不能替代针对疾病、过敏或用药情况的营养评估。
适用回答:
- “这张图片里有什么食物”;
- “这餐可能含盐/糖/脂肪较多吗”;
- “这餐热量为什么只能估算”;
- “一般心血管健康饮食有哪些原则”。
不支持:
- 根据用户疾病档案制定饮食处方;
- 说某个食物对所有患者都“绝对不能吃”;
- 声称某种饮食可以治疗疾病;
- 根据一张图片精确计算摄入量。
推荐引用格式:
```text
参考来源:
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
https://pubmed.ncbi.nlm.nih.gov/37128940/
```
### [SOURCE:S-DIET-02]
来源标题Diet and nutrition in cardiovascular disease prevention
来源机构European Society of Cardiology 相关预防心脏病学组织
PubMedhttps://pubmed.ncbi.nlm.nih.gov/40504596/
PMID40504596
可引用内容:
- 饮食是心血管疾病预防的重要组成部分;
- 富含蔬菜水果、较少加工食品的饮食模式总体上更符合心血管预防原则;
- 盐、糖、饱和脂肪和高度加工食品应作为饮食评估时的客观因素;
- 饮食信息应作为一般健康信息,不应直接替代个体化营养治疗。
适用回答:
- 食物营养特征说明;
- 饮食记录总结;
- 一般心血管饮食知识。
不支持:
- 针对某个患者制定每日热量、盐量或营养素处方;
- 根据疾病和药物直接判断某道食物“禁止食用”。
推荐引用格式:
```text
参考来源:
[S-DIET-02] Diet and nutrition in cardiovascular disease prevention
https://pubmed.ncbi.nlm.nih.gov/40504596/
```
---
## 运动计划
### [SOURCE:S-EX-01]
来源标题Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
来源机构American Heart Association
PubMedhttps://pubmed.ncbi.nlm.nih.gov/38059362/
PMID38059362
可引用内容:
- 抗阻训练可以作为成年人健康活动的一部分;
- 运动计划应考虑个人健康状态、运动能力和安全性;
- 普通健康信息不能直接变成心血管疾病患者的医学运动处方;
- 有胸痛、晕厥、明显呼吸困难或医生限制运动时,应先进行医疗评估。
适用回答:
- 创建或记录运动计划;
- 解释运动计划的记录内容;
- 一般运动安全提醒。
不支持:
- 针对心血管疾病制定具体训练处方;
- 在有危险症状时建议继续运动;
- 根据一次运动记录判断治疗效果。
推荐引用格式:
```text
参考来源:
[S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
https://pubmed.ncbi.nlm.nih.gov/38059362/
```
### [SOURCE:S-EX-02]
来源标题Core Components of Cardiac Rehabilitation Programs: 2024 Update
来源机构American Heart Association / American Association of Cardiovascular and Pulmonary Rehabilitation
PubMedhttps://pubmed.ncbi.nlm.nih.gov/39315436/
PMID39315436
可引用内容:
- 心脏康复是结构化、专业管理的过程;
- 运动安排需要结合评估、教育、危险因素管理和随访;
- AI 的运动计划记录功能不能替代心脏康复团队。
适用回答:
- 心脏康复相关问题;
- 已知心血管疾病用户的运动安全提醒;
- 建议用户咨询康复专业人员。
不支持:
- AI 自行制定心脏康复处方;
- 根据聊天内容判断用户可以进行何种运动强度。
推荐引用格式:
```text
参考来源:
[S-EX-02] Core Components of Cardiac Rehabilitation Programs: 2024 Update
https://pubmed.ncbi.nlm.nih.gov/39315436/
```
---
## 用药提醒
### [SOURCE:S-MED-01]
来源标题Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
来源机构National Institute for Health and Care Excellence
PubMedhttps://pubmed.ncbi.nlm.nih.gov/39480983/
PMID39480983
可引用内容:
- 用药依从性需要了解患者的实际困难、偏好和需求;
- 共同决策和清晰沟通有助于支持患者执行已经确定的用药方案;
- App 可以帮助记录用药计划、查询任务和提供提醒;
- 是否开始、停止、更换或调整药物,应由医生或药师决定。
适用回答:
- 用药计划记录;
- 服药提醒;
- 查询某日已保存的用药任务;
- 建议用户对药物疑问咨询医生或药师。
不支持:
- 停药、换药、加药、减药;
- 自行决定漏服后的补服方法;
- 根据症状猜测药物副作用或替代治疗。
推荐引用格式:
```text
参考来源:
[S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
https://pubmed.ncbi.nlm.nih.gov/39480983/
```
---
## 医学报告预整理
### [SOURCE:S-REPORT-01]
来源标题Interpretating Normal Values and Reference Ranges for Laboratory Tests
PubMedhttps://pubmed.ncbi.nlm.nih.gov/40268322/
PMID40268322
可引用内容:
- 实验室参考范围会受到检测方法、实验室、人群和生理因素影响;
- 报告标注为高或低,不等于已经诊断疾病;
- 参考范围内也不能单独排除疾病;
- 报告应结合症状、病史和其他检查结果由医生解释;
- AI 可以整理报告已有的项目、数值、单位和原始参考范围。
适用回答:
- 报告指标提取;
- 报告中的高/低标记说明;
- 提醒用户核对原报告;
- 提醒用户让医生解释报告。
不支持:
- 根据一项异常指标推断病因;
- 根据影像或检验图片诊断疾病;
- 根据 AI 摘要决定治疗。
推荐引用格式:
```text
参考来源:
[S-REPORT-01] Interpretating Normal Values and Reference Ranges for Laboratory Tests
https://pubmed.ncbi.nlm.nih.gov/40268322/
```
### [SOURCE:S-REPORT-02]
来源标题Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
PubMedhttps://pubmed.ncbi.nlm.nih.gov/29729142/
PMID29729142
可引用内容:
- 参考区间与检测方法和目标人群有关;
- 不同实验室的参考区间不能不加判断地互相替换;
- 解释化验结果时应优先使用报告本身提供的参考范围。
适用回答:
- “为什么不同报告的参考范围不同”;
- “为什么不能直接套用网上正常值”;
- “为什么 AI 只能整理而不能诊断”。
不支持:
- 把通用范围直接作为 App 对所有用户的诊断标准。
推荐引用格式:
```text
参考来源:
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
https://pubmed.ncbi.nlm.nih.gov/29729142/
```
---
## 危险症状和就医提醒
### [SOURCE:S-EM-01]
来源标题2024 American Heart Association and American Red Cross Guidelines for First Aid
来源机构American Heart Association / American Red Cross
来源https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
可引用内容:
- 胸部不适或压迫感、呼吸困难、上肢/背部/颈部/下颌不适、恶心、出汗和头晕可能是需要及时评估的危险信号;
- 面部下垂、单侧手臂或腿无力、言语障碍、突然视物困难、行走困难、失去平衡或突发严重头痛可能提示卒中危险信号;
- 出现突发危险症状时,应立即启动当地急救服务,不要等待 AI 判断;
- AI 不能远程确认心梗、卒中或其他急症。
适用回答:
- 用户描述胸痛、呼吸困难、单侧无力、言语不清、意识异常等情况;
- 健康记录异常同时伴随危险症状;
- 固定安全提醒。
不支持:
- 远程诊断心梗或卒中;
- 用“没有某个症状”排除急症;
- 指导用户自行用药处理急症。
推荐引用格式:
```text
参考来源:
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
```
### [SOURCE:S-EM-02]
来源标题Know the warning signs of heart attack and stroke
来源机构American Heart Association
来源https://international.heart.org/en/-/media/International/Files/Heart-Attack-Stroke-Warning-Signs/HeartAttackandStrokeWarningSignsInternationalEnglish.pdf?sc_lang=en
可引用内容:
- 心脏危险信号包括胸部不适、呼吸困难、上肢/背部/颈部/下颌不适、冷汗、恶心或头晕;
- 卒中可以使用 FAST 思路识别:面部下垂、手臂无力、言语困难、立即呼叫急救;
- 症状即使短暂消失,也应及时寻求医疗帮助。
适用回答:
- 心脏危险信号;
- 卒中危险信号;
- “是否应该立即就医”的安全提醒。
不支持:
- 仅凭文字排除急症;
- 让用户等待 AI 继续分析;
- 把当地急救号码固定写成 911。
推荐引用格式:
```text
参考来源:
[S-EM-02] Know the warning signs of heart attack and stroke
https://international.heart.org/en/-/media/International/Files/Heart-Attack-Stroke-Warning-Signs/HeartAttackandStrokeWarningSignsInternationalEnglish.pdf?sc_lang=en
```
---
## 引用使用示例
### 示例一:血压记录
```text
这条记录是收缩压 X mmHg、舒张压 Y mmHg。单次记录不能单独用于诊断建议结合连续记录和身体症状交给医生评估。
参考来源:
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
https://pubmed.ncbi.nlm.nih.gov/39210715/
```
### 示例二:饮食图片
```text
根据图片,这餐可能含有较多盐或脂肪,但图片无法准确判断实际份量、调味料和烹调油,因此只能作为一般营养估计,不是个性化饮食处方。
参考来源:
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
https://pubmed.ncbi.nlm.nih.gov/37128940/
```
### 示例三:报告结果
```text
报告中该项目被标记为偏高。不同实验室和检测方法的参考范围可能不同,报告中的异常标记不等于疾病诊断,请结合原报告和医生意见判断。
参考来源:
[S-REPORT-01] Interpretating Normal Values and Reference Ranges for Laboratory Tests
https://pubmed.ncbi.nlm.nih.gov/40268322/
```
### 示例四:危险症状
```text
你描述的症状可能需要及时进行医疗评估。请立即联系当地急救服务或前往急诊,不要等待 AI 继续分析,也不要自行开车。
参考来源:
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
```
---
版本V3
整理日期2026-07-26

View File

@@ -36,7 +36,7 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
// 正式签名配置 // 正式签名配置key.properties 不存在时回退到 debug keystore仅用于本地 release 测试
signingConfigs { signingConfigs {
create("release") { create("release") {
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
@@ -46,6 +46,11 @@ android {
file(path) file(path)
} }
storePassword = keystoreProperties.getProperty("storePassword") storePassword = keystoreProperties.getProperty("storePassword")
} else {
keyAlias = "androiddebugkey"
keyPassword = "android"
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
storePassword = "android"
} }
} }
} }

View File

@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/> <uses-feature android:name="android.hardware.camera" android:required="false"/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -70,9 +70,9 @@
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"

View File

@@ -26,12 +26,10 @@
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接支持的血压计等健康设备,以同步用户主动测量的健康数据</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要使用蓝牙连接支持的血压计等健康设备</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string> <string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要使用麦克风将你按住说出的健康问题转换为文字并发送给小脉健康助手</string>
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>需要保存图片到相册</string> <string>需要保存图片到相册</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>

View File

@@ -5,10 +5,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import 'core/app_router.dart'; import 'core/app_router.dart';
import 'core/app_theme.dart'; import 'core/app_theme.dart';
import 'core/elder_mode_scope.dart';
import 'core/navigation_provider.dart'; import 'core/navigation_provider.dart';
import 'pages/splash_page.dart'; import 'pages/splash_page.dart';
import 'providers/auth_provider.dart'; import 'providers/auth_provider.dart';
import 'providers/data_providers.dart'; import 'providers/data_providers.dart';
import 'providers/elder_mode_provider.dart';
/// 健康管家 App 根组件 /// 健康管家 App 根组件
class HealthApp extends ConsumerWidget { class HealthApp extends ConsumerWidget {
@@ -16,10 +18,16 @@ class HealthApp extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final elderMode = ref.watch(elderModeProvider);
final isPatient = auth.isLoggedIn && auth.user?.role == 'User';
final useElderMode = isPatient && elderMode.isEnabled;
final useLargeText = useElderMode && elderMode.preferences.largeTextEnabled;
return MaterialApp( return MaterialApp(
title: '小脉健康', title: '小脉健康',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme, theme: useElderMode ? AppTheme.elderLightTheme : AppTheme.lightTheme,
localizationsDelegates: const [ localizationsDelegates: const [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
@@ -30,12 +38,23 @@ class HealthApp extends ConsumerWidget {
home: const _RootNavigator(), home: const _RootNavigator(),
// 注入 ShadTheme + 启动闸门Splash 盖在最上层,直到首页今日健康卡数据就绪 // 注入 ShadTheme + 启动闸门Splash 盖在最上层,直到首页今日健康卡数据就绪
// 外层包一层 GestureDetector点击任意空白处取消输入框焦点全 app 生效) // 外层包一层 GestureDetector点击任意空白处取消输入框焦点全 app 生效)
builder: (context, child) => ShadTheme( builder: (context, child) => ElderModeScope(
data: AppTheme.shadTheme, enabled: useElderMode,
child: GestureDetector( largeTextEnabled: useLargeText,
behavior: HitTestBehavior.translucent, child: Builder(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(), builder: (scopeContext) => MediaQuery(
child: _BootGate(child: child!), data: MediaQuery.of(
scopeContext,
).copyWith(textScaler: ElderModeScope.textScalerFor(scopeContext)),
child: ShadTheme(
data: AppTheme.shadTheme,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: _BootGate(child: child!),
),
),
),
), ),
), ),
); );
@@ -52,6 +71,7 @@ final appReadyProvider = Provider<bool>((ref) {
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
final role = auth.user?.role ?? 'User'; final role = auth.user?.role ?? 'User';
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡 if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
if (!ref.watch(elderModeProvider).isLoaded) return false;
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死) // 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
final health = ref.watch(latestHealthProvider); final health = ref.watch(latestHealthProvider);
return health.hasValue || health.hasError; return health.hasValue || health.hasError;
@@ -74,11 +94,28 @@ class _BootGateState extends ConsumerState<_BootGate> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_startTimeout();
}
void _startTimeout() {
_timer?.cancel();
_timer = Timer(const Duration(seconds: 8), () { _timer = Timer(const Duration(seconds: 8), () {
if (mounted) setState(() => _timedOut = true); if (mounted) setState(() => _timedOut = true);
}); });
} }
void _handleReadyChanged(bool? previous, bool ready) {
if (ready) {
_timer?.cancel();
if (_timedOut) setState(() => _timedOut = false);
return;
}
if (previous != false) {
if (_timedOut) setState(() => _timedOut = false);
_startTimeout();
}
}
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
@@ -87,6 +124,7 @@ class _BootGateState extends ConsumerState<_BootGate> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ref.listen<bool>(appReadyProvider, _handleReadyChanged);
final ready = ref.watch(appReadyProvider) || _timedOut; final ready = ref.watch(appReadyProvider) || _timedOut;
return Stack( return Stack(
children: [ children: [
@@ -135,9 +173,21 @@ class _RootNavigator extends ConsumerWidget {
} }
return PopScope( return PopScope(
canPop: stack.length <= 1, canPop: false,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop) popRoute(ref); if (didPop) return;
if (stack.length > 1) {
popRoute(ref);
return;
}
final homeRoute = switch (authState.user?.role) {
'Admin' => 'adminHome',
'Doctor' => 'doctorHome',
_ => 'home',
};
if (current.name != homeRoute && authState.isLoggedIn) {
goRoute(ref, homeRoute);
}
}, },
child: Navigator( child: Navigator(
pages: [ pages: [
@@ -148,8 +198,12 @@ class _RootNavigator extends ConsumerWidget {
child: buildPage(stack[index], ref), child: buildPage(stack[index], ref),
), ),
], ],
onDidRemovePage: (_) { onDidRemovePage: (page) {
if (ref.read(routeStackProvider).length > 1) popRoute(ref); final routes = ref.read(routeStackProvider);
if (routes.length <= 1) return;
final lastIndex = routes.length - 1;
final currentPageKey = ValueKey(_pageKey(routes.last, lastIndex));
if (page.key == currentPageKey) popRoute(ref);
}, },
), ),
); );

View File

@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: kReleaseMode defaultValue: kReleaseMode
? 'https://erpapi.datalumina.cn/xiaomai' ? 'https://erpapi.datalumina.cn/xiaomai'
: 'http://10.4.159.130:5000', : 'http://192.168.1.34:5000',
); );
class ApiException implements Exception { class ApiException implements Exception {
@@ -44,6 +44,7 @@ class ApiClient {
final Dio _dio; final Dio _dio;
final LocalDatabase _db; final LocalDatabase _db;
final AuthExpiredNotifier? _authExpiredNotifier; final AuthExpiredNotifier? _authExpiredNotifier;
Future<_TokenRefreshResult>? _refreshInFlight;
ApiClient({ ApiClient({
required LocalDatabase db, required LocalDatabase db,
@@ -79,6 +80,63 @@ class ApiClient {
await _db.delete('refresh_token'); await _db.delete('refresh_token');
} }
Future<void> clearTokensIfRefreshMatches(String failedRefreshToken) async {
if (await refreshToken != failedRefreshToken) return;
await clearTokens();
}
Future<_TokenRefreshResult> _refreshTokens() {
final active = _refreshInFlight;
if (active != null) return active;
final future = _performTokenRefresh();
_refreshInFlight = future;
return future.whenComplete(() {
if (identical(_refreshInFlight, future)) _refreshInFlight = null;
});
}
Future<_TokenRefreshResult> _performTokenRefresh() async {
final refresh = await refreshToken;
if (refresh == null) return const _TokenRefreshResult.invalid();
try {
final response = await Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
),
).post('/api/auth/refresh', data: {'refreshToken': refresh});
final body = response.data;
final data = body is Map ? body['data'] : null;
final rawCode = body is Map ? body['code'] : null;
final code = rawCode is int ? rawCode : int.tryParse('$rawCode');
if (code != null && code != 0) {
return _TokenRefreshResult.invalid(refreshToken: refresh);
}
if (data is Map &&
data['accessToken'] is String &&
data['refreshToken'] is String) {
final accessToken = data['accessToken'] as String;
final newRefreshToken = data['refreshToken'] as String;
// 刷新期间用户可能已经退出或切换账号,旧响应不得覆盖新会话。
if (await refreshToken != refresh) {
return const _TokenRefreshResult.transientFailure();
}
await saveTokens(accessToken, newRefreshToken);
return _TokenRefreshResult.success(accessToken);
}
return const _TokenRefreshResult.transientFailure();
} on DioException catch (error) {
if (error.response?.statusCode == 400 ||
error.response?.statusCode == 401) {
return _TokenRefreshResult.invalid(refreshToken: refresh);
}
return const _TokenRefreshResult.transientFailure();
} catch (_) {
return const _TokenRefreshResult.transientFailure();
}
}
void notifyAuthExpired() { void notifyAuthExpired() {
_authExpiredNotifier?.notify(); _authExpiredNotifier?.notify();
} }
@@ -202,31 +260,51 @@ class _AuthInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) async { void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) { if (err.response?.statusCode != 401 ||
final refresh = await _client.refreshToken; err.requestOptions.extra['authRetried'] == true) {
if (refresh != null) { return handler.next(err);
try { }
final response = await Dio(
BaseOptions(baseUrl: baseUrl), final result = await _client._refreshTokens();
).post('/api/auth/refresh', data: {'refreshToken': refresh}); if (result.accessToken != null) {
final data = response.data['data']; try {
if (data != null) { final opts = err.requestOptions;
await _client.saveTokens(data['accessToken'], data['refreshToken']); opts.extra['authRetried'] = true;
final opts = err.requestOptions; opts.headers['Authorization'] = 'Bearer ${result.accessToken}';
final token = data['accessToken']; final retryResponse = await _client.dio.fetch(opts);
opts.headers['Authorization'] = 'Bearer $token'; return handler.resolve(retryResponse);
final retryResponse = await Dio( } catch (error) {
BaseOptions(baseUrl: baseUrl), log('[ApiClient] 请求重试失败: $error');
).fetch(opts); }
return handler.resolve(retryResponse); } else if (result.isInvalid) {
} final failedRefresh = result.failedRefreshToken;
} catch (e) { if (failedRefresh != null) {
log('[ApiClient] token刷新失败: $e'); await _client.clearTokensIfRefreshMatches(failedRefresh);
} } else {
await _client.clearTokens();
} }
await _client.clearTokens();
_client.notifyAuthExpired(); _client.notifyAuthExpired();
} }
handler.next(err); handler.next(err);
} }
} }
class _TokenRefreshResult {
final String? accessToken;
final bool isInvalid;
final String? failedRefreshToken;
const _TokenRefreshResult._({
this.accessToken,
this.isInvalid = false,
this.failedRefreshToken,
});
const _TokenRefreshResult.success(String accessToken)
: this._(accessToken: accessToken);
const _TokenRefreshResult.invalid({String? refreshToken})
: this._(isInvalid: true, failedRefreshToken: refreshToken);
const _TokenRefreshResult.transientFailure() : this._();
}

View File

@@ -26,9 +26,9 @@ class AppColors {
static const Color medication = Color(0xFF00B8D9); static const Color medication = Color(0xFF00B8D9);
static const Color medicationLight = Color(0xFFEAFBFF); static const Color medicationLight = Color(0xFFEAFBFF);
static const Color medicationBorder = Color(0xFFB8F0FA); static const Color medicationBorder = Color(0xFFB8F0FA);
static const Color exercise = Color(0xFF566FD4); static const Color exercise = Color(0xFF7C3AED);
static const Color exerciseLight = Color(0xFFF1F3FD); static const Color exerciseLight = Color(0xFFF3E8FF);
static const Color exerciseBorder = Color(0xFFD7DCF6); static const Color exerciseBorder = Color(0xFFDDD6FE);
static const Color report = Color(0xFF6366F1); static const Color report = Color(0xFF6366F1);
static const Color reportLight = Color(0xFFEFFBFF); static const Color reportLight = Color(0xFFEFFBFF);
static const Color reportBorder = Color(0xFFBAE6FD); static const Color reportBorder = Color(0xFFBAE6FD);
@@ -168,7 +168,7 @@ class AppColors {
static const LinearGradient healthGradient = LinearGradient( static const LinearGradient healthGradient = LinearGradient(
begin: Alignment.centerLeft, begin: Alignment.centerLeft,
end: Alignment.centerRight, end: Alignment.centerRight,
colors: [Color(0xFF2FAE9B), Color(0xFF63BFAE)], colors: [Color(0xFF43E97B), Color(0xFF38F9D7)],
); );
static const LinearGradient medicationGradient = LinearGradient( static const LinearGradient medicationGradient = LinearGradient(
@@ -180,7 +180,7 @@ class AppColors {
static const LinearGradient exerciseGradient = LinearGradient( static const LinearGradient exerciseGradient = LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFF5B7CDE), Color(0xFF7569D9)], colors: [Color(0xFFC4B5FD), Color(0xFF7C3AED)],
); );
static const LinearGradient reportGradient = LinearGradient( static const LinearGradient reportGradient = LinearGradient(

View File

@@ -95,7 +95,7 @@ class AppModuleVisuals {
static const diet = AppModuleVisual( static const diet = AppModuleVisual(
module: AppModule.diet, module: AppModule.diet,
label: '饮食', label: '饮食',
icon: LucideIcons.utensils, icon: LucideIcons.salad,
color: AppColors.diet, color: AppColors.diet,
lightColor: AppColors.dietLight, lightColor: AppColors.dietLight,
borderColor: AppColors.dietBorder, borderColor: AppColors.dietBorder,
@@ -168,3 +168,20 @@ class AppModuleVisuals {
static AppModuleVisual of(AppModule module) => values[module] ?? ai; static AppModuleVisual of(AppModule module) => values[module] ?? ai;
} }
/// 健康概览及所有健康指标卡片共用的图标与颜色。
class HealthMetricVisuals {
HealthMetricVisuals._();
static const bloodPressureIcon = Icons.bloodtype_outlined;
static const heartRateIcon = LucideIcons.heartPulse;
static const glucoseIcon = Icons.water_drop_outlined;
static const spo2Icon = Icons.air_outlined;
static const weightIcon = Icons.monitor_weight_outlined;
static const bloodPressureColor = Color(0xFF8B5CF6);
static const heartRateColor = Color(0xFFF43F5E);
static const glucoseColor = Color(0xFFF59E0B);
static const spo2Color = Color(0xFF0EA5E9);
static const weightColor = Color(0xFF10B981);
}

View File

@@ -11,15 +11,18 @@ import '../pages/report/report_pages.dart';
import '../pages/report/ai_analysis_page.dart'; 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/elder_mode_page.dart';
import '../pages/settings/notification_prefs_page.dart'; import '../pages/settings/notification_prefs_page.dart';
import '../pages/settings/ai_consent_details_page.dart';
import '../pages/notifications/notification_center_page.dart'; import '../pages/notifications/notification_center_page.dart';
import '../pages/history/conversation_history_page.dart'; import '../pages/history/conversation_history_page.dart';
import '../pages/profile/profile_page.dart'; import '../pages/profile/profile_page.dart';
import '../pages/profile/profile_edit_page.dart'; import '../pages/profile/profile_edit_page.dart';
import '../pages/diet/diet_capture_page.dart'; import '../pages/diet/diet_capture_page.dart';
import '../pages/exercise/exercise_plan_page.dart'; import '../pages/exercise/exercise_plan_page.dart';
import '../pages/device/device_scan_page.dart'; // iOS 审核版:蓝牙设备页已移除
import '../pages/device/device_management_page.dart'; // import '../pages/device/device_scan_page.dart';
// import '../pages/device/device_management_page.dart';
import '../pages/remaining_pages.dart'; import '../pages/remaining_pages.dart';
import '../pages/doctor/doctor_home_page.dart'; import '../pages/doctor/doctor_home_page.dart';
import '../pages/doctor/doctor_patient_detail_page.dart'; import '../pages/doctor/doctor_patient_detail_page.dart';
@@ -31,6 +34,7 @@ import '../pages/admin/admin_home_page.dart';
import '../pages/admin/admin_add_doctor_page.dart'; import '../pages/admin/admin_add_doctor_page.dart';
import '../providers/auth_provider.dart' show userRoleProvider; import '../providers/auth_provider.dart' show userRoleProvider;
import '../widgets/app_error_state.dart'; import '../widgets/app_error_state.dart';
import '../widgets/ai_consent_gate.dart';
Widget _missingParamPage() { Widget _missingParamPage() {
return const Scaffold( return const Scaffold(
@@ -51,7 +55,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const LoginPage(); return const LoginPage();
case 'home': case 'home':
final role = ref.watch(userRoleProvider); final role = ref.watch(userRoleProvider);
return role == 'Doctor' ? const DoctorHomePage() : const HomePage(); return role == 'Doctor'
? const DoctorHomePage()
: AiConsentGate(child: const HomePage());
case 'doctorHome': case 'doctorHome':
return const DoctorHomePage(); return const DoctorHomePage();
case 'adminHome': case 'adminHome':
@@ -117,18 +123,21 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id); return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
case 'doctorFollowUpEdit': case 'doctorFollowUpEdit':
return DoctorFollowUpEditPage(id: params['id']); return DoctorFollowUpEditPage(id: params['id']);
case 'devices': // iOS 审核版:蓝牙设备路由已移除
return const DeviceManagementPage(); // case 'devices': return const DeviceManagementPage();
case 'deviceScan': // case 'deviceScan': return const DeviceScanPage();
return const DeviceScanPage();
case 'healthArchive': case 'healthArchive':
return const HealthArchivePage(); return const HealthArchivePage();
case 'followups': case 'followups':
return const FollowUpListPage(); return const FollowUpListPage();
case 'settings': case 'settings':
return const SettingsPage(); return const SettingsPage();
case 'elderMode':
return const ElderModePage();
case 'notificationPrefs': case 'notificationPrefs':
return const NotificationPrefsPage(); return const NotificationPrefsPage();
case 'aiConsentDetails':
return const AiConsentDetailsPage();
case 'notifications': case 'notifications':
return const NotificationCenterPage(); return const NotificationCenterPage();
case 'conversationHistory': case 'conversationHistory':

View File

@@ -548,6 +548,7 @@ class GradientScaffold extends StatelessWidget {
final Widget? drawer; final Widget? drawer;
final Widget? bottomNavigationBar; final Widget? bottomNavigationBar;
final bool extendBody; final bool extendBody;
final bool resizeToAvoidBottomInset;
const GradientScaffold({ const GradientScaffold({
super.key, super.key,
@@ -557,6 +558,7 @@ class GradientScaffold extends StatelessWidget {
this.drawer, this.drawer,
this.bottomNavigationBar, this.bottomNavigationBar,
this.extendBody = false, this.extendBody = false,
this.resizeToAvoidBottomInset = true,
}); });
@override @override
@@ -572,6 +574,7 @@ class GradientScaffold extends StatelessWidget {
drawer: drawer, drawer: drawer,
bottomNavigationBar: bottomNavigationBar, bottomNavigationBar: bottomNavigationBar,
extendBody: extendBody, extendBody: extendBody,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
); );
} }
@@ -773,6 +776,74 @@ class AppTheme {
), ),
); );
/// 长辈模式只在已登录的患者端启用。这里提高主题级控件尺寸,页面中写死的
/// 字号再由 ElderModeScope 提供的文本缩放统一兜底。
static ThemeData get elderLightTheme {
final base = lightTheme;
return base.copyWith(
appBarTheme: base.appBarTheme.copyWith(
toolbarHeight: 60,
titleTextStyle: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: text,
),
iconTheme: const IconThemeData(size: 28, color: text),
),
textTheme: base.textTheme.copyWith(
headlineLarge: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: text,
),
titleLarge: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: text,
),
titleMedium: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
color: text,
),
bodyLarge: const TextStyle(fontSize: 18, color: text, height: 1.5),
bodyMedium: const TextStyle(fontSize: 16, color: textSub, height: 1.45),
labelMedium: const TextStyle(fontSize: 16, color: textSub),
labelSmall: const TextStyle(fontSize: 15, color: textHint),
),
inputDecorationTheme: base.inputDecorationTheme.copyWith(
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 16,
),
hintStyle: const TextStyle(color: textHint, fontSize: 17),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(rLg),
),
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
elevation: 0,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: text,
minimumSize: const Size(52, 56),
side: const BorderSide(color: border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(rMd),
),
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
),
);
}
static ShadThemeData get shadTheme => ShadThemeData( static ShadThemeData get shadTheme => ShadThemeData(
brightness: Brightness.light, brightness: Brightness.light,
colorScheme: ShadVioletColorScheme.light( colorScheme: ShadVioletColorScheme.light(

View File

@@ -0,0 +1,42 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
class ElderModeScope extends InheritedWidget {
final bool enabled;
final bool largeTextEnabled;
const ElderModeScope({
super.key,
required this.enabled,
required this.largeTextEnabled,
required super.child,
});
static ElderModeScope? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ElderModeScope>();
}
static bool enabledOf(BuildContext context) {
return maybeOf(context)?.enabled == true;
}
static bool largeTextOf(BuildContext context) {
final scope = maybeOf(context);
return scope?.enabled == true && scope?.largeTextEnabled == true;
}
static TextScaler textScalerFor(BuildContext context) {
final systemScaler = MediaQuery.textScalerOf(context);
if (!largeTextOf(context)) return systemScaler;
final systemFactor = systemScaler.scale(1);
final factor = math.max(systemFactor, 1.18).clamp(1.0, 1.6).toDouble();
return TextScaler.linear(factor);
}
@override
bool updateShouldNotify(ElderModeScope oldWidget) {
return enabled != oldWidget.enabled ||
largeTextEnabled != oldWidget.largeTextEnabled;
}
}

View File

@@ -1,187 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'bp_reading.dart';
enum BleDeviceType { bloodPressure, glucose, weightScale, pulseOximeter }
enum SupportedMetric { bloodPressure, heartRate, glucose, weight, spo2 }
extension BleDeviceTypeX on BleDeviceType {
String get code => switch (this) {
BleDeviceType.bloodPressure => 'bloodPressure',
BleDeviceType.glucose => 'glucose',
BleDeviceType.weightScale => 'weightScale',
BleDeviceType.pulseOximeter => 'pulseOximeter',
};
String get label => switch (this) {
BleDeviceType.bloodPressure => '血压计',
BleDeviceType.glucose => '血糖仪',
BleDeviceType.weightScale => '体重秤',
BleDeviceType.pulseOximeter => '血氧仪',
};
String get serviceUuid => switch (this) {
BleDeviceType.bloodPressure => BleDeviceIdentifier.bloodPressureServiceUuid,
BleDeviceType.glucose => BleDeviceIdentifier.glucoseServiceUuid,
BleDeviceType.weightScale => BleDeviceIdentifier.weightScaleServiceUuid,
BleDeviceType.pulseOximeter => BleDeviceIdentifier.pulseOximeterServiceUuid,
};
List<SupportedMetric> get metrics => switch (this) {
BleDeviceType.bloodPressure => [
SupportedMetric.bloodPressure,
SupportedMetric.heartRate,
],
BleDeviceType.glucose => [SupportedMetric.glucose],
BleDeviceType.weightScale => [SupportedMetric.weight],
BleDeviceType.pulseOximeter => [
SupportedMetric.spo2,
SupportedMetric.heartRate,
],
};
IconData get icon => switch (this) {
BleDeviceType.bloodPressure => Icons.speed_rounded,
BleDeviceType.glucose => Icons.water_drop_rounded,
BleDeviceType.weightScale => Icons.monitor_weight_outlined,
BleDeviceType.pulseOximeter => Icons.air_rounded,
};
static BleDeviceType? fromCode(String? code) {
for (final type in BleDeviceType.values) {
if (type.code == code) return type;
}
return null;
}
}
extension SupportedMetricX on SupportedMetric {
String get code => switch (this) {
SupportedMetric.bloodPressure => 'BloodPressure',
SupportedMetric.heartRate => 'HeartRate',
SupportedMetric.glucose => 'Glucose',
SupportedMetric.weight => 'Weight',
SupportedMetric.spo2 => 'SpO2',
};
String get label => switch (this) {
SupportedMetric.bloodPressure => '血压',
SupportedMetric.heartRate => '心率',
SupportedMetric.glucose => '血糖',
SupportedMetric.weight => '体重',
SupportedMetric.spo2 => '血氧',
};
}
class BoundBleDevice {
final String id;
final String name;
final BleDeviceType type;
final String serviceUuid;
final DateTime? lastSyncAt;
const BoundBleDevice({
required this.id,
required this.name,
required this.type,
required this.serviceUuid,
this.lastSyncAt,
});
List<SupportedMetric> get metrics => type.metrics;
BoundBleDevice copyWith({
String? id,
String? name,
BleDeviceType? type,
String? serviceUuid,
DateTime? lastSyncAt,
}) {
return BoundBleDevice(
id: id ?? this.id,
name: name ?? this.name,
type: type ?? this.type,
serviceUuid: serviceUuid ?? this.serviceUuid,
lastSyncAt: lastSyncAt ?? this.lastSyncAt,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'type': type.code,
'serviceUuid': serviceUuid,
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
};
static BoundBleDevice? fromJson(Map<String, dynamic> json) {
final type = BleDeviceTypeX.fromCode(json['type']?.toString());
final id = json['id']?.toString();
if (type == null || id == null || id.isEmpty) return null;
final lastSyncRaw = json['lastSyncAt']?.toString();
return BoundBleDevice(
id: id,
name: json['name']?.toString() ?? type.label,
type: type,
serviceUuid: json['serviceUuid']?.toString() ?? type.serviceUuid,
lastSyncAt: lastSyncRaw == null || lastSyncRaw.isEmpty
? null
: DateTime.tryParse(lastSyncRaw)?.toLocal(),
);
}
}
class BleDeviceIdentifier {
static const glucoseServiceUuid = '00001808-0000-1000-8000-00805F9B34FB';
static const bloodPressureServiceUuid =
'00001810-0000-1000-8000-00805F9B34FB';
static const weightScaleServiceUuid = '0000181D-0000-1000-8000-00805F9B34FB';
static const pulseOximeterServiceUuid =
'00001822-0000-1000-8000-00805F9B34FB';
static BleDeviceType? typeFromScan(ScanResult result) {
return typeFromUuidStrings(
result.advertisementData.serviceUuids.map((uuid) => uuid.str128),
);
}
static BleDeviceType? typeFromServices(List<BluetoothService> services) {
return typeFromUuidStrings(services.map((service) => service.uuid.str128));
}
static BleDeviceType? typeFromUuidStrings(Iterable<String> uuids) {
final normalized = uuids.map((uuid) => uuid.toUpperCase()).toSet();
if (normalized.contains(bloodPressureServiceUuid)) {
return BleDeviceType.bloodPressure;
}
if (normalized.contains(glucoseServiceUuid)) {
return BleDeviceType.glucose;
}
if (normalized.contains(weightScaleServiceUuid)) {
return BleDeviceType.weightScale;
}
if (normalized.contains(pulseOximeterServiceUuid)) {
return BleDeviceType.pulseOximeter;
}
return null;
}
static bool isSupportedScanResult(ScanResult result) {
return typeFromScan(result) != null;
}
}
sealed class HealthBleSyncResult {
const HealthBleSyncResult({required this.device});
final BoundBleDevice device;
}
class BloodPressureSyncResult extends HealthBleSyncResult {
const BloodPressureSyncResult({required super.device, required this.reading});
final BpReading reading;
}

Some files were not shown because too many files have changed in this diff Show More