Compare commits

17 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
MingNian
ae94ced2d5 feat: 后台管理页全量重构 + backoffice 共享模块 + 启动脚本优化
## 后台管理页重构
- admin 端: add_doctor / doctors / home / patients 四页重构
- doctor 端: consultations / dashboard / followup_edit / followups / home / patient_detail / patients / profile / report_detail / reports / settings 十一页重构
- 新增 backoffice 共享模块: backoffice_refresh_providers + backoffice_formatters + backoffice_ui

## 后端
- AdminService 增强(分页/搜索/统计)
- doctor_endpoints 微调
- appsettings.Development 调整
- 新增 admin_service_tests

## 前端其他
- 今日健康卡片 taskRow 行高 5->7(每行 44->48px)
- 健康仪表盘配色定 #4FACFE 纯色蓝
- admin_drawer / doctor_drawer 微调
- api_client IP 适配

## 启动脚本
- start-dev.bat: 加后端就绪检测(轮询 openapi 端点, 最多等 30s)

## 清理
- 删除旧品牌图(agent_welcome_abstract / login_background_v1)
- 删除 app_status_badge 组件
- 删除旧 HANDOFF 文档, 新增 07-17 版

## 测试
- 新增 backoffice_formatters / backoffice_refresh / backoffice_ui 三个测试
- app_router_test 扩展
2026-07-18 17:48:44 +08:00
MingNian
e1f4a4b91f docs: define doctor admin UI unification 2026-07-17 21:54:00 +08:00
MingNian
6e5d3e64cd feat: 健康仪表盘配色调整 + 登录页品牌升级 + iOS 配置 + 隐私文案修订 + 文档清理
## UI 配色
- 健康仪表盘: 背景从蓝紫粉三色渐变改为 #4FACFE 纯色蓝, 白字清晰不抢眼
- app_colors / app_design_tokens / app_theme: 配色体系微调
- 多个 widget 和页面跟随配色更新(health_drawer/admin_drawer/doctor_drawer/enterprise_widgets 等)

## 登录页品牌升级
- 新增品牌素材: drawer_background_v2 / login_background_v2 / health_login_character_transparent
- login_page 重构, 接入新品牌视觉
- app.dart 启动流程调整

## iOS / Android 配置
- Info.plist: 权限描述调整
- AppIcon / LaunchImage: 资源更新
- AndroidManifest: 权限微调

## 隐私文案修订
- privacy.html: 蓝牙设备描述调整为"标准蓝牙血压服务", 移除未上线设备类型表述
- terms.html: 移除"在线医生咨询"相关条款, 服务范围收窄

## 通知中心
- notification_center_page: 重构通知项布局

## 其他
- 删除已完成的计划/spec 文档(ui-system-first-pass / apple-sign-in / secondary-page-color-refresh / notification-preferences-design / ui-design-system)
- 新增 native_navigation_test
- 新增 HANDOFF 交接文档
- home_message_order_test 更新
- .gitignore 调整
2026-07-16 22:30:23 +08:00
MingNian
528859686f merge: integrate sccsbc release preparation safely 2026-07-16 00:41:52 +08:00
sccsbc
1a9d56b07c chore: 添加 appinfo 配置文件
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 15:31:36 +08:00
sccsbc
e52e21d295 chore: Android 正式签名配置 + 截图尺寸适配
- 生成 release.jks 正式签名密钥库
- build.gradle.kts 配置 release 签名自动读取 key.properties
- .gitignore 排除 keystore 和 key.properties
- 截图调整为 6.5 寸规格 (1242x2688)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 14:20:57 +08:00
ec5af19d30 chore: 补全 Apple Sign-In 迁移 + 调低开发环境日志级别
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-10 05:37:24 +00:00
sccsbc
bd4350c17f fix: 修复 Apple 登录 sub claim 映射 + 按钮文案中文化
- AppleTokenValidator: 从 JwtSecurityToken 原始 Claims 取 sub,避免 .NET ClaimType 映射导致丢失
- 登录页 Apple 按钮改为中文"通过 Apple 登录"

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 13:35:28 +08:00
sccsbc
fe4c81c54f feat: 集成 Apple Sign-In + iOS 上线准备
- 后端新增 Apple 登录端点 /api/auth/apple-login
- 新增 AppleTokenValidator 验证 IdentityToken
- User 实体添加 AppleUserId 字段,Phone 改为可空
- 前端添加 sign_in_with_apple 依赖和 Apple 登录按钮
- 统一 Bundle ID 为 com.datalumina.YYA,显示名称为小脉健康
- 配置 DEVELOPMENT_TEAM 和 Sign in with Apple Entitlements
- 补充 NSCamera/NSPhotoLibrary 权限描述
- 生产 API 地址改为 https://erpapi.datalumina.cn/xiaomai
- Flutter 升级至 3.44.6 / Dart 3.12.2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 13:24:03 +08:00
223 changed files with 14013 additions and 5845 deletions

5
.gitignore vendored
View File

@@ -4,6 +4,10 @@
*.key *.key
*.pfx *.pfx
# Android release keystore
health_app/android/**/*.jks
health_app/android/key.properties
# .NET build outputs # .NET build outputs
backend/**/bin/ backend/**/bin/
backend/**/obj/ backend/**/obj/
@@ -42,6 +46,7 @@ backend/src/Health.WebApi/data-protection-keys/
*.txt *.txt
*.jpg *.jpg
*.png *.png
!backend/src/Health.WebApi/wwwroot/app-icon.png
!health_app/assets/branding/*.png !health_app/assets/branding/*.png
!health_app/android/app/src/main/res/drawable/launch_brand.png !health_app/android/app/src/main/res/drawable/launch_brand.png
!health_app/android/app/src/main/res/drawable-v21/launch_brand.png !health_app/android/app/src/main/res/drawable-v21/launch_brand.png

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. 执行后进行完整验证并报告结果。
用户在具体任务中的直接指令始终优先于本文件。

File diff suppressed because one or more lines are too long

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

@@ -2,12 +2,14 @@ namespace Health.Application.Auth;
public sealed record AuthResult(int Code, object? Data, string? Message = null); public sealed record AuthResult(int Code, object? Data, string? Message = null);
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId); public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name);
public interface IAuthService public interface IAuthService
{ {
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct); Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct); Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct); Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct);
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct); Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
Task LogoutAsync(string refreshToken, CancellationToken ct); Task LogoutAsync(string refreshToken, CancellationToken ct);
} }

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

@@ -4,7 +4,7 @@ namespace Health.Application.Users;
public sealed record UserProfileDto( public sealed record UserProfileDto(
Guid Id, Guid Id,
string Phone, string? Phone, // Apple 用户无手机号
string Role, string Role,
string? Name, string? Name,
string? Gender, string? Gender,
@@ -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

@@ -6,7 +6,8 @@ namespace Health.Domain.Entities;
public sealed class User public sealed class User
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string Phone { get; set; } = string.Empty; public string? Phone { get; set; } // Apple 用户无手机号
public string? AppleUserId { get; set; } // Apple Sign-In 唯一标识
public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin" public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
public Guid? DoctorId { get; set; } // 患者选择的医生 public Guid? DoctorId { get; set; } // 患者选择的医生
public string? Name { get; set; } public string? Name { get; set; }

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" },
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" }
}
}
}; };
return normalized != "__invalid__";
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
{
success = false,
message = "查询运动计划时必须明确 scopetoday、scheduled_date、current_plans、upcoming_plans、ended_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 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
{ {
found = true,
plan_id = plan.Id, plan_id = plan.Id,
plan.StartDate, plan.StartDate,
plan.EndDate, plan.EndDate,
phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active",
plan.ReminderTime, plan.ReminderTime,
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }), 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",
});
} }
private const string SmartLinkRules = """ if (normalized.Contains("medical_consultation") ||
normalized.Contains("report_analysis") ||
- / Markdown [](app://diet),引导用户使用专门的饮食拍照分析功能。 normalized.Contains("diet_analysis"))
- [](app://report),引导上传到报告分析功能获得详细解读。 {
- [](app://device)。 sections.Add("rag/retrieval_rules.md");
}
var composed = Compose(sections);
- var needsMedicalSources = normalized.Contains("medical_consultation") ||
- "问热量/卡路里" app://diet"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。 normalized.Contains("report_analysis") ||
- Markdown 4 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 CitationRules = """
- //
- PMIDDOI URL
-
[来源编号]
URL
-
- /
-
"""; """;
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

@@ -16,25 +16,32 @@ public sealed class AdminService(AppDbContext db) : IAdminService
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct) public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空"); var phone = command.Phone.Trim();
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空"); var name = command.Name.Trim();
if (string.IsNullOrWhiteSpace(phone)) return Error(40001, "手机号不能为空");
if (string.IsNullOrWhiteSpace(name)) return Error(40002, "姓名不能为空");
if (await _db.Users.AnyAsync(x => x.Phone == phone, ct) ||
await _db.Doctors.AnyAsync(x => x.Phone == phone, ct))
return Error(40003, "该手机号已被其他账号使用");
var doctor = new Doctor var doctor = new Doctor
{ {
Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department, Id = Guid.NewGuid(), Name = name, Title = command.Title?.Trim(), Department = command.Department?.Trim(),
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow, Phone = phone, ProfessionalDirection = command.ProfessionalDirection?.Trim(), IsActive = true, CreatedAt = DateTime.UtcNow,
};
var user = new User
{
Id = Guid.NewGuid(), Phone = phone, Role = "Doctor", Name = name,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow
}; };
_db.Doctors.Add(doctor); _db.Doctors.Add(doctor);
if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct))
{
var user = new User { Id = Guid.NewGuid(), Phone = command.Phone, Role = "Doctor", Name = command.Name, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
_db.Users.Add(user); _db.Users.Add(user);
_db.DoctorProfiles.Add(new DoctorProfile _db.DoctorProfiles.Add(new DoctorProfile
{ {
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = command.Name, Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = name,
Title = command.Title, Department = command.Department, IsActive = true, Title = doctor.Title, Department = doctor.Department, IsActive = true,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
}); });
}
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.Name }); return Ok(new { doctor.Id, doctor.Name });
} }
@@ -43,11 +50,43 @@ public sealed class AdminService(AppDbContext db) : IAdminService
{ {
var doctor = await _db.Doctors.FindAsync([id], ct); var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在"); if (doctor == null) return Error(404, "医生不存在");
if (command.Name != null) doctor.Name = command.Name; var profile = await _db.DoctorProfiles.Include(x => x.User)
if (command.Title != null) doctor.Title = command.Title; .FirstOrDefaultAsync(x => x.DoctorId == id, ct);
if (command.Department != null) doctor.Department = command.Department; if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
if (command.Phone != null) doctor.Phone = command.Phone;
if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection; var user = profile.User;
if (command.Phone != null)
{
var phone = command.Phone.Trim();
if (phone.Length == 0) return Error(40001, "手机号不能为空");
if (await _db.Users.AnyAsync(x => x.Id != user.Id && x.Phone == phone, ct) ||
await _db.Doctors.AnyAsync(x => x.Id != id && x.Phone == phone, ct))
return Error(40003, "该手机号已被其他账号使用");
doctor.Phone = phone;
user.Phone = phone;
}
if (command.Name != null)
{
var name = command.Name.Trim();
if (name.Length == 0) return Error(40002, "姓名不能为空");
doctor.Name = name;
user.Name = name;
profile.Name = name;
}
if (command.Title != null)
{
doctor.Title = command.Title.Trim();
profile.Title = doctor.Title;
}
if (command.Department != null)
{
doctor.Department = command.Department.Trim();
profile.Department = doctor.Department;
}
if (command.ProfessionalDirection != null)
doctor.ProfessionalDirection = command.ProfessionalDirection.Trim();
user.UpdatedAt = DateTime.UtcNow;
profile.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id }); return Ok(new { doctor.Id });
} }
@@ -56,7 +95,11 @@ public sealed class AdminService(AppDbContext db) : IAdminService
{ {
var doctor = await _db.Doctors.FindAsync([id], ct); var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在"); if (doctor == null) return Error(404, "医生不存在");
var profile = await _db.DoctorProfiles.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
doctor.IsActive = !doctor.IsActive; doctor.IsActive = !doctor.IsActive;
profile.IsActive = doctor.IsActive;
profile.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.IsActive }); return Ok(new { doctor.Id, doctor.IsActive });
} }
@@ -65,7 +108,22 @@ public sealed class AdminService(AppDbContext db) : IAdminService
{ {
var doctor = await _db.Doctors.FindAsync([id], ct); var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在"); if (doctor == null) return Error(404, "医生不存在");
await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct);
if (await _db.Users.AnyAsync(x => x.Role == "User" && x.DoctorId == id, ct))
return Error(40004, "该医生仍有绑定患者,请先停用,不能直接删除");
if (await _db.Consultations.AnyAsync(x => x.DoctorId == id, ct))
return Error(40005, "该医生已有问诊记录,只能停用,不能删除");
var profile = await _db.DoctorProfiles.Include(x => x.User)
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
if (profile != null)
{
var user = profile.User;
var refreshTokens = await _db.RefreshTokens.Where(x => x.UserId == user.Id).ToListAsync(ct);
_db.RefreshTokens.RemoveRange(refreshTokens);
_db.DoctorProfiles.Remove(profile);
_db.Users.Remove(user);
}
_db.Doctors.Remove(doctor); _db.Doctors.Remove(doctor);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return Ok(new { success = true }); return Ok(new { success = true });
@@ -77,7 +135,9 @@ public sealed class AdminService(AppDbContext db) : IAdminService
pageSize = Math.Clamp(pageSize, 1, 100); pageSize = Math.Clamp(pageSize, 1, 100);
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User"); var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search)); query = query.Where(x =>
(x.Name != null && x.Name.Contains(search)) ||
(x.Phone != null && x.Phone.Contains(search)));
var total = await query.CountAsync(ct); var total = await query.CountAsync(ct);
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize) var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null }) .Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })

View File

@@ -3,7 +3,12 @@ using Health.Infrastructure.Services;
namespace Health.Infrastructure.Auth; namespace Health.Infrastructure.Auth;
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService public sealed class AuthService(
AppDbContext db,
JwtProvider jwt,
SmsService sms,
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
) : IAuthService
{ {
private const string AdminPhone = "12345678910"; private const string AdminPhone = "12345678910";
private const string AdminSmsCode = "000000"; private const string AdminSmsCode = "000000";
@@ -11,6 +16,7 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
private readonly AppDbContext _db = db; private readonly AppDbContext _db = db;
private readonly JwtProvider _jwt = jwt; private readonly JwtProvider _jwt = jwt;
private readonly SmsService _sms = sms; private readonly SmsService _sms = sms;
private readonly AppleTokenValidator _appleValidator = appleValidator;
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct) public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
{ {
@@ -82,14 +88,39 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
{ {
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)
@@ -99,13 +130,68 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
{
// 1. 验证 Apple identityToken
string appleUserId;
try
{
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
return Error(40005, "Apple 登录验证失败,请重试");
}
// 2. 查找已有用户
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
if (existingUser != null)
{
existingUser.UpdatedAt = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new
{
tokens.accessToken, tokens.refreshToken,
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
});
}
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
var newUser = new User
{
Id = Guid.NewGuid(),
Phone = null,
AppleUserId = appleUserId,
Role = "User",
Name = command.Name,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_db.Users.Add(newUser);
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new
{
newTokens.accessToken, newTokens.refreshToken,
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
});
}
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) => private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed) _db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct); .OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role) private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
{ {
var access = _jwt.GenerateAccessToken(userId, phone, role); var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
var refresh = _jwt.GenerateRefreshToken(); var refresh = _jwt.GenerateRefreshToken();
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) }); _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
return (access, refresh); return (access, refresh);

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)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddAppleSignIn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Phone",
table: "Users",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AddColumn<string>(
name: "AppleUserId",
table: "Users",
type: "text",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Users_AppleUserId",
table: "Users",
column: "AppleUserId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Users_AppleUserId",
table: "Users");
migrationBuilder.DropColumn(
name: "AppleUserId",
table: "Users");
migrationBuilder.AlterColumn<string>(
name: "Phone",
table: "Users",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
}
}

View File

@@ -819,6 +819,9 @@ namespace Health.Infrastructure.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("AppleUserId")
.HasColumnType("text");
b.Property<string>("AvatarUrl") b.Property<string>("AvatarUrl")
.HasColumnType("text"); .HasColumnType("text");
@@ -838,7 +841,6 @@ namespace Health.Infrastructure.Data.Migrations
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Phone") b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Role") b.Property<string>("Role")
@@ -853,6 +855,9 @@ namespace Health.Infrastructure.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AppleUserId")
.IsUnique();
b.HasIndex("DoctorId"); b.HasIndex("DoctorId");
b.HasIndex("Phone") b.HasIndex("Phone")
@@ -951,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>();
@@ -50,6 +51,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
builder.Entity<User>(e => builder.Entity<User>(e =>
{ {
e.HasIndex(u => u.Phone).IsUnique(); e.HasIndex(u => u.Phone).IsUnique();
e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User"); e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull); e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
}); });
@@ -193,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,78 @@
using System.IdentityModel.Tokens.Jwt;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace Health.Infrastructure.Services;
/// <summary>
/// Apple IdentityToken 验证:下载 Apple 公钥、验签、检查声明
/// </summary>
public sealed class AppleTokenValidator
{
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
private const string AppleIssuer = "https://appleid.apple.com";
private static readonly HttpClient _http = new();
private static List<JsonWebKey>? _cachedKeys;
private static DateTime _keysExpiry = DateTime.MinValue;
private static readonly SemaphoreSlim _lock = new(1, 1);
private readonly string _clientId;
public AppleTokenValidator(IConfiguration config)
{
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
}
/// <summary>
/// 验证 Apple identityToken返回 sub用户唯一标识
/// </summary>
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
{
var handler = new JwtSecurityTokenHandler();
// 先解码获取 kid
var rawToken = handler.ReadJwtToken(identityToken);
var kid = rawToken.Header.Kid;
var keys = await GetKeysAsync(ct);
var key = keys.FirstOrDefault(k => k.KeyId == kid)
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
var validationParams = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = AppleIssuer,
ValidateAudience = true,
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ClockSkew = TimeSpan.FromMinutes(1),
};
handler.ValidateToken(identityToken, validationParams, out _);
// 从原始 JWT 取 sub避免 .NET 的 ClaimType 映射把 "sub" 转成 NameIdentifier
var sub = rawToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
if (string.IsNullOrWhiteSpace(sub))
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
return sub;
}
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
{
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
await _lock.WaitAsync(ct);
try
{
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
var jwks = new JsonWebKeySet(response);
_cachedKeys = jwks.Keys.ToList();
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
return _cachedKeys;
}
finally { _lock.Release(); }
}
}

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

@@ -12,6 +12,8 @@ public static class AuthEndpoints
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct))); ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) => app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct))); ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) => app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
ToResult(await auth.RefreshAsync(request.RefreshToken, ct))); ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) => app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
@@ -28,4 +30,5 @@ public static class AuthEndpoints
public sealed record SendSmsRequest(string Phone); public sealed record SendSmsRequest(string Phone);
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId); public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
public sealed record LoginRequest(string Phone, string SmsCode); public sealed record LoginRequest(string Phone, string SmsCode);
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
public sealed record RefreshRequest(string RefreshToken); public sealed record RefreshRequest(string RefreshToken);

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();
@@ -105,6 +119,7 @@ public static class DoctorEndpoints
doctorHospital = profile.Hospital, doctorHospital = profile.Hospital,
doctorAvatar = profile.AvatarUrl, doctorAvatar = profile.AvatarUrl,
doctorOnline = profile.IsOnline, doctorOnline = profile.IsOnline,
doctorActive = profile.IsActive,
stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps }, stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps },
pendingConsultations, pendingConsultations,
pendingReports = pendingReportList, pendingReports = pendingReportList,
@@ -125,7 +140,7 @@ public static class DoctorEndpoints
{ {
query = query.Where(u => query = query.Where(u =>
(u.Name != null && u.Name.Contains(search)) || (u.Name != null && u.Name.Contains(search)) ||
u.Phone.Contains(search)); (u.Phone != null && u.Phone.Contains(search)));
} }
var total = await query.CountAsync(); var total = await query.CountAsync();
var patients = await query.OrderByDescending(u => u.CreatedAt) var patients = await query.OrderByDescending(u => u.CreatedAt)
@@ -303,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 });
}); });
@@ -316,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 });
@@ -390,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
@@ -414,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 文件(开发环境)
@@ -108,8 +109,10 @@ builder.Services.AddAuthorization();
// ---- 业务服务 ---- // ---- 业务服务 ----
builder.Services.AddSingleton<JwtProvider>(); builder.Services.AddSingleton<JwtProvider>();
builder.Services.AddSingleton<SmsService>(); builder.Services.AddSingleton<SmsService>();
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>();
@@ -150,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")));
@@ -162,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 =>
@@ -205,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();
@@ -245,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

@@ -1,8 +1,9 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Warning",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
} }
} }
} }

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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -28,7 +28,7 @@
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p> <p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
<h2>一、我们收集的信息</h2> <h2>一、我们收集的信息</h2>
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p> <p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
<h3>1. 账号与身份信息</h3> <h3>1. 账号与身份信息</h3>
<ul> <ul>
@@ -57,7 +57,7 @@
<h3>4. 蓝牙设备信息</h3> <h3>4. 蓝牙设备信息</h3>
<ul> <ul>
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主</li> <li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前实际支持以采用标准蓝牙血压服务的血压计为主;其他设备类型将在功能正式开放后另行说明</li>
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间以及用于避免重复同步的短期记录指纹。</li> <li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间以及用于避免重复同步的短期记录指纹。</li>
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li> <li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
</ul> </ul>

View File

@@ -28,7 +28,7 @@
<p>如您不同意本协议,请停止注册或使用本应用。</p> <p>如您不同意本协议,请停止注册或使用本应用。</p>
<h2>一、服务内容</h2> <h2>一、服务内容</h2>
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p> <p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
<p>本应用当前可能包含以下服务:</p> <p>本应用当前可能包含以下服务:</p>
<ul> <ul>
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li> <li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
@@ -37,7 +37,6 @@
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li> <li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
<li>AI 健康咨询根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li> <li>AI 健康咨询根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li> <li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
<li>在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。</li>
</ul> </ul>
<h2>二、账号注册与使用</h2> <h2>二、账号注册与使用</h2>
@@ -56,7 +55,6 @@
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li> <li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li> <li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li> <li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
<li>医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。</li>
</ul> </ul>
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p> <p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>

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

@@ -0,0 +1,191 @@
using Health.Application.Admin;
using Health.Domain.Entities;
using Health.Infrastructure.Admin;
using Health.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Health.Tests;
public sealed class AdminServiceTests
{
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new AppDbContext(options);
}
[Fact]
public async Task AddDoctor_CreatesDoctorLoginAndProfileTogether()
{
await using var db = CreateDbContext();
var service = new AdminService(db);
var result = await service.AddDoctorAsync(
new AddDoctorCommand("13800138001", "王医生", "主任医师", "心内科", "高血压"),
CancellationToken.None);
Assert.Equal(0, result.Code);
var doctor = await db.Doctors.SingleAsync();
var user = await db.Users.SingleAsync();
var profile = await db.DoctorProfiles.SingleAsync();
Assert.Equal("Doctor", user.Role);
Assert.Equal(doctor.Id, profile.DoctorId);
Assert.Equal(user.Id, profile.UserId);
Assert.Equal(doctor.Name, user.Name);
Assert.Equal(doctor.Name, profile.Name);
Assert.Equal(doctor.Title, profile.Title);
Assert.Equal(doctor.Department, profile.Department);
}
[Fact]
public async Task AddDoctor_RejectsAPhoneAlreadyUsedByAnyAccount()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = Guid.NewGuid(),
Phone = "13800138002",
Role = "User",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var service = new AdminService(db);
var result = await service.AddDoctorAsync(
new AddDoctorCommand("13800138002", "李医生", null, null, null),
CancellationToken.None);
Assert.NotEqual(0, result.Code);
Assert.Empty(db.Doctors);
Assert.Empty(db.DoctorProfiles);
}
[Fact]
public async Task UpdateDoctor_SynchronizesDoctorLoginAndProfile()
{
await using var db = CreateDbContext();
var (doctor, user, profile) = await SeedDoctorAsync(db);
var service = new AdminService(db);
var result = await service.UpdateDoctorAsync(
doctor.Id,
new UpdateDoctorCommand("13800138999", "新姓名", "副主任医师", "神经内科", "脑血管"),
CancellationToken.None);
Assert.Equal(0, result.Code);
Assert.Equal("13800138999", doctor.Phone);
Assert.Equal("13800138999", user.Phone);
Assert.Equal("新姓名", doctor.Name);
Assert.Equal("新姓名", user.Name);
Assert.Equal("新姓名", profile.Name);
Assert.Equal("副主任医师", profile.Title);
Assert.Equal("神经内科", profile.Department);
}
[Fact]
public async Task ToggleDoctor_SynchronizesAvailabilityButKeepsLoginEnabled()
{
await using var db = CreateDbContext();
var (doctor, user, profile) = await SeedDoctorAsync(db);
var service = new AdminService(db);
var result = await service.ToggleDoctorAsync(doctor.Id, CancellationToken.None);
Assert.Equal(0, result.Code);
Assert.False(doctor.IsActive);
Assert.False(profile.IsActive);
Assert.Equal("Doctor", user.Role);
Assert.NotNull(await db.Users.FindAsync(user.Id));
}
[Fact]
public async Task DeleteDoctor_IsRejectedWhilePatientsAreBound()
{
await using var db = CreateDbContext();
var (doctor, user, profile) = await SeedDoctorAsync(db);
db.Users.Add(new User
{
Id = Guid.NewGuid(),
Phone = "13800138088",
Name = "患者",
Role = "User",
DoctorId = doctor.Id,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var service = new AdminService(db);
var result = await service.DeleteDoctorAsync(doctor.Id, CancellationToken.None);
Assert.NotEqual(0, result.Code);
Assert.NotNull(await db.Doctors.FindAsync(doctor.Id));
Assert.NotNull(await db.Users.FindAsync(user.Id));
Assert.NotNull(await db.DoctorProfiles.FindAsync(profile.Id));
}
[Fact]
public async Task DeleteUnusedDoctor_RemovesLoginProfileAndRefreshTokens()
{
await using var db = CreateDbContext();
var (doctor, user, _) = await SeedDoctorAsync(db);
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = "refresh-token",
ExpiresAt = DateTime.UtcNow.AddDays(1),
});
await db.SaveChangesAsync();
var service = new AdminService(db);
var result = await service.DeleteDoctorAsync(doctor.Id, CancellationToken.None);
Assert.Equal(0, result.Code);
Assert.Empty(db.Doctors);
Assert.Empty(db.DoctorProfiles);
Assert.Empty(db.Users);
Assert.Empty(db.RefreshTokens);
}
private static async Task<(Doctor Doctor, User User, DoctorProfile Profile)> SeedDoctorAsync(AppDbContext db)
{
var doctor = new Doctor
{
Id = Guid.NewGuid(),
Phone = "13800138003",
Name = "原姓名",
Title = "医师",
Department = "内科",
IsActive = true,
CreatedAt = DateTime.UtcNow,
};
var user = new User
{
Id = Guid.NewGuid(),
Phone = doctor.Phone,
Name = doctor.Name,
Role = "Doctor",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
var profile = new DoctorProfile
{
Id = Guid.NewGuid(),
DoctorId = doctor.Id,
UserId = user.Id,
Name = doctor.Name,
Title = doctor.Title,
Department = doctor.Department,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.AddRange(doctor, user, profile);
await db.SaveChangesAsync();
return (doctor, user, profile);
}
}

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

@@ -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

@@ -1,96 +0,0 @@
# UI System First Pass Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the first pass of the app-wide UI system and migrate the highest-impact frontend surfaces to the agreed typography, color, icon, layout, button, feedback, and AI content rules.
**Architecture:** Centralize design decisions in token and common widget files, then migrate pages by replacing local hardcoded visuals with shared module visuals and components. Keep this pass focused on visible consistency and maintainability without adding the full accessibility-mode feature yet.
**Tech Stack:** Flutter, Riverpod, shadcn_ui Lucide icons, flutter_markdown.
---
### Task 1: Design Tokens And Common Widgets
**Files:**
- Modify: `health_app/lib/core/app_colors.dart`
- Modify: `health_app/lib/core/app_theme.dart`
- Create: `health_app/lib/core/app_design_tokens.dart`
- Create: `health_app/lib/core/app_module_visuals.dart`
- Create: `health_app/lib/widgets/app_buttons.dart`
- Create: `health_app/lib/widgets/app_status_badge.dart`
- Create: `health_app/lib/widgets/app_toast.dart`
- Create: `health_app/lib/widgets/ai_content.dart`
- [ ] Add typography, spacing, radius, shadow, module color, and module icon tokens.
- [ ] Add reusable gradient-outline button and module-aware buttons.
- [ ] Add top-center toast helper for light feedback.
- [ ] Add shared AI Markdown renderer and plain text cleaner.
- [ ] Run `flutter analyze`.
### Task 2: AI Conversation And Cards
**Files:**
- Modify: `health_app/lib/pages/home/widgets/chat_messages_view.dart`
- Modify: `health_app/lib/pages/home/home_page.dart`
- [ ] Apply shared AI content renderer to AI bubbles.
- [ ] Normalize user bubble, AI bubble, attachments, AI generated note, and Markdown typography.
- [ ] Update intelligent agent visuals to use shared module icons and colors, including `dumbbell` for exercise and `pill` for medication.
- [ ] Preserve gradient-outline white-background buttons in agent welcome cards.
- [ ] Keep today health card gradient outline and ensure empty medication windows still show a light status.
- [ ] Run `flutter analyze lib/pages/home/widgets/chat_messages_view.dart lib/pages/home/home_page.dart`.
### Task 3: Sidebar
**Files:**
- Modify: `health_app/lib/widgets/health_drawer.dart`
- [ ] Keep current structure: user header, health dashboard, common functions, conversation records.
- [ ] Convert user header to no-frame layout with settings on the right.
- [ ] Keep health dashboard as a light panel.
- [ ] Convert common functions to a two-column light matrix without heavy per-item cards.
- [ ] Keep conversation records as lightweight rows with summary left and date right.
- [ ] Run `flutter analyze lib/widgets/health_drawer.dart`.
### Task 4: Core Management Pages
**Files:**
- Modify: `health_app/lib/widgets/enterprise_widgets.dart`
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
- Modify: `health_app/lib/pages/medication/medication_checkin_page.dart`
- Modify: `health_app/lib/pages/remaining_pages.dart`
- Modify: `health_app/lib/pages/report/report_pages.dart`
- Modify: `health_app/lib/pages/report/ai_analysis_page.dart`
- Modify: `health_app/lib/pages/notifications/notification_center_page.dart`
- [ ] Update summary panels to avoid repeating page titles and reduce icon emphasis.
- [ ] Apply module colors and Lucide icons to medication, exercise, reports, and notifications.
- [ ] Apply shared AI content renderer to report AI text to prevent visible raw Markdown markers such as `**`.
- [ ] Normalize list title/subtitle/tag sizes and row heights.
- [ ] Run targeted `flutter analyze` on modified files.
### Task 5: Profile, Health Archive, Diet, Device
**Files:**
- Modify: `health_app/lib/pages/profile/profile_page.dart`
- Modify: `health_app/lib/pages/remaining_pages.dart`
- Modify: `health_app/lib/pages/diet/diet_capture_page.dart`
- Modify: `health_app/lib/pages/device/device_management_page.dart`
- Modify: `health_app/lib/widgets/ble_sync_dialog.dart`
- [ ] Use two-column short-field layout where appropriate in profile/health archive surfaces already in scope.
- [ ] Apply module tokens to diet and device surfaces.
- [ ] Improve Bluetooth/device panels with shared radius, typography, and module colors.
- [ ] Replace obvious bottom SnackBar style feedback in touched flows with top-center toast where low-risk.
- [ ] Run targeted `flutter analyze` on modified files.
### Task 6: Full Verification
**Files:**
- Test: `health_app`
- [ ] Run `flutter analyze`.
- [ ] If available and practical, run a smoke test or build command for the Flutter app.
- [ ] Summarize changed files and any deferred surfaces.

View File

@@ -1,47 +0,0 @@
# Secondary Page Color Refresh Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Execute this plan inline in the current session. Do not create a branch or worktree.
**Goal:** Produce a restrained first-pass visual refresh for secondary pages while leaving the home page and all business behavior unchanged.
**Architecture:** Centralize a light neutral secondary-page canvas with a white AppBar in `GradientScaffold`, keep the existing home implementation untouched, and introduce one reusable outlined create FAB for the pages that currently expose add/upload actions. Fix swipe-delete rows at the shared widget layer so medication and exercise rows always paint an opaque white content surface over the red delete action.
**Tech Stack:** Flutter, Material 3, flutter_test.
## Global Constraints
- Work on the existing `main` branch and preserve all unrelated dirty-worktree changes.
- Do not change backend APIs, providers, routing, or page business logic.
- Keep the current home page visual design unchanged.
- Secondary pages use a `#F7F8FA` canvas with white content surfaces; module colors remain content accents only.
- Reuse the existing `AppRadius` and `AppColors.actionOutlineGradient` tokens.
---
### Task 1: Lock the shared secondary-page visual behavior
**Files:**
- Modify: `health_app/test/swipe_delete_tile_test.dart`
- Create: `health_app/test/secondary_page_visuals_test.dart`
- Modify: `health_app/lib/core/app_theme.dart`
- Modify: `health_app/lib/widgets/common_widgets.dart`
- [ ] Add failing widget tests for an opaque swipe-row surface, a white `GradientScaffold`, and the shared outlined create FAB.
- [ ] Run the targeted tests and confirm they fail for the missing visual behavior.
- [ ] Make `GradientScaffold` and the global AppBar surface white without changing the home page.
- [ ] Add the reusable white, purple-gradient-outline create FAB.
- [ ] Paint swipe-row content white so red appears only in the revealed action area.
- [ ] Run the targeted tests and confirm they pass.
### Task 2: Adopt the common create action on active user pages
**Files:**
- Modify: `health_app/lib/pages/chart/trend_page.dart`
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
- Modify: `health_app/lib/pages/exercise/exercise_plan_page.dart`
- Modify: `health_app/lib/pages/report/report_pages.dart`
- [ ] Replace page-specific solid-color FAB styling with the shared create FAB.
- [ ] Preserve every existing callback, tooltip, route, and upload sheet.
- [ ] Format only the touched Dart files.
- [ ] Run the focused widget tests, the full Flutter test suite, and `flutter analyze`.

View File

@@ -1,47 +0,0 @@
# 通知设置改造设计
## 目标
让通知设置展示真实的服务端状态,保存失败时明确提示并恢复原值;将五个健康指标开关收纳到底部选择弹窗中,同时修复通知总开关未真正参与后端投递判断的问题。
## 页面结构
主页面保持四个区域:通知总开关、通知类型、健康录入提醒、免打扰时段。
健康录入提醒区域只展示:
- “每日健康录入提醒”总开关。
- “提醒指标”入口,副标题展示当前已选指标摘要。
点击“提醒指标”后打开底部弹窗。弹窗使用复选框选择血压、心率、血糖、血氧和体重,至少保留一项,点击“保存”后一次性提交全部指标设置。
## 状态与保存
前端使用明确的通知偏好状态对象,不再直接使用无类型的 Map。状态区分首次加载、单项保存、指标批量保存和错误信息。
- 首次加载成功后才展示设置内容。
- 首次加载失败展示错误页和重试按钮,不使用默认值冒充服务端数据。
- 普通开关修改时只锁定当前操作,后端成功后采用响应中的完整偏好更新页面。
- 保存失败恢复原值并显示错误提示。
- 指标弹窗先维护本地草稿,点击保存时一次提交五个字段,避免连续请求和部分成功。
- 免打扰时间采用相同的真实保存与失败恢复规则。
## 后端行为
保留现有 `/api/notification-prefs` GET 和 PUT 接口。PUT 继续支持部分字段更新并返回保存后的完整偏好。
通知发送管线必须优先检查 `PushEnabled`。总开关关闭时,不生成或投递任何用户通知;健康录入、用药、随访、医生回复和异常提醒继续读取各自开关,免打扰规则继续生效。
## 错误处理
- 网络或服务错误显示后端可读信息,无法解析时使用统一提示。
- 保存过程中禁止同一设置重复提交。
- 指标未选择任何一项时,弹窗内提示至少选择一项,不发送请求。
- 后端返回失败时不显示成功状态。
## 测试
- Flutter 单元测试覆盖偏好解析、指标摘要、至少选择一项和失败回滚。
- Flutter 组件测试覆盖加载失败、指标弹窗批量选择和保存中禁用。
- 后端测试覆盖 `PushEnabled=false` 时所有通知类型均不投递,以及各分类开关和免打扰继续生效。
- 运行 `flutter analyze`、Flutter 全量测试和相关 .NET 测试。

View File

@@ -0,0 +1,73 @@
# 医生端与管理员端 UI 统一设计
## 目标
在不改变现有业务流程、接口协议和导航结构的前提下,使医生端与管理员端的全部现有页面统一到患者端当前的视觉语言,并在修改过程中进行源码级逻辑检查。
## 范围
- 覆盖 `health_app/lib/pages/doctor/` 下全部现有页面。
- 覆盖 `health_app/lib/pages/admin/` 下全部现有页面。
- 覆盖医生端和管理员端侧边栏及其直接使用的共用展示组件。
- 复用患者端已有的 `AppColors`、主题、卡片阴影、圆角、渐变和页面背景。
- 检查前端状态处理、异步调用、空数据、错误反馈、导航和字段映射中的明显逻辑错误。
- 检查后端与医生端、管理员端直接相关的鉴权、数据归属、参数验证和异常处理。
## 不在范围内
- 不新增医生端或管理员端业务功能。
- 不修改 API 协议、数据库结构或现有权限规则,除非发现明确的高风险缺陷并经用户另行确认。
- 不重做患者端。
- 不截图、不做视觉回归平台、不进行真机测试或发布。
- 不为了视觉统一进行无关的大规模重构。
## 视觉方案
### 页面骨架
- 页面使用患者端的浅灰背景和统一安全区处理。
- 顶部标题栏保持轻量、透明或白色表面,标题、返回和菜单图标统一使用患者端文字颜色。
- 页面内容统一使用 16 像素水平边距;主要区块之间保持稳定的 1216 像素间距。
### 卡片与操作
- 内容容器统一为白色圆角卡片,使用 `AppColors.borderLight` 和轻阴影。
- 主操作使用患者端紫蓝主渐变;次操作使用浅色表面和边框;危险操作使用现有错误色。
- 列表项、统计卡片、筛选控件、表单和弹窗使用同一套圆角、文字层级和状态色。
- 医疗业务的警告、成功、待处理和异常状态继续保持语义颜色,不用装饰色覆盖含义。
### 状态反馈
- 加载状态保留明确进度反馈,避免空白页面。
- 空状态说明当前没有数据,并保留用户可执行的下一步操作(如果原流程已有操作)。
- 错误状态显示可理解的中文信息和重试入口(如果原页面已有重载能力)。
- 长文本和动态数据使用弹性布局与省略处理,降低窄屏溢出风险。
## 组件边界
- 优先使用现有 `AppColors``AppTheme`,不建立第二套颜色系统。
- 仅在多个医生端和管理员端页面确实重复时,新增小型共用展示组件,例如页面标题、区块卡片、状态占位和标签。
- 共用组件只负责展示与交互外观,不持有业务状态、不直接调用服务。
- 页面继续通过现有 Riverpod Provider 和 Service 获取数据,避免视觉改造改变数据流。
## 逻辑检查原则
- UI 改造过程中检查 `mounted`、重复提交、加载状态复位、空集合、空字段、分页/筛选和导航返回后的刷新。
- 检查医生与管理员 API 是否要求正确角色,并验证用户或患者数据归属。
- 明确的低风险页面错误可随 UI 修改修正;涉及鉴权、删除、隐私、医疗数据或接口行为的缺陷先记录并向用户说明,不擅自改变规则。
- 已知的全局上线风险单独列入最终检查报告,不与本次视觉修改混在一起扩大范围。
## 验证
- 运行 `flutter analyze`
- 运行受修改页面影响的现有相关测试;不新增复杂测试框架。
- 运行一次 Android 编译验证,确认 Flutter 与原生依赖能完成编译。
- 不运行截图测试、真机测试、iOS 构建或发布流程。
- 最终说明修改过的页面、发现的逻辑问题、验证结果和仍需人工确认的事项。
## 完成标准
- 医生端和管理员端全部现有页面在颜色、卡片、间距、按钮、文字层级和状态反馈上与患者端一致。
- 原有页面入口、导航、数据加载和操作流程不被视觉改造改变。
- 修改代码通过静态分析、相关测试和一次 Android 编译验证。
- 逻辑检查结果按严重程度清晰列出,高风险业务问题没有未经确认的行为变更。

View File

@@ -1,79 +0,0 @@
# 健康 App 前端 UI 设计系统
这份规范用于后续逐页精修。首页是品牌化例外,保持当前紫色背景和已有结构;以下规则主要约束普通二级页面。
## 1. 页面层级
- 标题栏:纯白背景,深色标题和返回图标。
- 页面画布:`#F7F8FA`,只负责建立内容边界,不作为卡片颜色。
- 主要内容组:纯白背景,区域间保留 1216 px 的画布间距。
- 列表行:同一内容组内连续排列,用文字区域浅分隔线区分,不为每一行单独套卡片。
- 重点对象报告、设备、AI 分析和关键概览可以使用独立白色卡片。
## 2. 颜色角色
- 品牌紫:通用选中、焦点、品牌按钮描边和少量重点操作。
- 模块色:只用于统一样式的图标、极浅图标底、图表曲线、小标签和状态识别。
- 中性色:页面画布、分隔线、正文层级和不可用状态。
- 语义色:绿色表示成功/正常,黄色表示提醒/等待,红色表示错误/危险,蓝色表示信息/进行中。
- 禁止模块色用于整页背景、大面积头部、大卡片底色和普通新增按钮。
## 3. 间距
- 页面左右边距16 px。
- 页面顶部内容间距12 px。
- 内容组间距1216 px。
- 列表图标与文字12 px。
- 列表行垂直内边距:根据内容使用 12 或 16 px不通过缩小字体制造紧凑感。
## 4. 圆角与阴影
- 所有圆角使用 `AppRadius`,不得在页面里随意新增数值。
- 连续内容组默认使用 16 px 圆角。
- 输入框和小型操作使用 1014 px 圆角。
- 普通内容组默认无阴影、无描边。
- 浮动操作可以使用一层轻微中性阴影;同一组件不同时使用明显边框和重阴影。
## 5. 字体层级
- 页面标题1920 px粗体颜色 `textPrimary`
- 区域标题18 px粗体。
- 列表主标题1617 px中粗体。
- 正文和补充说明1315 px保证对比度不使用过浅小字。
- 数值、单位、份量和热量在同一行时保持基线、字号和字重协调。
- 选中状态不能改变文字大小、字重或位置。
## 6. 图标
- 全 App 优先使用同一套 Material 线性 `outlined/rounded` 图标,不混用大面积实心图标。
- 普通列表图标底座统一为 40×40 px、10 px 圆角,图标统一为 22 px。
- 不同模块可以改变图标和极浅底色,但尺寸、线条感觉、底座形状和对齐必须一致。
- 无底座的小型操作图标统一为 2024 px并保证至少 44 px 点击区域。
## 7. 按钮
- 通用新增:白底、紫色渐变细描边、深紫色线性加号;所有模块共用同一组件。
- 页面主要操作:优先使用白底紫色品牌描边;是否使用实心按钮由逐页任务重要性决定。
- 次要操作:白底或极浅灰底、深色文字。
- 危险操作:白底红字;只有确认删除阶段显示红色危险面。
- 新增、上传、绑定和记录属于通用操作,不跟随模块颜色。
## 8. 列表与滑动操作
- 列表行连续排列,分隔线从文字区域开始,不穿过左侧图标。
- 整行可点击,附属按钮不能缩小主点击区域。
- 删除背景默认完全隐藏,向左打开删除操作后才出现。
- 删除失败时保留真实列表数据,不提前移除前端项目。
## 9. 弹窗、表单和状态
- 弹窗、底部面板统一白色,不跟随模块色。
- 输入框焦点继续使用品牌紫,错误提示使用语义红。
- 空状态说明原因并提供一个明确下一步;加载、空、失败和未开放必须能被用户区分。
- 暂未启用的问诊和随访显示明确准备状态,不伪装成可用功能。
## 10. 逐页精修原则
- 每次只精修一个真实页面或一组完全同构页面。
- 先看真机截图,再确定该页的信息层级、内容组、操作和模块色用量。
- 不因建立设计系统而批量重排所有页面;公共组件只提供一致基础,页面布局仍按实际任务设计。

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@@ -5,6 +7,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// 加载正式签名配置
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
android { android {
namespace = "com.datalumina.YYA" namespace = "com.datalumina.YYA"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -20,21 +29,35 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.datalumina.YYA" applicationId = "com.datalumina.YYA"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
// 正式签名配置key.properties 不存在时回退到 debug keystore仅用于本地 release 测试
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = keystoreProperties.getProperty("storeFile")?.let { path ->
file(path)
}
storePassword = keystoreProperties.getProperty("storePassword")
} else {
keyAlias = "androiddebugkey"
keyPassword = "android"
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
storePassword = "android"
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. signingConfig = signingConfigs.getByName("release")
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
} }
} }
} }

View File

@@ -1,10 +1,12 @@
<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.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"/>
<!-- BLE 蓝牙血压计 --> <!-- BLE 蓝牙健康设备(当前实现血压计同步) -->
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

12
health_app/appinfo Normal file
View File

@@ -0,0 +1,12 @@
IOS:
SHA1:
B1:17:1D:FD:7E:CA:CE:61:65:C2:2D:8B:A0:7C:57:5A:EB:EF:20:5E
SHA256:
D0:1E:AF:24:40:06:42:1B:FA:86:5E:3D:90:ED:FA:D7:3F:2D:E2:99:52:60:58:F4:85:4F:18:95:1E:B9:CC:96
MD5:
15:3B:89:ED:6B:C2:1B:6A:F9:6D:E0:71:44:54:CB:0F
Android:
SHA1: 48:48:22:D2:DA:B5:0D:4B:3F:D6:CA:8C:77:B1:36:B5:B5:8F:47:D1
SHA256: 0F:10:E7:B5:D0:8A:1E:4B:A6:67:EB:D4:D4:7F:00:C1:08:7E:EB:46:72:51:73:B2:AC:30:DB:69:18:DD:15:A2
MD5:D9:75:8F:17:66:17:3F:83:49:1A:82:34:59:9A:08:25

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

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