63 Commits

Author SHA1 Message Date
MingNian
b4bc15b9b9 feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新
后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
2026-07-28 15:03:38 +08:00
MingNian
25a4078688 feat: 启用百度云 SMS 真实验证码发送
- SmsService: 从开发桩改为调用 BceSmsClient.SendAsync 真实发送
- AuthService.SendCodeAsync: 加 60 秒冷却 + 发送结果检查(失败返回 40010)
- Program.cs: 注册 BceSmsClient 到 DI(AddHttpClient + 10s 超时)
- auth_tests.cs: 加 CreateSmsService helper 适配构造注入

移动号实测可收到验证码,联通号运营商环节待报备
2026-07-28 15:01:00 +08:00
MingNian
32aae40b12 feat: 新增百度云 SMS v3 签名算法与 HttpClient 直连实现
- BceSigner: BCE v1 签名算法(HMAC-SHA256),与 baidubce-sdk 输出 byte-for-byte 一致
- BceSmsClient: HttpClient 直连百度云 SMS v3 API,自写签名,无第三方 SDK
- BceSignerTests: 7 个单测,包括真实 AK/SK 对比百度 SDK debug 输出

暂未启用:SmsService 仍为开发桩,等签名运营商实名报备后接入真实发送
2026-07-23 18:58:33 +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
MingNian
fade61ac21 feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
2026-07-15 23:22:52 +08:00
MingNian
e654c1e0cc feat: 通知推送/免打扰偏好 + 饮食记录侧滑删除修复 + 聊天交互优化
- 通知: 新增推送开关/免打扰时段偏好持久化 + EF 迁移; 通知管线支持免打扰过滤
- 饮食: 侧滑删除 confirmDismiss 按方向放行(修复删不掉); 新增 diet_nutrition_widgets; 饮食录入页重构
- 聊天: 智能体胶囊点击锁防误触; 流式状态 select 优化; 卡片入场动画
- 主页: 消息列表提取 _HomeMessages + 侧滑手势打开抽屉
- 其他: api_client IP 适配; 通知/用药/饮食端点微调; 测试更新
2026-07-13 10:39:34 +08:00
MingNian
335a3e6440 feat: 饮食记录逻辑重构 + 通知管理分隔线修复 + 多页面 UI 调整
- 饮食: 新增 DietCommentaryPolicy + diet_record_logic, 饮食记录逻辑抽取复用
- 通知管理: 分隔线改为仿通知中心样式, 跳过图标区域只留文字下方
- 侧边栏: 对话记录操作栏浮层修复 + 常用功能间距收紧
- 智能体: 欢迎卡片去 400ms 延迟 + 胶囊去阴影
- 后端: AI 会话仓库新增方法 + 饮食评论策略 + 健康记录规则调整
- 其他: api_client IP 适配 + 多页面 UI 微调 + 新增测试
2026-07-12 22:49:38 +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
MingNian
d82e006cf4 refactor: 死代码清理 + 趋势图重构 + 侧边栏交互优化 + 智能体卡片去延迟
- 死代码清理: 删除 AppCard/AppMenuItem/AppTabChip/AppButtons 等 4 个未使用 Widget 文件; 清理 common_widgets/enterprise_widgets/app_status_badge 中未使用 class; 移除 HealthService 等未使用方法; 后端移除 ExerciseService.CreateFromItemsAsync/HealthArchiveService.GetOrCreateAsync/MedicationAgentHandler 死分支/ChatRequest DTO
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格; X 轴日+月份; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层; 选中点变实心
- 侧边栏: 对话记录改单选删除(灰底高亮); 操作栏浮层修复触摸问题; 常用功能/健康仪表盘间距收紧; _Panel 去外框
- 智能体: 欢迎卡片去掉 400ms 延迟; 胶囊去阴影
- 其他: api_client IP 适配; 多页面 UI 微调; 新增 chat_provider/prelaunch_guardrails 测试
2026-07-09 15:04:02 +08:00
MingNian
1c020b8ae5 feat: UI 系统中心化 + 趋势图重构 + 法律文档 H5 上线
- UI 系统: 新增 design_tokens / module_visuals / app_buttons / app_status_badge / app_toast / ai_content 六个共享模块; 28 个页面接入, SnackBar 全部替换为 AppToast
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格线; X 轴日+月份分隔; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层, 选中点变实心
- 法律文档 H5: 后端 wwwroot 托管隐私政策/服务协议/关于/个人信息收集清单/第三方 SDK 清单 + 索引页; Program.cs 启用 UseDefaultFiles + UseStaticFiles
- 其他: auth_provider 登录流程调整; api_client IP 适配; 主页智能体栏间距优化
2026-07-08 21:25:07 +08:00
MingNian
7a93237069 feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构
- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService
- 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放
- UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面
- 配置: api_client baseUrl 适配当前 WiFi IP
2026-07-06 12:44:59 +08:00
MingNian
4507083f3f refactor: 重构蓝牙设备页与新增设备页的扫描-连接-录入流程
- 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID)
- 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿
- 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据
- 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送)
- 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect)
- 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认
- 顺手更新本机开发 IP 与清理过时设计文档
2026-06-28 22:53:35 +08:00
MingNian
a748c316f2 chore: 清理废弃代码和 lint 警告
- 删除旧的 _chip 方法(服药次数改用滚轮后遗留)
- 移除多余的 ! 非空断言
2026-06-26 19:32:28 +08:00
MingNian
fa2cc994fa feat: 血压计联动心率记录 + 侧边栏左滑手势
- 血压计测量含脉搏时自动同步为 HeartRate 记录
- 首页 Scaffold 增加左侧滑手势打开侧边栏
2026-06-26 16:55:13 +08:00
MingNian
df37076c46 Polish health app UI 2026-06-26 14:37:17 +08:00
MingNian
86c7957144 style: 重构登录页视觉布局
- 使用品牌抽象背景和角落装饰素材
- 调整登录/注册卡片布局、品牌信息和表单层级
- 保留验证码、医生选择、协议勾选和登录注册流程
2026-06-25 22:19:10 +08:00
MingNian
18df2f3285 style: 统一患者端页面视觉并补齐入口跳转
- 调整全局颜色基调,提高灰色文字和边框对比度
- 新增企业级页面头部、统计块和卡片通用组件
- 优化用药管理、报告管理、个人信息、通知中心页面布局
- 新增多张页面插画和通用 UI 装饰素材
- 运动计划列表更紧凑,并新增运动计划详情路由
- 补齐医生端协议入口和原始报告查看入口
- 移除设置页未实现的字体大小和清除缓存入口
2026-06-25 22:13:42 +08:00
MingNian
53ce19e8c3 feat: 健康录入提醒 + 多指标异常 + 用药漏服 + UI 优化
后端
- NotificationPreference 增加健康录入提醒总开关和 5 子项(血压/心率/血糖/血氧/体重)
- EF 迁移 AddHealthRecordReminder
- 新增 GET/PUT /api/notification-prefs
- 新增 HealthRecordReminderService 每天 9 点/12 点扫描未录入用户推送提醒

前端
- 通知偏好对接真实后端 API
- 设置页新增「健康录入提醒」总开关+5 子项
- 通知中心右上角加齿轮跳设置页
- 今日健康卡片:4 项异常检测+多项合并;用药只显漏服并按时间窗口区分文案
- 侧边栏头像改圆形白底灰图标,去掉紫色品牌渐变
- 整体背景统一浅灰白 #F6F8FC,去掉浅紫渐变
- 设置页简化(去掉分组/彩色图标方块)
- 健康档案保存按钮移到右上角,TextButton 全局默认黑色
- API baseUrl 跟随网络环境
2026-06-24 22:11:21 +08:00
MingNian
7ff429e071 feat: 接入FastGPT知识库RAG检索,AI回答前先查医学资料
- 新增 FastGptKnowledgeClient:调用 searchTest 接口检索知识库
- ai_chat_endpoints: 调 DeepSeek 之前先检索,把相关片段拼到 system prompt
- 5个智能体(default/consultation/diet/report/unified)启用 RAG
- 3个纯工具调用智能体(health/medication/exercise)保持原样
- .env: 新增 FastGPT 配置(API Key、datasetId、相似度等)
- api_client: 更新默认 baseUrl 适配新网络环境
2026-06-22 20:50:32 +08:00
MingNian
73d99d56f6 fix: 趋势页恢复体重 + 图标换Material Icons + VLM禁用思考模式
- 健康概览页恢复体重指标的标签/单位/加载/筛选/录入
- 历史记录图标从emoji改为Material Icons
- VLM请求显式设置enable_thinking=false,禁用混合思考加速识图
2026-06-22 16:45:51 +08:00
MingNian
80540e2191 fix: 健康档案保存按钮移至右上角 + 5页面补充返回按钮 + API地址修复
- 健康档案: 保存按钮移到AppBar右侧,删除底部按钮和绿色成功SnackBar
- 5个缺失页面补充左上角返回按钮: 健康概览/编辑用药/报告管理/报告详情/报告解读
- api_client.dart: 默认baseUrl改为192.168.1.29 (支持WiFi直连)
2026-06-22 15:58:59 +08:00
MingNian
6ce010e46a feat: AI回复格式约束 + 前端清洗器增强
- prompt: 禁止**粗体*斜体等内联标记、HTML标签、表格,标题仅限###
- 前端_cleanAiText: 移除$N占位符、HTML标签、多余#标题降级
- 确认卡片/结构化消息不受影响
2026-06-22 14:14:42 +08:00
MingNian
431b72d49a feat: Cupertino滚轮选择器 + 品牌更名 + UI全面优化
- 日期/时间/次数选择器统一改为底部弹出Cupertino滚轮,无单位标签,双横线选区
- App更名为"小脉健康",副标题"血管病患者的AI健康管理助手"
- 健康仪表盘及侧边栏移除体重指标,侧边栏背景改为蓝白渐变
- 健康档案按钮移入功能入口网格,与其他入口样式统一
- 记数据智能体配色改为绿色渐变(#A1FFCE→#69DB8F)
- 隐私协议/关于页从Text改为MarkdownBody正确渲染
- 运动计划/饮食记录卡片添加边框和阴影
- 服药次数从chip改为滚轮选择器
- 日期显示格式统一为空格分隔(1999 01 01)
- 健康档案页背景改为纯白
2026-06-22 13:59:37 +08:00
MingNian
a479e5e95d style: 今日健康卡片顶部标题改用健康档案的紫蓝左右渐变 2026-06-21 23:37:18 +08:00
MingNian
a7fb6e5ff3 fix: 启动页遮住登录/首页初始态,消除深色模式黑闪
- 新增 SplashPage + appReadyProvider/_BootGate:启动闸门将启动页覆盖在最上层,
  直到 auth 判定完成且今日健康卡数据就绪,遮住登录页闪现与首页对话流初始态
- auth 初始状态改为 isLoading=true,无 token 时再置 false
- values-night/styles.xml 改为亮色白底,消除深色模式下 Flutter 初始化期间的黑色窗口背景
- 带 8 秒安全兜底,避免离线时卡在启动页
2026-06-21 23:32:02 +08:00
MingNian
415c7ca082 fix: 请求体解析失败返回 400 而非 500
ExceptionMiddleware 捕获 BadHttpRequestException(非法 JSON/编码错误/请求体过大等),
按其携带的状态码返回(通常 400),避免把客户端请求格式问题误报为服务器内部错误。
2026-06-21 21:15:02 +08:00
MingNian
13714d9ed8 feat: 应用内通知系统 + 结构化手术史/用药等相关改动
- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker
- 通知中心页面及前端通知服务接入
- 健康指标异常、用药/运动提醒等事件统一产出站内通知
- 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整
- AppDbContext 注册通知相关实体
2026-06-21 21:06:29 +08:00
MingNian
b57d0d16f4 feat: 引入 EF Core Migrations + 后台任务/校验修复 + 前端四态
后端
- 改用 EF Core Migrations(InitialCreate),Program.cs 用 MigrateAsync + AUTO_MIGRATE 开关 + 显式 MigrationsAssembly;移除 EnsureCreated、删除手写 DatabaseSchemaMigrator
- 修复后台任务永久卡 Processing:三个队列对超时且达上限的任务原子标记 Failed
- 修复报告重试失效:基础设施异常向上抛激活 RetryAsync,停机取消不计失败
- 修复 AI 用药天数 off-by-one(结束日为包含式)
- AI 报告日志不再记录 VLM 健康正文
- 新增统一输入校验(ValidationException + 中间件映射 400):血压/心率/血糖/血氧/体重范围、药名/日期/服药时间去重、饮食热量与评分、运动时长上限
- 删除健康数据 AI 录入的影子直写路径,统一走 Service 校验

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

瘦身
- 删除过时的 DataSeeder / DevDataSeeder 及调用
- 删除依赖实时服务的 ai_agent_tests 集成测试
2026-06-21 21:04:40 +08:00
MingNian
aa44d6f0f0 refactor: 清理 AI 客户端死代码并统一错误处理
- 删除未使用的 OpenAiCompatibleClient,DTO 抽离到 ai_contracts.cs
- 新增 AiHttpResponseGuard 统一 LLM/VLM 的 HTTP 错误处理:
  错误响应带响应体(截断 2000 字符)、空响应与无效 JSON 显式抛 InvalidDataException
- 空 catch 补全精确异常类型(IOException/UnauthorizedAccessException/JsonException)
- 修正 Program.cs 数据库初始化注释
- 新增 AI 客户端错误处理单元测试
2026-06-20 22:06:31 +08:00
MingNian
4d213b5a44 feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
2026-06-20 20:41:42 +08:00
MingNian
c610417e29 fix: 胶囊高度42px + 功能卡片白底 + 面板绿粉渐变 + 间距调整 2026-06-17 17:50:27 +08:00
MingNian
21e67014aa fix: 健康档案按钮背景色换紫蓝渐变 2026-06-17 17:38:20 +08:00
MingNian
fbaed0cf1d fix: 健康仪表盘单位下移 + 档案按钮浅紫 + AI问诊胶囊改名 + 删除账号功能 2026-06-17 17:35:47 +08:00
MingNian
0f2a9c1c1a fix: 欢迎卡片图标去白圆点 + 按钮渐变图标 + 多处微调 2026-06-17 11:22:02 +08:00
MingNian
4f1ad82345 fix: 欢迎卡片UI优化 + 蓝牙录入按钮 + 多处修复
- 欢迎卡片:去掉白框圆角错位、图片满宽、按钮去箭头改圆角居中
- 渐变actionOutlineGradient改紫蓝双色循环
- 新增健康Agent"蓝牙录入"按钮→蓝牙设备页
- "查看健康情况"改名"健康概览"
- 饮食编辑框删除bug修复(控制器缓存)
- 启动脚本加adb reverse自动转发
- 登录闪屏修复
2026-06-17 10:58:59 +08:00
MingNian
9279b7e283 fix: 食物识别编辑框删除bug + VLM相关代码回滚
- 修复饮食识别结果编辑框一次只能删一个字的bug(控制器缓存)
- 回滚VLM图片压缩代码到原始状态
- 保持原有显示逻辑不变
2026-06-16 17:08:32 +08:00
MingNian
c70d5d4be6 fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复
- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者
- 注册页重构:去掉角色选择+审核码,改为选医生+填姓名
- 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立
- Doctor/DoctorProfile/User实体新增关联字段
- JSON循环引用修复(IgnoreCycles)
- /api/doctors改为公开接口
- 登录闪屏修复+原生Android启动页
- 输入框全局白底+灰框
- 蓝牙重连同步修复
- Web端(doctor_web+health_app/web)全部删除
- 全局UI改版:白底企业风,新配色和组件系统
- 新品牌图标和启动图
2026-06-16 15:16:44 +08:00
MingNian
b635e9d25f Update app branding assets 2026-06-15 18:13:52 +08:00
MingNian
01cd132476 feat: 医生端完整实现 + 双角色登录 + 健康日历 + 饮食记录重做 + 配色系统重构
后端:
- User表加Role字段 + DoctorProfile表 + 废弃旧Doctor数据
- JWT加Role claim + 注册/登录拆分(/api/auth/register + /api/auth/login)
- 医生端点全量加JWT鉴权+Role校验(15个端点)
- 日历端点改造(用药/运动/随访按日期+星期匹配)
- 饮食记录PUT端点 + 健康记录DELETE端点
- 用户Profile端点返回Role

前端:
- 登录页重做(角色选择卡片 用户紫/医生蓝 + 审核码6666 + 注册返登录)
- App路由分流(userRole→患者端/医生端)
- 医生端: 工作台Dashboard + 患者管理(搜索分页) + 问诊/报告/随访CRUD
- 报告审核: AI预分析+指标表格+严重程度4级+模板多选+评语
- 随访编辑: 患者选择器+日期时间+打通患者端
- 健康日历: 可点击日历+当日安排列表+用药/运动/随访颜色标记
- 饮食记录: 热量趋势7/30天+日历(一周)+当日胶囊详情+侧滑编辑删除
- 健康档案: 日期选择器+手术可添加多条+白底渐变保存按钮
- 配色系统: 清理25个未使用色+新增doctorBlue/drawerGradient/pageGrey
- 患者端侧边栏: 新渐变+白底按钮+紫色汉堡图标
- AI对话: 用户气泡白底黑字+药管家改蓝色
- App启动闪屏: 防登录页闪烁

文档: color_design_system.md(20页面完整配色设计+图标底色对照表)
2026-06-14 23:28:14 +08:00
MingNian
d102205b18 feat: 蓝牙血压计BLE数据同步 + 设备管理页重构 + 健康记录滑动删除 + 医生端合并设计文档
- BLE: 修复Omron设备Indication模式连接(先订阅后配CCCD + forceIndications) + 直连重连
- BLE: 设备断连实时检测(connectionState流→Provider→UI)
- 修复: 血压数据上报后端枚举不匹配(Device→DeviceSync)导致500
- 新增: DELETE /api/health-records/{id} 后端接口
- 设备管理页: 白色主题重设计 + 直连重连 + 实时状态 + Toast通知
- 设备扫描页: 白色主题 + 扫描动画居中 + 已连接等待页面
- 健康记录: 左滑删除(Dismissible) + 同时清理_allRecords和_filtered
- 导航: 修复自定义路由栈下Navigator.pop黑屏bug
- 文档: 医生端合并App完整设计文档(已确认版)
2026-06-11 22:11:02 +08:00
MingNian
66168e3261 docs: 更新页面设计文档+需求文档至当前实现状态
- 页面设计文档:新增色彩系统/智能体配色/圆角规范/已实现15个页面详情/交互特性/后端接口表
- 需求文档:新增已实现/待实现功能清单
2026-06-11 11:29:09 +08:00
MingNian
7baf0d513f fix: 登录页白底+渐变边框/胶囊加大/底部提示/今日健康优化/发送按钮蓝紫渐变
- 登录页:输入框白底+渐变紫蓝外框,去掉focus内框;登录按钮白底紫字;协议勾选白底
- 首页胶囊:适老化加大(内边距14x8/字号15/图标17/圆角18)
- 欢迎卡片底部提示:各智能体专属引导文案,字号加大颜色加深
- 今日健康卡片:健康指标改一行总结(异常提醒/正常显示);用药显示具体药品名+状态
- 发送按钮:蓝→紫渐变
- 设置页菜单:白色卡片+圆角16+轻阴影
- IP改localhost:adb reverse解决IP变动问题
- 服药打卡快捷按钮:改为跳转打卡页,不再一键全打
2026-06-11 11:17:13 +08:00
MingNian
f6dd14be3b feat: 全面UI改造 — 报告/饮食/运动/用药/侧边栏/对话流/胶囊
- 报告模块:重写AI解读页,统一白色卡片+三栏布局(指标→解读→医生审核);加删除功能+后端删接口;VLM自动识别报告类型生成中文标题;去五颜六色
- 饮食分析:全面重设计,暖橙主题配色,图片自适应,餐次emoji选择器,去紫色
- 运动确认卡片:修复duration_minutes JSON类型转换,支持中文数字识别(三十分钟/半小时→30);主区域只显示运动名,字段行改为横向排列
- 流式输出:简化为最基础逐字追加,去buffer+Timer+淡入动画
- 欢迎卡片:每智能体独立渐变色(青/橙/蓝/紫/绿/粉),加卡片入场滑入+淡入动画(600ms)
- 确认卡片:头部去紫色背景改浅灰白,字段行去图标改纯信息横排,编辑框去双重边框
- 用药管理:胶囊改TabBar,添加按钮改右下FAB;打卡按钮改对号圆圈形式;药丸图标白底
- 侧边栏:加VIP服务/保险栏+图标,标题字体放大;服务包去查看更多+去VIP金色标签
- 胶囊:首页智能体胶囊白底深色字; side胶囊加阴影
- 对话流:reverse=false,今日健康出现在顶部; 对话页input bar匹配主页面样式
- 其他:个人资料去紫色+去多余入口;设置页白底图标;蓝牙页加返回按钮;报告管理改名
- 后端:加DELETE /api/reports/{id}; 修复manage_exercise数据类型转换;AI提示优化中文数字
2026-06-10 23:46:45 +08:00
MingNian
63d2092c24 feat: UI全面改造 + 后端多项修复
【后端修复】
- 删除diet/consultation agent假工具声明
- 修复DayOfWeek实体注释
- 修复Vision API content序列化
- cleanup_service级联删除修复
- 用药提醒时区偏差修复
- 统一DateTime处理(UtcNow+8)
- 新增UTC DateTime JSON转换器

【前端UI重构】
- 配色体系全面更新(#8B5CF6淡紫+#F0ECFF背景)
- 登录页重设计
- 首页重设计(透明顶栏、渐变背景、胶囊输入区)
- 聊天卡片加白蓝边框、渐变标题
- 侧边栏重构(渐变背景、合并顶部、删除底部设置)
- 确认卡片可编辑字段恢复
- 所有子页面加返回按钮
- catch异常加日志
- 删除后refresh provider缓存
2026-06-10 18:27:38 +08:00
MingNian
3964cf2bcb feat: 蓝牙血压计基础架构 + UI配色体系升级
- 新增 AppColors 配色体系(紫蓝渐变 + 超大圆角 + 漂浮卡片)
- 新增 OmronBleService(BLE 扫描/连接/SFLOAT 协议解析)
- 新增 BpReading 数据模型 + 血压数据自动同步后端
- 新增 DeviceScanPage(血压计扫描绑定页)+ DeviceBindPage(已绑定管理页)
- 侧边栏新增"蓝牙设备"入口,ProfilePage 移除设备管理
- Android/iOS BLE 权限配置(BLUETOOTH_SCAN/CONNECT)
- AppTheme 升级:新阴影系统、新语义色、圆角放大
2026-06-09 18:48:58 +08:00
MingNian
f01fc9268d feat: UI清爽紫蓝风全面改造 + 对话卡片重构 + 配色体系升级
- 新增 AppColors 统一配色方案(清爽紫蓝,参考蚂蚁阿福风格)
- 新增 common_widgets(GradientBorderButton / CardActionButton / IconBox)
- AgentWelcomeCard 美化:渐变 header + AI标签 + 核心功能区 + 快捷操作区
- DataConfirmCard 统一:三合一(健康/药品/运动),可编辑字段列表
- 删除死代码:medicationConfirm/dietAnalysis/reportAnalysis/quickOptions 卡片
- 删除死路由:doctors/profileEdit/editProfile
- AI 对话去除 Markdown 符号(_stripMd),统一行间距 1.5
- 逐字淡入动画修复:key 改用 stableId 避免重复触发
- 健康概览时间修复:前端发送 UTC 避免时区歧义
- PG 数据目录迁入项目 backend/pgdata/,加入 .gitignore
- 新增欧姆龙血压计实施方案文档
2026-06-09 17:04:11 +08:00
MingNian
5f8964e03f fix: 绿色主题回滚为淡紫清新风
朝露绿翻车,换回柔和紫(#7C6FF7) + 淡紫白底(#FAF9FF)
2026-06-08 18:08:22 +08:00
MingNian
58af5f6d5b feat: UI 清新风全面改造 — 朝露主题 + shadcn_ui + 全局字号放大
- 全新"朝露"主题:深青绿主色(#2D6A4F),暖白底,完整设计Token
- 引入 shadcn_ui 组件库,MaterialApp 注入 ShadTheme
- 新增 4 个公共组件:AppCard、AppMenuItem、AppEmptyState、AppTabChip
- 全面消除硬编码颜色,统一使用 AppTheme Token
- 全局字号 +3~4pt,图标等比放大 +3px
- home/medication/profile/settings/login 等核心页面重写
- 路由和功能逻辑零改动
2026-06-08 18:06:18 +08:00
MingNian
16d1d3d305 feat: 用药打卡全面重构 - 按次追踪 + 独立打卡页 + 任务区汇总
- 后端用药提醒改为按次粒度(每个服药时间独立判断)
- 新增 per-dose 打卡/取消打卡 API
- 新增 MedicationCheckInPage 独立打卡页(每药每时间单独toggle)
- 用药列表删底部弹窗(编辑/停药),点药品直接进该药打卡页
- 任务区用药汇总为一行(过期待服已服)
- 支持隔天(EveryOtherDay)/每周频率判断
- 删历史对话功能(ConversationItem + conversationListProvider + UI)
2026-06-08 16:21:13 +08:00
MingNian
fc7150ad60 feat: 统一智能体路由 + 删除 onboarding
- 新增 Unified 智能体(合并 health/diet/medication/exercise/default 全部工具)
- AI 自动判断意图并调用对应工具,前端胶囊自动切换
- 删除 onboarding 智能体及所有建档引导 UI
- 前端始终发 unified 类型,通过 tool_result 事件同步胶囊状态
2026-06-08 14:55:38 +08:00
MingNian
b5f2977b32 chore: 更改 applicationId 为 com.datalumina.YYA 2026-06-08 14:40:25 +08:00
MingNian
714adc2d51 fix: diet capture flow - 去掉多余页面,点击直接拍照
- 点击"拍照识别/上传照片"直接打开相机/相册,不再跳转多余选择页
- DietCapturePage 去掉重复的拍照按钮,只保留分析结果展示
- 新增 dietActionProvider 处理拍饮食触发
- 修复 initState reset 导致结果页被清空的 bug
- 修复 IP 地址恢复问题
2026-06-08 14:39:45 +08:00
440 changed files with 60590 additions and 16771 deletions

17
.gitignore vendored
View File

@@ -4,9 +4,20 @@
*.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/
backend/artifacts/
# Generated local audit reports
audit/*.html
# PostgreSQL local data
backend/pgdata/
# Flutter build outputs # Flutter build outputs
health_app/build/ health_app/build/
@@ -34,9 +45,15 @@ health_app/ios/.symlinks/
# Uploads & logs # Uploads & logs
backend/src/Health.WebApi/uploads/ backend/src/Health.WebApi/uploads/
backend/src/Health.WebApi/data-protection-keys/
*.log
*.txt *.txt
*.jpg *.jpg
*.png *.png
!backend/src/Health.WebApi/wwwroot/app-icon.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-v21/launch_brand.png
# Claude # Claude
.claude/ .claude/

57
AGENTS.md Normal file
View File

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

View File

@@ -1,189 +0,0 @@
# AI System Prompt 总览
> 文件位置:`backend/src/Health.Infrastructure/AI/prompt_manager.cs`
---
## 1. 默认对话 (DefaultPrompt)
```
你是一位专业的AI健康管家专注于为心脏术后康复患者提供贴心的健康管理服务。
职责:
1. 理解用户的健康需求,解析健康数据
2. 主动查看患者近期数据,发现异常时提醒
3. 回答健康知识问题
4. 每次回复末尾,如有需要提醒的事项,简短温馨地提醒一句
规则:
- 不要提供超出你能力范围的医疗建议
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
- 饮食/运动建议要结合患者档案中的疾病和限制
- 回复语气温暖、专业、像朋友一样关怀患者
```
---
## 2. AI 问诊 (ConsultationPrompt)
```
你是一个心血管内科医生助手,负责对心脏术后患者进行多轮问诊。
规则:
1. 每次只问一个问题,不要一次问多个
2. 给出 2-3 个快捷选项让患者点击
3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析
4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸
5. 遇到以下情况建议转医生:血压持续>160/100、心率>120或<50
6. 所有分析末尾标注"以上为AI分析具体请咨询医生"
7. 问诊结束给出结构化小结
```
---
## 3. 记数据 (HealthDataPrompt)
```
你是一个健康数据录入助手。
规则:
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
2. 指标明确+数值明确→直接录入
3. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
4. 时间模糊→取当前时间,在确认卡片中告知用户
5. 数值超出正常范围→附带异常提醒
6. 录入后生成确认卡片格式的回复
正常值参考范围:
- 收缩压 90-139 mmHg舒张压 60-89 mmHg
- 心率 60-100 次/分
- 空腹血糖 3.9-6.1 mmol/L
- 血氧 95-100%
```
---
## 4. 拍饮食 (DietPrompt)
```
你是一个营养分析专家,专门为心脏术后患者提供饮食指导。
规则:
1. 收到VLM食物识别结果后必须先调用 check_archive 查询患者档案
2. 逐项判断"能不能吃"
- 过敏食物 → 严格禁止,红色警告
- 饮食限制(如低盐/低脂/低糖)→ 黄色提醒
- 疾病禁忌(如冠心病忌高脂、糖尿病忌高糖)→ 红色警告
3. 给出 1-5 星健康评分
4. 结合患者档案中的诊断如冠心病PCI术后、慢病史高血压/高血脂等)给出个性化建议
5. 单项警告 + 整体饮食建议 + 替代推荐
6. 追问餐次归属:早餐/午餐/晚餐/加餐
```
---
## 5. 药管家 (MedicationPrompt)
```
你是一个用药管理专家。
规则:
1. 用户询问当前用药时,必须先调用 manage_medication(action="query") 查询已有用药记录
2. 解析用户口中的药品信息(药名/剂量/频次/时间)
3. "早饭后"等模糊时间→追问具体几点
4. 解析完成后展示确认卡片
5. 处方拍照→提取药品信息→生成用药计划→让用户确认
6. 回答用药相关疑问
```
---
## 6. 看报告 (ReportPrompt)
```
你是一个医学报告解读专家。
规则:
1. 收到报告图片后,提取所有指标及其数值
2. 标注异常指标(偏高/偏低/正常)
3. 给出初步分析
4. 所有内容标注"AI预解读待医生确认"
5. 图像类报告(彩超/CT注明"需医生人工审阅"
```
---
## 7. 运动计划 (ExercisePrompt)
```
你是一个运动康复教练,专门为心脏术后患者制定运动计划。
规则:
1. 帮助用户设定每周运动计划(类型/时长/天数)
2. 以周为单位,每天指定运动类型和时长
3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等
4. 运动强度要循序渐进
5. 避免剧烈运动
```
---
## 8. 建档引导 (OnboardingPrompt) — 新增
```
你是一个健康管家助手,正在帮助新用户建立健康档案。
必须严格遵守以下流程,一次只问一个问题:
步骤1询问主要诊断冠心病/高血压/糖尿病/其他)
步骤2询问手术史PCI支架植入/心脏搭桥/其他/没有)→ 追问手术日期
步骤3询问过敏史青霉素/头孢/海鲜/鸡蛋/无过敏)→ 可多选
步骤4询问慢性病史高血脂/糖尿病/痛风/无)
步骤5询问饮食限制低盐/低脂/低糖/无限制)
规则:
- 每次只问一个问题给出2-4个快捷选项让用户点击
- 用户回答每个问题后,立即调用 manage_archive 工具保存
- 用户说"跳过"/"以后再说"/"再说"/"不用了"→ 礼貌结束建档
- 全部完成后,生成结构化总结卡片
- 回复语气温暖、像朋友一样关怀患者
- 每步只问一个问题不要多问
```
---
## 9. VLM 食物识别 Prompt
> 文件位置:`backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` → `POST /api/ai/analyze-food-image`
```
仔细观察图片中的所有食物和饮品,逐一识别。
对每一项,用中文返回 JSON 数组,每项包含:
name: 食物或饮品的中文名称
portion: 份量估算(如"约1碗"、"约400g"、"约200ml"
calories: 估算热量(千卡整数)
要点:
必须根据图片实际内容识别,如实描述
饮品要标注清楚(如水、茶、咖啡、果汁、汤等)
热量参考常见食物数据库合理估算
只返回 JSON 数组,不要其他文字
```
---
## 10. VLM 报告分析 Prompt
> 文件位置:`backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs`
```
你是一个医学报告解读专家。请分析以下检查报告图片以JSON格式返回
{
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
"indicators": [
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
],
"summary": "初步分析摘要",
"needsDoctorReview": true/false
}
只返回JSON不要其他内容。
```

View File

@@ -19,6 +19,7 @@
-前端用sqlite存储信息不用shared_preferences -前端用sqlite存储信息不用shared_preferences
- 用 Riverpod 管理所有状态和页面跳转 - 用 Riverpod 管理所有状态和页面跳转
- com.datalumina.YYA applicationId用这个这个是有要求的
## 运行与测试 ## 运行与测试
- 测试时自行启动后端,通过浏览器 DevTools 发送请求验证 - 测试时自行启动后端,通过浏览器 DevTools 发送请求验证

1
PC Normal file
View File

@@ -0,0 +1 @@
Phone - forwarding OK

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,302]"><node NAF="true" index="0" text="" resource-id="" class="android.widget.Button" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[14,127][182,295]" /><node index="1" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="复查随访" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[600,173][840,250]" /></node><node index="1" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="暂无随访安排" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[600,1816][840,1872]" /><node index="2" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="医生创建随访后将显示在这里" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[401,1886][1039,1952]" /></node></node></node></node></node></node></node></node></hierarchy>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,261 @@
# 小脉健康项目全面检查报告
检查日期2026-07-14
检查范围Flutter Android 真机主要用户路径、Flutter 静态检查与测试、.NET 后端测试、前后端接口对应关系、用户/医生/管理员业务逻辑、权限安全、合规页面与 Android/iOS 发布准备。
本轮只检查,没有修改业务代码,也没有使用 Docker。
## 一、总体结论
项目主体功能已经比较完整,普通用户主路径能进入,当前 Flutter 静态检查通过Flutter 47 项测试和后端 44 项测试全部通过。但测试通过不等于已经稳定:本次仍确认了多项自动测试没有覆盖的真实断点。
当前不适合进入正式环境或商店审核。最优先的不是继续统一小间距,而是先处理问诊鉴权、管理员验证码、上传文件访问、蓝牙上传、账号注销清理和正式包权限。这些问题会影响真实数据、安全或用户是否能完成任务。
## 二、必须先修的问题
### 1. AI 流式聊天的 token 只被“读出来”,没有验证真伪
- 用户表现:正常用户不一定马上发现,但攻击者可以自己伪造一个 token冒充任意用户进入 AI 对话、读取该用户上下文,并可能触发健康数据写入。
- 原因SSE 接口没有强制登录;备用 token 处理只解析 JWT 内容,没有校验签名、过期时间、签发方和接收方。
- 位置:`backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs``GetUserIdFromToken`
- 优先级:最高。
### 2. 医生问诊实时通道没有登录和问诊归属校验
- 用户表现:知道问诊编号的人可以加入房间,甚至把自己声明成“用户”或“医生”发送消息;消息还会真实写入数据库。
- 原因SignalR Hub 没有鉴权,加入房间和发送消息时也没有核对当前账号是否属于该患者或医生。
- 位置:`backend/src/Health.WebApi/Hubs/ConsultationHub.cs`
- 优先级:最高。
### 3. 管理员存在固定手机号和固定验证码
- 用户表现:正式环境若沿用当前代码,知道固定号码的人可用 `000000` 登录管理员端。
- 原因:管理员手机号和验证码直接写在认证服务中,而且不只在开发环境生效。
- 位置:`backend/src/Health.Infrastructure/Auth/AuthService.cs`
- 优先级:最高。
### 4. 上传的报告、聊天图片和 PDF 可被未登录访问
- 用户表现:文件链接一旦泄露,不登录也能打开健康报告或聊天附件。
- 原因:整个 `/uploads` 目录作为公开静态目录提供,没有按用户、医生关系或临时授权校验。
- 位置:`backend/src/Health.WebApi/Program.cs` 的上传目录静态映射。
- 优先级:最高。
### 5. 蓝牙血压计能读到数据,但自动上传会失败
- 用户表现:设备连接成功、血压已经读取,最后却提示上传失败;血压和心率不会真正保存到后端。
- 原因:前端提交到 `/api/health-records/batch`,当前后端没有这个接口。
- 位置:`health_app/lib/pages/device/device_management_page.dart``backend/src/Health.WebApi/Endpoints/health_endpoints.cs`
- 优先级:高。
### 6. 每次打开医生问诊页都会创建一条新问诊并消耗次数
- 用户表现:只是进入页面看一下、没有发送任何内容,也会新增问诊;反复进出会快速消耗每月次数。显示剩余 0 次时仍可能继续创建,甚至出现负数。
- 原因:前端初始化先查次数,然后无条件创建;后端创建接口没有真正执行三次上限,也没有验证医生仍然有效。
- 位置:`health_app/lib/providers/consultation_provider.dart``backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs`
- 优先级:高。
### 7. 问诊消息发送失败时仍显示为已发送
- 用户表现:网络断开或服务器失败后,消息仍留在聊天里,没有失败标记和重试入口,用户会误以为医生已经收到。
- 原因:发送异常被静默忽略,本地消息没有回滚或转成失败状态。
- 位置:`health_app/lib/providers/consultation_provider.dart``sendMessage`
- 优先级:高,且不符合“不能假成功”的项目要求。
### 8. Android 正式包没有网络权限
- 用户表现Debug 真机可联网,但以后构建 release 包后所有登录、数据、AI 和报告请求都无法联网。
- 原因:`INTERNET` 只在 debug/profile Manifest 中main Manifest 没有release 不会合并 debug 配置。
- 位置:`health_app/android/app/src/main/AndroidManifest.xml`
- 优先级:正式包前必须修。
### 9. 账号注销只删数据库记录,不会清理服务器上传文件
- 用户表现App 显示账号已删除但报告原图、聊天图片、PDF 等仍可能留在服务器目录;公开链接仍可能继续访问。
- 原因:账号注销批量删除数据库数据时没有收集并删除物理文件。单独删除报告有删文件,注销账号没有走同一流程。
- 位置:`backend/src/Health.Infrastructure/Users/EfUserRepository.cs`
- 优先级:高,涉及合规承诺和真实删除。
### 10. iOS 相机和相册权限说明缺失
- 用户表现:以后在 iPhone 上拍饮食、拍报告或选相册时,系统可能直接终止 App而不是正常弹出权限框。
- 原因Info.plist 有蓝牙说明,但没有 `NSCameraUsageDescription``NSPhotoLibraryUsageDescription`
- 位置:`health_app/ios/Runner/Info.plist`
- 优先级iOS 调试前必须修。
## 三、重要功能和稳定性问题
### 11. 多个请求同时遇到登录过期时,可能刚刷新成功又被强制退出
多个 401 会同时拿同一个刷新 token。第一个请求刷新并保存新 token 后,第二个请求可能因旧 token 已失效而失败,随后把刚保存的新 token 一并清掉。用户会偶发跳回登录页。
位置:`health_app/lib/core/api_client.dart` 的 401 刷新逻辑。
### 12. 启动时只要服务器暂时连不上,就把用户当成未登录
刷新 token 的请求遇到断网或本地后端未启动时,认证状态直接变为未登录,但本地 token 又没有清除。用户会看到登录页,网络恢复后也不会自动重试,只能重启或重新操作。
位置:`health_app/lib/providers/auth_provider.dart``_checkAuth`
### 13. 问诊轮询用随机 GUID 判断“之后的消息”,可能漏消息
SignalR 失败后会回退轮询。后端把随机消息 ID 与上一条随机 ID 做大小比较,这不等于消息时间顺序,可能漏掉医生新回复。正确游标应基于服务器时间或稳定递增字段。
位置:`backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs`
### 14. 问诊 AI 开场白只存在前端,没有保存
用户看到的“AI 分身问候”不是数据库消息,医生端看不到;重新进入又会新建问诊并生成另一条本地问候,历史不连续。
位置:`health_app/lib/providers/consultation_provider.dart`
### 15. 短信验证码没有频率、次数和防刷限制
发送接口可反复调用,验证码校验也没有失败次数限制。正式短信接入后会产生短信轰炸、费用和验证码暴力尝试风险。
位置:认证端点和 `AuthService`
### 16. 管理员新增、停用、修改、删除医生时,几套医生数据可能不同步
- 手机号已属于普通用户时,仍可创建医生实体,但不会把已有用户升级并绑定医生档案。
- 修改医生手机号/姓名时,没有同步医生登录账号与医生档案。
- 停用医生只改一处状态,医生档案的状态可能仍是启用。
- 删除医生后,医生登录用户仍可能保留 Doctor 角色,但档案已失去关联,登录后只能看到“档案未关联”。
- 医生手机号没有唯一约束,可能出现重复医生。
位置:`backend/src/Health.Infrastructure/Admin/AdminService.cs`
### 17. 附件本地路径没有限制在 uploads 目录内
附件 URL 中的相对路径直接拼到服务器路径,没有做规范化后再确认最终路径仍位于 uploads 内,也没有核对文件是否属于当前用户。这会带来跨用户读取或路径越界风险。
位置:`backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs`
### 18. 通知点击先联网标记已读,失败时目的页面也打不开
用户点一条未读通知时,先请求后端标记已读;如果这一步失败,后续报告/用药/健康页面不会打开,也没有明确提示。
位置:`health_app/lib/pages/notifications/notification_center_page.dart`
### 19. 用户端与医生端部分“今天/本月”按 UTC 计算
中国时区的凌晨时段可能被算到上一天或上个月,影响问诊次数、今日随访和提醒归类。正式环境应统一明确业务时区。
位置:问诊和医生工作台中使用 `DateTime.UtcNow.Date` 或 UTC 月份的逻辑。
## 四、UI 与交互问题
### 20. 用药和运动列表默认露出整片红色删除背景
真机截图中,用药行和运动行在没有滑动时已经整行变红;运动行还出现“打卡”和“删除”重叠。用户会误以为数据异常或正处于危险删除状态。
原因是通用滑动删除组件始终铺红色底层,而这些列表行本身透明。测试只验证滑动动作,没有验证未滑动时的最终视觉。
证据:`05-medications.png``09-exercise.png`
位置:`health_app/lib/widgets/common_widgets.dart`
### 21. 首页多个纯图标按钮缺少无障碍名称
真机可访问性结构中,菜单、附件、输入框和发送按钮等多个可点击区域没有名称。普通用户能凭图标猜,读屏用户无法知道用途。
证据:`01-current.xml`
### 22. 删除动作风格不统一
报告、用药、运动使用滑动删除,通知使用整行侧滑,蓝牙设备直接显示红色垃圾桶,设置页则用大按钮。交互成本和误触风险不同。建议统一为“列表行滑动后显示删除 + 二次确认用于高价值数据”。
### 23. 设置页的红色占比过重
“删除账号”和“退出登录”两个大面积危险操作占据页面最强视觉层级,反而压过消息、隐私和服务协议。删除账号保持红色是合理的,但退出登录不必使用整块高饱和红色。
证据:`12-settings.png`
### 24. 侧边栏健康仪表盘过于抢眼
大面积蓝紫粉渐变和四个小框成为最强区域,与项目“克制、专业、模块色只做识别”的偏好有偏差;指标小字和透明边框的对比度也偏弱。
证据:`02-drawer.png`
### 25. 空状态表达不统一
复查随访使用居中图标和说明;饮食使用一条白色容器;其他页面可能只留空白。建议统一空状态的标题、原因和下一步操作,但不需要每页再套一张大卡片。
证据:`06-diet-records.png``08-followups.png`
### 26. 部分图标按钮只有约 40dp
例如蓝牙页顶部新增按钮使用固定 40×40低于常见的 48dp 触控建议;对手部不便或视力较弱的健康类用户不够友好。
### 27. 大字体、横屏、小屏和读屏尚无验证证据
许多页面使用固定高度、固定行布局和多个并排信息块。当前只在一台 1440×3200 Android 真机、默认字体比例下检查,不能据此确认大字体和 iOS 横屏不会溢出。
## 五、合规与发布准备问题
### 28. 合规文档仍有主体和联系方式占位符
隐私政策、服务协议、个人信息清单和 SDK 清单里仍有 `xxx`、待定短信服务商等内容,不能用于正式审核。
### 29. App 内文档与 H5 文档存在多套日期和内容
App 内隐私/协议日期为 6 月 29 日,个人信息与 SDK 清单为 7 月 9 日H5 清单日期为 7 月 7 日。以后修改一处很容易漏另一处。建议确定单一内容源或至少增加一致性测试。
### 30. “关于我们”写了并不存在的意见反馈入口
文档提示“设置 → 意见反馈”,实际设置页没有该入口;版本还写成 `Build 20260101`,与当前 `1.0.0+1` 不一致。
### 31. 用户同意协议没有服务端记录
当前“同意”只是登录页内存中的一个布尔值,未保存同意时间、协议版本或政策更新后的重新同意记录。服务端接口也不要求同意状态,无法证明用户同意了哪一版条款。
### 32. Android 权限声明偏多且缺少版本限制
当前声明了蓝牙广播权限,但 App 主要是扫描/连接;旧版存储和蓝牙权限也没有按 Android 版本加 `maxSdkVersion`。正式个人信息清单应与实际最小权限保持一致。
### 33. 本地 token 使用普通 SQLite 明文保存
符合项目“不用 shared_preferences”的约定但 access token 和 refresh token 没有系统级加密保护;同时 Android 也未显式关闭备份。正式版本需要决定是否改用系统安全存储或加密数据库,并限制备份。
### 34. iOS 名称和方向仍是模板状态
Info.plist 的显示名称还是 `Health App`,并允许 iPhone 横屏当前页面主要按竖屏设计尚未验证横屏。Bundle ID、签名和证书则仍按项目计划待配置。
### 35. CORS 当前允许任意来源并携带凭据
本地调试方便,但正式环境应只允许确定的 H5/管理端域名,不能继续接受任意 Origin。
## 六、已确认正常或已有保护的部分
- 当前 Flutter `analyze` 无错误。
- Flutter 47 项测试全部通过。
- 后端 44 项测试全部通过。
- 报告、用药、饮食、运动、健康数据等主要删除接口都按当前用户筛选,不是只删前端显示。
- 单独删除报告时会同时删除报告原文件。
- AI 历史对话恢复后仍保留原 conversationId可以继续发送新消息历史确认卡只读是合理保护。
- 管理员和医生普通 HTTP 端点已有角色过滤,问题主要集中在固定管理员验证码、医生数据同步和未鉴权 SignalR Hub。
- 设置、通知、报告列表已经较接近“一行一行、文字区分隔线”的目标风格。
- 隐私政策、服务协议、个人信息清单、SDK 清单和关于我们均已有 App 内入口与 H5 文件。
## 七、真机流程健康度
1. 首页与 AI 对话可进入视觉基本完整但纯图标无障碍名称不足AI SSE 鉴权有严重后端风险。
2. 侧边栏:可打开;功能入口齐全,仪表盘色彩和层级偏重。
3. 健康档案:可加载和编辑;固定保存区需要继续验证大字体遮挡。
4. 报告管理:列表正常;文件公开访问是严重隐私风险。
5. 用药管理:数据能加载;列表红底显示错误明显。
6. 饮食记录:日期、空状态和趋势能显示;空状态样式与其他页不统一。
7. 健康日历:日期和计划能显示;业务时区需要后端统一。
8. 复查随访:空状态清楚;本次账号没有数据,未实测详情。
9. 运动计划:数据能加载;红底和操作文字重叠明显。
10. 蓝牙设备:绑定设备能显示;自动读取后的批量上传链路断开。
11. 通知中心:历史和未读数能显示;未读通知在网络失败时可能打不开目的页。
12. 设置与合规入口:入口齐全;危险操作权重过高,文档仍有占位和不一致。
13. 个人信息:真机运行版本可进入;检查期间源代码刚加入新的基本资料编辑入口,当前运行包尚未热更新到这一版。
14. 医生端:已完成代码与接口核对;没有医生测试账号,本次未做完整真机流程。
15. 管理员端:已完成代码与接口核对;没有实际执行新增/停用/删除,避免改变真实数据。
16. iOS仅做配置与代码检查没有证书、Mac 构建和 iPhone 实测,不能确认界面与权限流程。
## 八、证据限制
- 真机截图来自本次检查时设备上正在运行的调试包。检查过程中项目源代码仍在被更新,后加入的个人资料编辑和设备错误文案没有重新构建到该运行包,因此相关结论以当前代码为准、旧界面截图只用于对应未变化页面。
- 没有执行真实删除、账号注销、医生管理、短信发送或蓝牙测量,避免改变现有数据;这些部分通过前后端代码和接口对应关系确认。
- 没有正式域名、HTTPS、短信服务商、对象存储、iOS 签名和商店账号,因此只检查了当前准备缺口。

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -9,13 +9,21 @@ JWT_AUDIENCE=health-manager-app
# DeepSeek LLM # DeepSeek LLM
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
# 千问 VLM # 千问 VLM
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
# 阿里云百炼实时语音输入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

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

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

View File

@@ -0,0 +1,67 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.AI;
public sealed record AiConversationDto(
Guid Id,
string AgentType,
string? Title,
string? Summary,
int MessageCount,
DateTime CreatedAt,
DateTime UpdatedAt);
public sealed record AiConversationMessageDto(
Guid Id,
string Role,
string Content,
string? Intent,
string? MetadataJson,
DateTime CreatedAt);
public sealed record OpenConversationResult(
bool Found,
bool Created,
Guid ConversationId);
public interface IAiConversationService
{
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
}
public interface IAiConversationRepository
{
Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct);
Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct);
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct);
void Delete(Conversation conversation);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IPatientContextService
{
Task<string> BuildAsync(Guid userId, CancellationToken ct);
}
public sealed record AiConfirmedWriteResult(int Code, object? Data, string? Message);
public interface IAiToolExecutionService
{
Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct);
Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct);
}

View File

@@ -0,0 +1,157 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.AI;
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
{
private const int HistoryConversationLimit = 30;
private readonly IAiConversationRepository _conversations = conversations;
public async Task<OpenConversationResult> OpenAsync(
Guid userId,
Guid? conversationId,
AgentType agentType,
string firstMessage,
CancellationToken ct)
{
if (conversationId.HasValue)
{
var existing = await _conversations.GetOwnedAsync(userId, conversationId.Value, ct);
return existing == null
? new OpenConversationResult(false, false, conversationId.Value)
: new OpenConversationResult(true, false, existing.Id);
}
var conversation = new Conversation
{
Id = Guid.NewGuid(),
UserId = userId,
AgentType = agentType,
Title = firstMessage.Length > 30 ? firstMessage[..30] : firstMessage,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _conversations.AddConversationAsync(conversation, ct);
await _conversations.SaveChangesAsync(ct);
await PruneHistoryAsync(userId, ct);
return new OpenConversationResult(true, true, conversation.Id);
}
public async Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, metadataJson, ct);
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson: null, ct);
public async Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson, ct);
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
{
var messages = await _conversations.GetRecentMessagesAsync(conversationId, limit, ct);
return messages.OrderBy(m => m.CreatedAt).Select(ToMessageDto).ToList();
}
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
{
await _conversations.DeleteLegacyDietCommentaryArtifactsAsync(
userId,
DietCommentaryPolicy.LegacyPromptPrefix,
ct);
var conversations = await _conversations.ListAsync(userId, ct);
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
}
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
if (conversation == null) return [];
var messages = await _conversations.GetMessagesAsync(conversationId, ct);
return messages.Select(ToMessageDto).ToList();
}
public async Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct)
{
var conversation = await _conversations.GetAsync(conversationId, ct)
?? throw new InvalidOperationException("会话不存在");
var normalized = summary.Trim();
if (string.IsNullOrWhiteSpace(normalized)) return;
conversation.Summary = normalized.Length > 24 ? normalized[..24] : normalized;
await _conversations.SaveChangesAsync(ct);
}
public async Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
if (conversation == null) return false;
_conversations.Delete(conversation);
await _conversations.SaveChangesAsync(ct);
return true;
}
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
{
var list = await _conversations.ListAsync(userId, ct);
foreach (var conv in list)
{
_conversations.Delete(conv);
}
if (list.Count > 0)
await _conversations.SaveChangesAsync(ct);
return list.Count;
}
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, string? metadataJson, CancellationToken ct)
{
var conversation = await _conversations.GetAsync(conversationId, ct)
?? throw new InvalidOperationException("会话不存在");
await _conversations.AddMessageAsync(new ConversationMessage
{
Id = Guid.NewGuid(),
ConversationId = conversationId,
Role = role,
Content = content,
MetadataJson = metadataJson,
CreatedAt = DateTime.UtcNow,
}, ct);
conversation.MessageCount++;
conversation.UpdatedAt = DateTime.UtcNow;
if (updateSummary)
conversation.Summary = content.Length > 100 ? content[..100] : content;
await _conversations.SaveChangesAsync(ct);
await PruneHistoryAsync(conversation.UserId, ct);
}
private async Task PruneHistoryAsync(Guid userId, CancellationToken ct)
{
var list = await _conversations.ListAsync(userId, ct);
var expired = list.Skip(HistoryConversationLimit).ToList();
if (expired.Count == 0) return;
foreach (var conversation in expired)
_conversations.Delete(conversation);
await _conversations.SaveChangesAsync(ct);
}
private static AiConversationDto ToDto(Conversation conversation) => new(
conversation.Id,
conversation.AgentType.ToString(),
conversation.Title,
conversation.Summary,
conversation.MessageCount,
conversation.CreatedAt,
conversation.UpdatedAt);
private static AiConversationMessageDto ToMessageDto(ConversationMessage message) => new(
message.Id,
message.Role.ToString(),
message.Content,
message.Intent,
message.MetadataJson,
message.CreatedAt);
}

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

@@ -0,0 +1,15 @@
namespace Health.Application.AI;
public sealed record PendingAiWriteCommand(
Guid Id,
Guid UserId,
string ToolName,
string Arguments,
DateTime ExpiresAt);
public interface IAiWriteConfirmationStore
{
Task<PendingAiWriteCommand> CreateAsync(Guid userId, string toolName, string arguments, TimeSpan lifetime, CancellationToken ct);
Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct);
Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct);
}

View File

@@ -0,0 +1,20 @@
namespace Health.Application.AI;
/// <summary>
/// 用户消息附带的图片/PDF 解析结果。
/// 既用作 LLM 上下文拼装,也持久化到 ConversationMessage.MetadataJson。
/// </summary>
public sealed record AttachmentContext(
string Kind, // "image" 或 "pdf"
string? Category, // 仅图片food / report / wound / drug / chart / other
string? FileName, // PDF 文件原名
string Summary, // 简短摘要,用于持久化和历史回顾
string LlmContent); // 完整文本,拼到当前轮 LLM user content 前
public interface IAttachmentContextBuilder
{
/// <summary>
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
/// </summary>
Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct);
}

View File

@@ -0,0 +1,35 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.AI;
public sealed record DietCommentaryFood(string Name, string Portion, int Calories);
public static class DietCommentaryPolicy
{
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
public const string SystemPrompt = """
App 23
1222使 Markdown
""";
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>
string.Join("、", foods.Select(food =>
$"{Normalize(food.Name, 40)}{Normalize(food.Portion, 30)}{Math.Clamp(food.Calories, 0, 10000)}千卡)"));
public static bool IsLegacyArtifact(IEnumerable<ConversationMessage> messages)
{
var userMessages = messages.Where(message => message.Role == MessageRole.User).ToList();
return userMessages.Count > 0 &&
userMessages.All(message => message.Content.TrimStart().StartsWith(LegacyPromptPrefix, StringComparison.Ordinal));
}
private static string Normalize(string value, int maxLength)
{
var normalized = string.Join(' ', value.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
return normalized.Length > maxLength ? normalized[..maxLength] : normalized;
}
}

View File

@@ -0,0 +1,65 @@
using System.Text;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
namespace Health.Application.AI;
public sealed class PatientContextService(
IHealthArchiveService archives,
IHealthRecordService healthRecords) : IPatientContextService
{
private readonly IHealthArchiveService _archives = archives;
private readonly IHealthRecordService _healthRecords = healthRecords;
public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
{
var archive = await _archives.GetAsync(userId, ct);
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
var sb = new StringBuilder();
if (archive != null)
{
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
if (archive.SurgeryHistoryStatus == "无")
sb.AppendLine("手术史: 无");
else if (archive.Surgeries.Count > 0)
sb.AppendLine($"手术: {string.Join("", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
else if (!string.IsNullOrEmpty(archive.SurgeryType))
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
if (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}");
if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}");
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
}
if (recentRecords.Count > 0)
{
sb.AppendLine("近期健康数据:");
foreach (var record in recentRecords)
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({ToBeijing(record.RecordedAt):MM-dd HH:mm} 北京时间)");
}
return sb.ToString();
}
private static string RecordValue(HealthRecordDto record) => record.Type switch
{
"BloodPressure" => $"{record.Systolic}/{record.Diastolic}",
"HeartRate" => $"{record.Value}次/分",
"Glucose" => $"{record.Value}",
"SpO2" => $"{record.Value}%",
"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

@@ -0,0 +1,15 @@
namespace Health.Application.Admin;
public sealed record AdminResult(int Code, object? Data, string? Message = null);
public sealed record AddDoctorCommand(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection);
public sealed record UpdateDoctorCommand(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection);
public interface IAdminService
{
Task<AdminResult> ListDoctorsAsync(CancellationToken ct);
Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct);
Task<AdminResult> UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct);
Task<AdminResult> ToggleDoctorAsync(Guid id, CancellationToken ct);
Task<AdminResult> DeleteDoctorAsync(Guid id, CancellationToken ct);
Task<AdminResult> ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct);
}

View File

@@ -0,0 +1,15 @@
namespace Health.Application.Auth;
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 AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name);
public interface IAuthService
{
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
Task<AuthResult> RegisterAsync(RegisterCommand command, 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 LogoutAsync(string refreshToken, CancellationToken ct);
}

View File

@@ -0,0 +1,19 @@
using Health.Domain.Entities;
namespace Health.Application.Calendars;
public sealed record CalendarDataSnapshot(
IReadOnlyList<Medication> Medications,
IReadOnlyList<ExercisePlan> ExercisePlans,
IReadOnlyList<FollowUp> FollowUps);
public interface ICalendarService
{
Task<IReadOnlyList<object>> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct);
Task<object> GetDayAsync(Guid userId, DateOnly date, CancellationToken ct);
}
public interface ICalendarRepository
{
Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct);
}

View File

@@ -0,0 +1,101 @@
namespace Health.Application.Calendars;
public sealed class CalendarService(ICalendarRepository calendar) : ICalendarService
{
private readonly ICalendarRepository _calendar = calendar;
public async Task<IReadOnlyList<object>> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct)
{
var start = new DateOnly(year, month, 1);
var end = start.AddMonths(1);
var snapshot = await _calendar.GetSnapshotAsync(userId, start, end, ct);
var result = new List<object>();
for (var date = start; date < end; date = date.AddDays(1))
{
var entries = BuildEntries(snapshot, date);
if (entries.Count == 0) continue;
result.Add(new
{
date = date.ToString("yyyy-MM-dd"),
events = entries.Select(entry => entry.Type).Distinct().ToList(),
details = entries.Select(entry => entry.Value).ToList()
});
}
return result;
}
public async Task<object> GetDayAsync(Guid userId, DateOnly date, CancellationToken ct)
{
var snapshot = await _calendar.GetSnapshotAsync(userId, date, date.AddDays(1), ct);
var medications = snapshot.Medications
.Where(m => IsMedicationActiveOn(m, date))
.Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() })
.ToList();
var exercises = snapshot.ExercisePlans
.SelectMany(p => p.Items)
.Where(i => i.ScheduledDate == date && !i.IsRestDay)
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
.ToList();
var followUps = snapshot.FollowUps
.Where(f => BeijingDate(f.ScheduledAt) == date)
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
.ToList();
return new { medications, exercises, followUps };
}
private static List<CalendarEntry> BuildEntries(CalendarDataSnapshot snapshot, DateOnly date)
{
var entries = new List<CalendarEntry>();
foreach (var medication in snapshot.Medications.Where(m => IsMedicationActiveOn(m, date)))
{
entries.Add(new CalendarEntry("medication", new
{
type = "medication",
name = medication.Name,
dosage = medication.Dosage,
timeOfDay = medication.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList()
}));
}
foreach (var plan in snapshot.ExercisePlans)
{
foreach (var item in plan.Items.Where(i => i.ScheduledDate == date && !i.IsRestDay))
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 => BeijingDate(f.ScheduledAt) == date))
{
entries.Add(new CalendarEntry("followup", new
{
type = "followup",
title = followUp.Title,
doctorName = followUp.DoctorName,
department = followUp.Department,
status = followUp.Status.ToString()
}));
}
return entries;
}
private static bool IsMedicationActiveOn(Health.Domain.Entities.Medication medication, DateOnly date) =>
medication.IsActive
&& medication.TimeOfDay.Count > 0
&& (medication.StartDate == null || medication.StartDate <= 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);
}

View File

@@ -0,0 +1,51 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Diets;
public sealed record DietFoodItemInput(
string Name,
string? Portion,
int? Calories,
int SortOrder);
public sealed record DietRecordCreateRequest(
MealType MealType,
int? TotalCalories,
int? HealthScore,
DateOnly RecordedAt,
IReadOnlyList<DietFoodItemInput> FoodItems);
public sealed record DietRecordPatchRequest(
int? TotalCalories,
int? HealthScore);
public sealed record DietFoodItemDto(
string Name,
string? Portion,
int? Calories);
public sealed record DietRecordDto(
Guid Id,
string MealType,
int? TotalCalories,
int? HealthScore,
DateOnly RecordedAt,
IReadOnlyList<DietFoodItemDto> FoodItems);
public interface IDietService
{
Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct);
}
public interface IDietRepository
{
Task<IReadOnlyList<DietRecord>> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct);
Task<DietRecord?> GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct);
Task AddAsync(DietRecord record, CancellationToken ct);
void Delete(DietRecord record);
Task SaveChangesAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,27 @@
namespace Health.Application.Diets;
public sealed record DietImageUploadFile(string FileName, long Length, Stream Content);
public sealed record DietImageAnalysisJob(Guid Id, IReadOnlyList<string> FilePaths);
public sealed record DietImageAnalysisResult(bool Success, int Code, string? Data, string? Message);
public interface IDietImageAnalysisCoordinator
{
Task<DietImageAnalysisResult> AnalyzeAsync(IReadOnlyList<DietImageUploadFile> files, CancellationToken ct);
}
public interface IDietImageAnalysisQueue
{
Task<Guid> EnqueueAsync(IReadOnlyList<string> filePaths, CancellationToken ct);
Task RecoverStaleAsync(CancellationToken ct);
Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct);
Task<DietImageAnalysisResult> WaitForResultAsync(Guid jobId, CancellationToken ct);
Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct);
Task<bool> RetryAsync(Guid jobId, string error, CancellationToken ct);
}
public interface IDietImageAnalyzer
{
Task<DietImageAnalysisResult> AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct);
}

View File

@@ -0,0 +1,103 @@
using Health.Domain;
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Diets;
public sealed class DietService(IDietRepository diets) : IDietService
{
private const int MaxCalories = 20000; // 单条记录/单项热量上限kcal
private readonly IDietRepository _diets = diets;
public async Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct)
{
var recordedAt = DateOnly.TryParse(date, out var d) ? d : (DateOnly?)null;
var parsedMealType = Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt) ? mt : (MealType?)null;
var records = await _diets.ListAsync(userId, recordedAt, parsedMealType, ct);
return records.Select(ToDto).ToList();
}
public async Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct)
{
ValidateCalories(request.TotalCalories, "总热量");
ValidateScore(request.HealthScore);
foreach (var item in request.FoodItems)
{
if (string.IsNullOrWhiteSpace(item.Name))
throw new ValidationException("食物名称不能为空");
ValidateCalories(item.Calories, $"食物「{item.Name.Trim()}」热量");
}
var record = new DietRecord
{
Id = Guid.NewGuid(),
UserId = userId,
MealType = request.MealType,
TotalCalories = request.TotalCalories,
HealthScore = request.HealthScore,
RecordedAt = request.RecordedAt,
CreatedAt = DateTime.UtcNow,
};
foreach (var item in request.FoodItems)
{
record.FoodItems.Add(new DietFoodItem
{
Id = Guid.NewGuid(),
Name = item.Name.Trim(),
Portion = item.Portion,
Calories = item.Calories,
SortOrder = item.SortOrder,
});
}
await _diets.AddAsync(record, ct);
await _diets.SaveChangesAsync(ct);
return record.Id;
}
public async Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct)
{
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
if (record == null) return false;
_diets.Delete(record);
await _diets.SaveChangesAsync(ct);
return true;
}
public async Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct)
{
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
if (record == null) return false;
ValidateCalories(request.TotalCalories, "总热量");
ValidateScore(request.HealthScore);
if (request.TotalCalories.HasValue) record.TotalCalories = request.TotalCalories.Value;
if (request.HealthScore.HasValue) record.HealthScore = request.HealthScore.Value;
await _diets.SaveChangesAsync(ct);
return true;
}
private static void ValidateCalories(int? calories, string field)
{
if (calories.HasValue && (calories.Value < 0 || calories.Value > MaxCalories))
throw new ValidationException($"{field}应在 0~{MaxCalories} kcal 之间,当前值 {calories.Value} 不合理");
}
private static void ValidateScore(int? score)
{
if (score.HasValue && (score.Value < 0 || score.Value > 100))
throw new ValidationException($"健康评分应在 0~100 之间,当前值 {score.Value} 不合理");
}
private static DietRecordDto ToDto(DietRecord record) => new(
record.Id,
record.MealType.ToString(),
record.TotalCalories,
record.HealthScore,
record.RecordedAt,
record.FoodItems.OrderBy(f => f.SortOrder).Select(f => new DietFoodItemDto(f.Name, f.Portion, f.Calories)).ToList());
}

View File

@@ -0,0 +1,67 @@
using Health.Domain.Entities;
namespace Health.Application.Exercises;
public sealed record ExercisePlanCreateRequest(
DateOnly StartDate,
DateOnly EndDate,
string? ExerciseType,
int DurationMinutes,
TimeOnly? ReminderTime = null);
public sealed record ExercisePlanItemDto(
Guid Id,
DateOnly ScheduledDate,
string ExerciseType,
int DurationMinutes,
bool IsCompleted,
DateTime? CompletedAt,
bool IsRestDay);
public sealed record ExercisePlanDto(
Guid Id,
DateOnly StartDate,
DateOnly EndDate,
TimeOnly ReminderTime,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<ExercisePlanItemDto> Items);
public sealed record ExercisePlanSummaryDto(
Guid Id,
DateOnly StartDate,
DateOnly EndDate,
TimeOnly ReminderTime,
DateTime CreatedAt,
int TotalDays,
int CompletedDays,
IReadOnlyList<ExercisePlanItemDto> Items);
public interface IExerciseService
{
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
Task<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<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
}
public interface IExerciseRepository
{
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, 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<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
Task AddAsync(ExercisePlan plan, CancellationToken ct);
void Delete(ExercisePlan plan);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IExerciseReminderProducer
{
Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct);
}

View File

@@ -0,0 +1,143 @@
using Health.Domain;
using Health.Domain.Entities;
namespace Health.Application.Exercises;
public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService
{
private const int MaxDurationMinutes = 1440; // 单次运动时长上限 24 小时
private static readonly TimeOnly DefaultReminderTime = new(19, 0);
private readonly IExerciseRepository _exercises = exercises;
public async Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct)
{
var today = BeijingToday();
var plans = await _exercises.ListActiveOnAsync(userId, today, ct);
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.ScheduledDate == today));
return plan == null ? null : ToDto(plan);
}
public async Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct)
{
ValidateDuration(request.DurationMinutes);
var startDate = request.StartDate;
var endDate = request.EndDate < startDate ? startDate : request.EndDate;
if (endDate.DayNumber - startDate.DayNumber > 365)
endDate = startDate.AddDays(365);
var exerciseType = string.IsNullOrWhiteSpace(request.ExerciseType) ? "运动" : request.ExerciseType.Trim();
var duration = request.DurationMinutes > 0 ? request.DurationMinutes : 30;
var plan = NewPlan(userId, startDate, endDate, request.ReminderTime);
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(),
ScheduledDate = date,
ExerciseType = exerciseType,
DurationMinutes = duration,
IsRestDay = false,
});
}
await SaveNewAsync(plan, ct);
return plan.Id;
}
// 时长上限校验:下限交由各方法兜底为 30这里只拦截不合理的超大值
private static void ValidateDuration(int durationMinutes)
{
if (durationMinutes > MaxDurationMinutes)
throw new ValidationException($"单次运动时长不能超过 {MaxDurationMinutes} 分钟24 小时),当前值 {durationMinutes} 不合理");
}
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
{
var plans = await _exercises.ListAsync(userId, 20, ct);
return plans.Select(ToSummaryDto).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)
{
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
return plan == null ? null : ToDto(plan);
}
public async Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct)
{
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
if (plan == null) return false;
_exercises.Delete(plan);
await _exercises.SaveChangesAsync(ct);
return true;
}
public async Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
{
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return null;
if (item.ScheduledDate != BeijingToday())
throw new ValidationException("只能打卡今天的运动任务");
if (item.IsRestDay)
throw new ValidationException("休息日无需打卡");
item.IsCompleted = !item.IsCompleted;
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
item.Plan.UpdatedAt = DateTime.UtcNow;
await _exercises.SaveChangesAsync(ct);
return item.IsCompleted;
}
public async Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
{
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return false;
if (item.ScheduledDate != BeijingToday())
throw new ValidationException("只能打卡今天的运动任务");
if (item.IsRestDay)
throw new ValidationException("休息日无需打卡");
item.IsCompleted = true;
item.CompletedAt = DateTime.UtcNow;
item.Plan.UpdatedAt = DateTime.UtcNow;
await _exercises.SaveChangesAsync(ct);
return true;
}
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
{
await _exercises.AddAsync(plan, ct);
await _exercises.SaveChangesAsync(ct);
}
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
{
Id = Guid.NewGuid(),
UserId = userId,
StartDate = startDate,
EndDate = endDate,
ReminderTime = reminderTime ?? DefaultReminderTime,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
private static 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(
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
item.IsCompleted, item.CompletedAt, item.IsRestDay);
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
}

View File

@@ -0,0 +1,51 @@
using Health.Domain.Entities;
namespace Health.Application.HealthArchives;
public sealed record HealthArchiveSurgeryDto(
Guid Id,
string Type,
DateOnly? Date);
public sealed record HealthArchiveSurgeryInput(
string Type,
DateOnly? Date);
public sealed record HealthArchiveDto(
Guid Id,
Guid UserId,
string? Diagnosis,
string? SurgeryType,
DateOnly? SurgeryDate,
IReadOnlyList<HealthArchiveSurgeryDto> Surgeries,
IReadOnlyList<string> Allergies,
IReadOnlyList<string> DietRestrictions,
IReadOnlyList<string> ChronicDiseases,
string? FamilyHistory,
string? SurgeryHistoryStatus,
DateTime UpdatedAt);
public sealed record HealthArchiveUpdateRequest(
string? Diagnosis,
string? SurgeryType,
DateOnly? SurgeryDate,
IReadOnlyList<string>? Allergies,
IReadOnlyList<string>? DietRestrictions,
IReadOnlyList<string>? ChronicDiseases,
string? FamilyHistory,
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null,
string? SurgeryHistoryStatus = null);
public interface IHealthArchiveService
{
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct);
}
public interface IHealthArchiveRepository
{
Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct);
Task AddAsync(HealthArchive archive, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,184 @@
using Health.Domain.Entities;
using System.ComponentModel.DataAnnotations;
namespace Health.Application.HealthArchives;
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
{
private readonly IHealthArchiveRepository _archives = archives;
public async Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct)
{
var archive = await _archives.GetByUserIdAsync(userId, ct);
return archive == null ? null : ToDto(archive);
}
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
{
Validate(request);
var archive = await GetOrCreateEntityAsync(userId, ct);
Apply(archive, request);
archive.UpdatedAt = DateTime.UtcNow;
await _archives.SaveChangesAsync(ct);
return ToDto(archive);
}
public async Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct)
{
if (action == "query")
{
var archive = await GetAsync(userId, ct);
return archive == null ? new { found = false } : ToAiResult(archive);
}
var current = await GetOrCreateEntityAsync(userId, ct);
var scopedRequest = action switch
{
"update_diagnosis" => request with
{
SurgeryType = null, SurgeryDate = null, Allergies = null,
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null,
Surgeries = null
},
"update_surgery" => request with
{
Diagnosis = null, Allergies = null, DietRestrictions = null,
ChronicDiseases = null, FamilyHistory = null
},
"update_allergies" => new HealthArchiveUpdateRequest(null, null, null, request.Allergies, null, null, null),
"update_chronic_diseases" => new HealthArchiveUpdateRequest(null, null, null, null, null, request.ChronicDiseases, null),
"update_diet_restrictions" => new HealthArchiveUpdateRequest(null, null, null, null, request.DietRestrictions, null, null),
_ => null
};
if (scopedRequest == null)
return new { success = false, message = $"未知档案操作: {action}" };
Apply(current, scopedRequest);
current.UpdatedAt = DateTime.UtcNow;
await _archives.SaveChangesAsync(ct);
return new { success = true };
}
private async Task<HealthArchive> GetOrCreateEntityAsync(Guid userId, CancellationToken ct)
{
var archive = await _archives.GetByUserIdAsync(userId, ct);
if (archive != null) return archive;
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId, UpdatedAt = DateTime.UtcNow };
await _archives.AddAsync(archive, ct);
return archive;
}
private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request)
{
if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis;
if (request.Surgeries != null)
{
archive.Surgeries.Clear();
var items = request.Surgeries
.Where(s => !string.IsNullOrWhiteSpace(s.Type))
.Select((s, index) => new HealthArchiveSurgery
{
Id = Guid.NewGuid(),
HealthArchiveId = archive.Id,
Type = s.Type.Trim(),
Date = s.Date,
SortOrder = index,
})
.ToList();
foreach (var item in items) archive.Surgeries.Add(item);
SyncLegacySurgeryFields(archive);
}
else if (request.SurgeryType != null)
{
SeedLegacySurgeryIfNeeded(archive);
var type = request.SurgeryType.Trim();
if (type.Length > 0 && !archive.Surgeries.Any(s =>
s.Type == type && s.Date == request.SurgeryDate))
{
archive.Surgeries.Add(new HealthArchiveSurgery
{
Id = Guid.NewGuid(),
HealthArchiveId = archive.Id,
Type = type,
Date = request.SurgeryDate,
SortOrder = archive.Surgeries.Count,
});
}
SyncLegacySurgeryFields(archive);
}
if (request.Allergies != null) archive.Allergies = request.Allergies.ToList();
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
if (request.SurgeryHistoryStatus != null)
archive.SurgeryHistoryStatus = request.SurgeryHistoryStatus;
}
private static void Validate(HealthArchiveUpdateRequest request)
{
if (request.SurgeryHistoryStatus is not null and not ("无" or "有"))
throw new ValidationException("手术史状态格式不正确");
}
private static object ToAiResult(HealthArchiveDto archive) => new
{
found = true,
archive.Diagnosis,
archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
surgeries = archive.Surgeries.Select(s => new { s.Type, date = s.Date?.ToString("yyyy-MM-dd") }),
archive.Allergies,
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
archive.SurgeryHistoryStatus,
};
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
archive.Id,
archive.UserId,
archive.Diagnosis,
archive.SurgeryType,
archive.SurgeryDate,
GetSurgeries(archive),
archive.Allergies,
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
archive.SurgeryHistoryStatus,
archive.UpdatedAt);
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
{
var surgeries = archive.Surgeries
.OrderBy(s => s.SortOrder)
.Select(s => new HealthArchiveSurgeryDto(s.Id, s.Type, s.Date))
.ToList();
if (surgeries.Count == 0 && !string.IsNullOrWhiteSpace(archive.SurgeryType))
surgeries.Add(new HealthArchiveSurgeryDto(Guid.Empty, archive.SurgeryType, archive.SurgeryDate));
return surgeries;
}
private static void SyncLegacySurgeryFields(HealthArchive archive)
{
var first = archive.Surgeries.OrderBy(s => s.SortOrder).FirstOrDefault();
archive.SurgeryType = first?.Type;
archive.SurgeryDate = first?.Date;
}
private static void SeedLegacySurgeryIfNeeded(HealthArchive archive)
{
if (archive.Surgeries.Count > 0 || string.IsNullOrWhiteSpace(archive.SurgeryType)) return;
archive.Surgeries.Add(new HealthArchiveSurgery
{
Id = Guid.NewGuid(),
HealthArchiveId = archive.Id,
Type = archive.SurgeryType,
Date = archive.SurgeryDate,
SortOrder = 0,
});
}
}

View File

@@ -0,0 +1,46 @@
using Health.Domain.Enums;
using Health.Domain.Entities;
namespace Health.Application.HealthRecords;
public sealed record HealthRecordUpsertRequest(
HealthMetricType Type,
int? Systolic,
int? Diastolic,
decimal? Value,
string? Unit,
HealthRecordSource Source,
DateTime? RecordedAt);
public sealed record HealthRecordDto(
Guid Id,
string Type,
int? Systolic,
int? Diastolic,
decimal? Value,
string? Unit,
string Source,
bool IsAbnormal,
DateTime RecordedAt);
public interface IHealthRecordService
{
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
Task<IReadOnlyList<Guid>> CreateManyAsync(Guid userId, IReadOnlyList<HealthRecordUpsertRequest> requests, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct);
}
public interface IHealthRecordRepository
{
Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct);
Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct);
Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct);
Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct);
Task AddAsync(HealthRecord record, CancellationToken ct);
void Delete(HealthRecord record);
Task SaveChangesAsync(CancellationToken ct);
}

View File

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

View File

@@ -0,0 +1,205 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Application.Notifications;
using System.Security.Cryptography;
using System.Text;
namespace Health.Application.HealthRecords;
public sealed class HealthRecordService(
IHealthRecordRepository records,
IUserNotificationProducer? notifications = null) : IHealthRecordService
{
private readonly IHealthRecordRepository _records = records;
private readonly IUserNotificationProducer? _notifications = notifications;
public async Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct)
{
HealthMetricType? metricType = null;
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var parsed))
metricType = parsed;
var recordedAfter = days.HasValue ? DateTime.UtcNow.AddDays(-days.Value) : (DateTime?)null;
var result = await _records.ListAsync(userId, metricType, recordedAfter, 100, ct);
return result.Select(ToDto).ToList();
}
public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct)
{
HealthRecordRules.Validate(request);
var record = new HealthRecord
{
Id = Guid.NewGuid(),
UserId = userId,
MetricType = request.Type,
Systolic = request.Systolic,
Diastolic = request.Diastolic,
Value = request.Value,
Unit = request.Unit,
Source = request.Source,
RecordedAt = request.RecordedAt ?? DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
};
await _records.AddAsync(record, ct);
await _records.SaveChangesAsync(ct);
await EnqueueAbnormalNotificationAsync(record, ct);
return record.Id;
}
public async Task<IReadOnlyList<Guid>> CreateManyAsync(
Guid userId,
IReadOnlyList<HealthRecordUpsertRequest> requests,
CancellationToken ct)
{
if (requests.Count == 0)
throw new ArgumentException("至少需要一条健康记录", nameof(requests));
foreach (var request in requests)
HealthRecordRules.Validate(request);
var now = DateTime.UtcNow;
var records = requests.Select(request => new HealthRecord
{
Id = Guid.NewGuid(),
UserId = userId,
MetricType = request.Type,
Systolic = request.Systolic,
Diastolic = request.Diastolic,
Value = request.Value,
Unit = request.Unit,
Source = request.Source,
RecordedAt = request.RecordedAt ?? now,
CreatedAt = now,
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
}).ToList();
foreach (var record in records)
await _records.AddAsync(record, ct);
await _records.SaveChangesAsync(ct);
foreach (var record in records)
await EnqueueAbnormalNotificationAsync(record, ct);
return records.Select(record => record.Id).ToList();
}
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
{
var record = await _records.GetOwnedAsync(userId, id, ct);
if (record == null) return false;
HealthRecordRules.Validate(request);
record.MetricType = request.Type;
record.Systolic = request.Systolic;
record.Diastolic = request.Diastolic;
record.Value = request.Value;
record.Unit = request.Unit;
record.Source = request.Source;
record.RecordedAt = request.RecordedAt ?? record.RecordedAt;
record.IsAbnormal = HealthRecordRules.CheckAbnormal(request);
await _records.SaveChangesAsync(ct);
await EnqueueAbnormalNotificationAsync(record, ct);
return true;
}
public async Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct)
{
var record = await _records.GetOwnedAsync(userId, id, ct);
if (record == null) return false;
_records.Delete(record);
await _records.SaveChangesAsync(ct);
return true;
}
public async Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct)
{
var types = new[]
{
HealthMetricType.BloodPressure,
HealthMetricType.HeartRate,
HealthMetricType.Glucose,
HealthMetricType.SpO2,
HealthMetricType.Weight
};
var result = new Dictionary<string, object?>();
foreach (var type in types)
{
var latest = await _records.GetLatestByTypeAsync(userId, type, ct);
result[type.ToString()] = latest == null ? null : new
{
latest.Systolic,
latest.Diastolic,
latest.Value,
latest.Unit,
latest.IsAbnormal,
latest.RecordedAt
};
}
return result;
}
public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct)
{
var days = period switch { 7 => 7, 30 => 30, 90 => 90, 365 => 365, _ => 7 };
var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct);
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
}
public static HealthRecordDto ToDto(HealthRecord record) => new(
record.Id,
record.MetricType.ToString(),
record.Systolic,
record.Diastolic,
record.Value,
record.Unit,
record.Source.ToString(),
record.IsAbnormal,
record.RecordedAt);
private async Task EnqueueAbnormalNotificationAsync(HealthRecord record, CancellationToken ct)
{
if (!record.IsAbnormal || _notifications == null) return;
var (title, message, severity, target) = record.MetricType switch
{
HealthMetricType.BloodPressure when record.Systolic >= 140 || record.Diastolic >= 90 =>
("血压偏高提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg高于参考范围。建议休息后复测如伴明显不适请及时就医。", "warning", "blood_pressure"),
HealthMetricType.BloodPressure =>
("血压偏低提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg低于参考范围。请留意头晕、乏力等不适必要时及时就医。", "warning", "blood_pressure"),
HealthMetricType.HeartRate when record.Value > 100 =>
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
HealthMetricType.HeartRate =>
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
HealthMetricType.Glucose when record.Value > 6.1m =>
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
HealthMetricType.Glucose =>
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L低于参考范围。请及时关注身体状况。", "critical", "glucose"),
HealthMetricType.SpO2 =>
("血氧偏低提醒", $"本次血氧为 {record.Value:0.#}%,低于参考范围。建议立即复测;如持续偏低或伴呼吸不适,请及时就医。", "critical", "spo2"),
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
};
var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
await _notifications.EnqueueAsync(
record.UserId,
sourceId,
new NotificationMessage("HealthMetricAlert", title, message, severity, "health", target),
ct);
}
private static Guid DeterministicGuid(string value)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return new Guid(hash.AsSpan(0, 16));
}
}

View File

@@ -0,0 +1,16 @@
namespace Health.Application.Maintenance;
public sealed record MaintenanceCleanupResult(
int Conversations,
int VerificationCodes,
int BackgroundTasks);
public interface IMaintenanceService
{
Task<MaintenanceCleanupResult> CleanupAsync(DateTime utcNow, CancellationToken ct);
}
public interface IMaintenanceRepository
{
Task<MaintenanceCleanupResult> CleanupAsync(DateTime conversationCutoff, DateTime taskCutoff, DateTime utcNow, CancellationToken ct);
}

View File

@@ -0,0 +1,9 @@
namespace Health.Application.Maintenance;
public sealed class MaintenanceService(IMaintenanceRepository repository) : IMaintenanceService
{
private readonly IMaintenanceRepository _repository = repository;
public Task<MaintenanceCleanupResult> CleanupAsync(DateTime utcNow, CancellationToken ct) =>
_repository.CleanupAsync(utcNow.AddDays(-30), utcNow.AddDays(-30), utcNow, ct);
}

View File

@@ -0,0 +1,117 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Medications;
public sealed record MedicationUpsertRequest(
string Name,
string? Dosage,
MedicationFrequency Frequency,
IReadOnlyList<TimeOnly> TimeOfDay,
DateOnly? StartDate,
DateOnly? EndDate,
MedicationSource Source,
string? Notes);
public sealed record MedicationPatchRequest(
string? Name,
string? Dosage,
MedicationFrequency? Frequency,
IReadOnlyList<TimeOnly>? TimeOfDay,
DateOnly? StartDate,
DateOnly? EndDate,
string? Notes,
bool ClearEndDate = false);
public sealed record MedicationDto(
Guid Id,
string Name,
string? Dosage,
string Frequency,
IReadOnlyList<TimeOnly> TimeOfDay,
DateOnly? StartDate,
DateOnly? EndDate,
bool IsActive,
string? Notes,
string Source,
DateTime CreatedAt,
DateTime UpdatedAt,
bool TodayTaken);
public sealed record MedicationReminderDto(
Guid Id,
string Name,
string? Dosage,
string ScheduledTime,
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(
Guid TaskId,
Guid UserId,
Guid MedicationId,
string Name,
string? Dosage,
DateOnly ScheduledDate,
TimeOnly ScheduledTime);
public interface IMedicationService
{
Task<IReadOnlyList<MedicationDto>> ListAsync(Guid userId, string? filter, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid medicationId, 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> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(Guid userId, DateOnly date, CancellationToken ct);
}
public interface IMedicationRepository
{
Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, 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<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, 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<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
Task AddMedicationAsync(Medication medication, CancellationToken ct);
Task AddLogAsync(MedicationLog log, CancellationToken ct);
void DeleteMedication(Medication medication);
void DeleteLog(MedicationLog log);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IMedicationReminderScanner
{
Task<IReadOnlyList<MedicationReminderTask>> ScanAsync(DateTime utcNow, CancellationToken ct);
}
public interface IMedicationReminderQueue
{
Task<bool> EnqueueAsync(MedicationReminderTask task, CancellationToken ct);
Task RecoverStaleAsync(CancellationToken ct);
Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct);
Task CompleteAsync(Guid taskId, CancellationToken ct);
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
}
public interface IMedicationReminderDispatcher
{
Task DispatchAsync(MedicationReminderTask task, CancellationToken ct);
}

View File

@@ -0,0 +1,72 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Medications;
public sealed class MedicationReminderScanner(IMedicationRepository medications) : IMedicationReminderScanner
{
private readonly IMedicationRepository _medications = medications;
public async Task<IReadOnlyList<MedicationReminderTask>> ScanAsync(DateTime utcNow, CancellationToken ct)
{
var beijingNow = utcNow.AddHours(8);
var date = DateOnly.FromDateTime(beijingNow);
var time = TimeOnly.FromDateTime(beijingNow);
var windowStart = time.AddMinutes(-5);
var todayStartUtc = beijingNow.Date.AddHours(-8);
var todayEndUtc = todayStartUtc.AddDays(1);
var active = await _medications.ListActiveForReminderScanAsync(ct);
var tasks = new List<MedicationReminderTask>();
foreach (var medication in active)
{
if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue;
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time)))
{
if (await _medications.DoseLogExistsAsync(
medication.UserId,
medication.Id,
scheduledTime,
todayStartUtc,
todayEndUtc,
ct))
continue;
tasks.Add(new MedicationReminderTask(
Guid.NewGuid(),
medication.UserId,
medication.Id,
medication.Name,
medication.Dosage,
date,
scheduledTime));
}
}
return tasks;
}
private static bool IsActiveOn(Medication medication, DateOnly date) =>
(medication.StartDate == null || medication.StartDate <= date)
&& (medication.EndDate == null || medication.EndDate >= date);
private static bool IsInWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
end >= start ? value >= start && value <= end : value >= start || value <= end;
private static bool GetDosesForToday(Medication medication, DateOnly today)
{
if (medication.Frequency is MedicationFrequency.Daily
or MedicationFrequency.TwiceDaily
or MedicationFrequency.ThreeTimesDaily
or MedicationFrequency.AsNeeded)
return true;
var startDate = medication.StartDate ?? today;
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
if (medication.Frequency == MedicationFrequency.Weekly)
return today.DayOfWeek == startDate.DayOfWeek;
return true;
}
}

View File

@@ -0,0 +1,18 @@
using Health.Domain.Enums;
namespace Health.Application.Medications;
public static class MedicationScheduleStatus
{
public static string Resolve(
TimeOnly scheduledTime,
TimeOnly currentTime,
MedicationLogStatus? logStatus)
{
if (logStatus.HasValue)
return logStatus == MedicationLogStatus.Taken ? "taken" : "skipped";
if (scheduledTime <= currentTime.AddMinutes(-30)) return "overdue";
if (scheduledTime <= currentTime.AddHours(3)) return "upcoming";
return "scheduled";
}
}

View File

@@ -0,0 +1,281 @@
using Health.Domain;
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Medications;
public sealed class MedicationService(IMedicationRepository medications) : IMedicationService
{
private readonly IMedicationRepository _medications = medications;
public async Task<IReadOnlyList<MedicationDto>> ListAsync(Guid userId, string? filter, CancellationToken ct)
{
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var meds = await _medications.ListAsync(userId, filter, ct);
return meds.Select(m => ToDto(m, HasTakenToday(m, todayStartUtc, todayEndUtc))).ToList();
}
public async Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct)
{
var name = ValidateName(request.Name);
ValidateDateRange(request.StartDate, request.EndDate);
var times = NormalizeTimes(request.TimeOfDay);
if (times.Count == 0) times = [new TimeOnly(8, 0)];
var med = new Medication
{
Id = Guid.NewGuid(),
UserId = userId,
Name = name,
Dosage = request.Dosage,
Frequency = request.Frequency,
TimeOfDay = times,
StartDate = request.StartDate,
EndDate = request.EndDate,
Source = request.Source,
Notes = request.Notes,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _medications.AddMedicationAsync(med, ct);
await _medications.SaveChangesAsync(ct);
return med.Id;
}
public async Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct)
{
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
if (med == null) return false;
if (request.Name != null) med.Name = ValidateName(request.Name);
if (request.Dosage != null) med.Dosage = request.Dosage;
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
if (request.ClearEndDate) med.EndDate = null;
else if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
ValidateDateRange(med.StartDate, med.EndDate);
if (request.Notes != null) med.Notes = request.Notes;
med.UpdatedAt = DateTime.UtcNow;
await _medications.SaveChangesAsync(ct);
return true;
}
// ── 输入校验 ──
private static string ValidateName(string? name)
{
var trimmed = name?.Trim();
if (string.IsNullOrEmpty(trimmed))
throw new ValidationException("药品名称不能为空");
if (trimmed.Length > 200)
throw new ValidationException("药品名称过长(不超过 200 字)");
return trimmed;
}
private static void ValidateDateRange(DateOnly? startDate, DateOnly? endDate)
{
if (startDate.HasValue && endDate.HasValue && endDate.Value < startDate.Value)
throw new ValidationException("结束日期不能早于开始日期");
}
// 同一时间点去重并排序;空集合交由调用方/实体默认处理
private static List<TimeOnly> NormalizeTimes(IReadOnlyList<TimeOnly> times) =>
times.Distinct().OrderBy(t => t).ToList();
public async Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct)
{
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
if (med == null) return false;
_medications.DeleteMedication(med);
await _medications.SaveChangesAsync(ct);
return true;
}
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
{
var beijingNow = DateTime.UtcNow.AddHours(8);
var now = TimeOnly.FromDateTime(beijingNow);
var today = DateOnly.FromDateTime(beijingNow);
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var meds = await _medications.ListActiveForRemindersAsync(userId, today, ct);
var logs = await _medications.ListLogsInWindowAsync(userId, todayStartUtc, todayEndUtc, ct);
var reminders = new List<MedicationReminderDto>();
foreach (var med in meds)
{
if (!GetDosesForToday(med, today)) continue;
foreach (var scheduledTime in med.TimeOfDay)
{
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
var status = MedicationScheduleStatus.Resolve(
scheduledTime,
now,
log?.Status);
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
}
}
return reminders
.OrderBy(r => r.Status == "overdue" ? 0 : r.Status == "upcoming" ? 1 : 2)
.ToList();
}
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
{
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 existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
if (existing) return false;
await AddLogAsync(userId, medicationId, status, scheduledTime, ct);
return true;
}
public async Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct)
{
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var log = await _medications.GetDoseLogAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
if (log == null) return false;
_medications.DeleteLog(log);
await _medications.SaveChangesAsync(ct);
return true;
}
public async Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(
Guid userId,
DateOnly date,
CancellationToken ct)
{
var medications = await _medications.ListAsync(userId, null, ct);
var (startUtc, endUtc) = GetBeijingDateUtcWindow(date);
var logs = await _medications.ListLogsInWindowAsync(userId, startUtc, endUtc, ct);
var today = BeijingToday();
var now = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
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)
{
await _medications.AddLogAsync(new MedicationLog
{
Id = Guid.NewGuid(),
MedicationId = medicationId,
UserId = userId,
Status = status,
ScheduledTime = scheduledTime,
ConfirmedAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
}, ct);
await _medications.SaveChangesAsync(ct);
}
private static MedicationDto ToDto(Medication med, bool todayTaken) => new(
med.Id,
med.Name,
med.Dosage,
med.Frequency.ToString(),
med.TimeOfDay,
med.StartDate,
med.EndDate,
med.IsActive,
med.Notes,
med.Source.ToString(),
med.CreatedAt,
med.UpdatedAt,
todayTaken);
private static bool HasTakenToday(Medication med, DateTime todayStartUtc, DateTime todayEndUtc) =>
med.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken);
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
{
return GetBeijingDateUtcWindow(BeijingToday());
}
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)
{
if (med.Frequency is MedicationFrequency.Daily
or MedicationFrequency.TwiceDaily
or MedicationFrequency.ThreeTimesDaily
or MedicationFrequency.AsNeeded)
return true;
var startDate = med.StartDate ?? today;
if (med.Frequency == MedicationFrequency.EveryOtherDay)
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
if (med.Frequency == MedicationFrequency.Weekly)
return today.DayOfWeek == startDate.DayOfWeek;
return true;
}
}

View File

@@ -0,0 +1,67 @@
namespace Health.Application.Notifications;
public sealed record InAppNotificationDto(
Guid Id,
string Type,
string Title,
string Message,
string Severity,
string? ActionType,
string? ActionTargetId,
bool IsRead,
DateTime CreatedAt);
public sealed record NotificationMessage(
string Type,
string Title,
string Message,
string Severity,
string? ActionType,
string? ActionTargetId);
public interface IInAppNotificationService
{
Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<InAppNotificationDto>> GetHistoryAsync(Guid userId, CancellationToken ct);
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct);
}
public interface IInAppNotificationRepository
{
Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<InAppNotificationRecord>> GetHistoryAsync(Guid userId, CancellationToken ct);
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct);
}
public sealed record InAppNotificationRecord(
Guid Id,
string Type,
string Title,
string Message,
string Severity,
string? ActionType,
string? ActionTargetId,
bool IsRead,
DateTime CreatedAt);
public interface IUserNotificationProducer
{
Task<bool> EnqueueAsync(Guid userId, Guid sourceId, NotificationMessage message, CancellationToken ct);
}
public interface INotificationOutboxProcessor
{
Task RecoverStaleAsync(CancellationToken ct);
Task<bool> ProcessNextAsync(CancellationToken ct);
}
public sealed record ReminderCatchUpResult(int CreatedCount);
public interface IReminderCatchUpService
{
Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct);
}

View File

@@ -0,0 +1,31 @@
namespace Health.Application.Notifications;
public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService
{
private readonly IInAppNotificationRepository _notifications = notifications;
public async Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct)
{
var records = await _notifications.GetPendingAsync(userId, ct);
return records.Select(ToDto).ToList();
}
public async Task<IReadOnlyList<InAppNotificationDto>> GetHistoryAsync(Guid userId, CancellationToken ct)
{
var records = await _notifications.GetHistoryAsync(userId, ct);
return records.Select(ToDto).ToList();
}
public Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
_notifications.AcknowledgeAsync(userId, notificationId, ct);
public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
_notifications.MarkAllReadAsync(userId, ct);
public Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
_notifications.DeleteAsync(userId, notificationId, ct);
private static InAppNotificationDto ToDto(InAppNotificationRecord record) => new(
record.Id, record.Type, record.Title, record.Message, record.Severity,
record.ActionType, record.ActionTargetId, record.IsRead, record.CreatedAt);
}

View File

@@ -0,0 +1,83 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Reports;
public sealed record ReportUploadFile(
string FileName,
long Length,
Stream Content);
public sealed record ReportDto(
Guid Id,
Guid UserId,
string FileUrl,
string FileType,
string Category,
string Status,
string AiStatus,
string ReviewStatus,
string? Severity,
string? AiSummary,
string? AiIndicators,
string? DoctorComment,
string? DoctorRecommendation,
string? DoctorName,
DateTime? ReviewedAt,
DateTime CreatedAt);
public sealed record ReportUploadResult(
bool Success,
int Code,
string? Message,
ReportDto? Report);
public sealed record ReportAnalysisJob(
Guid TaskId,
Guid ReportId,
string FilePath);
public sealed record StoredReportFile(
string FileUrl,
string FilePath);
public interface IReportService
{
Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct);
Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct);
Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct);
Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct);
Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct);
}
public interface IReportAnalysisQueue
{
Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default);
Task RecoverStaleAsync(CancellationToken ct);
Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct);
Task CompleteAsync(Guid taskId, CancellationToken ct);
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
}
public interface IReportAnalysisService
{
Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct);
}
public interface IReportRepository
{
Task<IReadOnlyList<Report>> ListAsync(Guid userId, CancellationToken ct);
Task<Report?> GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct);
Task<Report?> GetByIdAsync(Guid reportId, CancellationToken ct);
Task AddAsync(Report report, CancellationToken ct);
void Delete(Report report);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IReportFileStorage
{
Task<StoredReportFile> SaveAsync(ReportUploadFile file, string extension, CancellationToken ct);
string GetLocalFilePath(string fileUrl);
bool Exists(string filePath);
void Delete(string filePath);
}

View File

@@ -0,0 +1,122 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Reports;
public sealed class ReportService(
IReportRepository reports,
IReportFileStorage fileStorage,
IReportAnalysisQueue queue) : IReportService
{
private const long MaxReportFileBytes = 20 * 1024 * 1024;
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".webp", ".pdf"
};
private readonly IReportRepository _reports = reports;
private readonly IReportFileStorage _fileStorage = fileStorage;
private readonly IReportAnalysisQueue _queue = queue;
public async Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct)
{
var result = await _reports.ListAsync(userId, ct);
return result.Select(ToDto).ToList();
}
public async Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct)
{
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
return report == null ? null : ToDto(report);
}
public async Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct)
{
if (file.Length <= 0)
return Fail(400, "未上传文件");
if (file.Length > MaxReportFileBytes)
return Fail(400, "报告文件不能超过 20MB请压缩后重新上传");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return Fail(400, "目前支持上传 JPG、PNG、WEBP 图片或 PDF 报告");
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
var report = new Report
{
Id = Guid.NewGuid(),
UserId = userId,
FileUrl = storedFile.FileUrl,
FileType = ext == ".pdf" ? ReportFileType.Pdf : ReportFileType.Image,
Category = ReportCategory.Other,
Status = ReportStatus.Analyzing,
AiSummary = null,
AiIndicators = null,
CreatedAt = DateTime.UtcNow,
};
await _reports.AddAsync(report, ct);
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, storedFile.FilePath), ct);
return new ReportUploadResult(true, 0, "报告已上传AI 正在分析中", ToDto(report));
}
public async Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct)
{
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
if (report == null) return false;
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
if (_fileStorage.Exists(filePath))
_fileStorage.Delete(filePath);
_reports.Delete(report);
await _reports.SaveChangesAsync(ct);
return true;
}
public async Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct)
{
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
if (report == null) return false;
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
if (!_fileStorage.Exists(filePath)) return false;
report.Status = ReportStatus.Analyzing;
report.AiSummary = null;
report.AiIndicators = null;
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, filePath), ct);
return true;
}
public static ReportDto ToDto(Report report) => new(
report.Id,
report.UserId,
$"/api/reports/{report.Id}/file",
report.FileType.ToString(),
report.Category.ToString(),
report.Status.ToString(),
ToAiStatus(report),
report.Status == ReportStatus.DoctorReviewed ? "Reviewed" : "Pending",
report.Severity,
report.AiSummary,
report.AiIndicators,
report.DoctorComment,
report.DoctorRecommendation,
report.DoctorName,
report.ReviewedAt,
report.CreatedAt);
private static string ToAiStatus(Report report) => report.Status switch
{
ReportStatus.Analyzing => "Analyzing",
ReportStatus.AnalysisFailed => "Failed",
_ when string.IsNullOrWhiteSpace(report.AiSummary) => "Analyzing",
_ => "Succeeded"
};
private static ReportUploadResult Fail(int code, string message) =>
new(false, code, message, null);
}

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

@@ -0,0 +1,42 @@
using Health.Domain.Entities;
namespace Health.Application.Users;
public sealed record UserProfileDto(
Guid Id,
string? Phone, // Apple 用户无手机号
string Role,
string? Name,
string? Gender,
string? BirthDate,
string? AvatarUrl);
public sealed record UserProfileUpdateRequest(
string? Name,
string? Gender,
DateOnly? BirthDate,
string? AvatarUrl);
public sealed record AccountFileReferences(
IReadOnlyList<string> FileUrls,
IReadOnlyList<string> ConversationMetadataJson);
public interface IUserService
{
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct);
Task DeleteAccountAsync(Guid userId, CancellationToken ct);
}
public interface IUserRepository
{
Task<User?> GetAsync(Guid userId, CancellationToken ct);
Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
}
public interface IAccountFileCleanup
{
Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct);
}

View File

@@ -0,0 +1,43 @@
namespace Health.Application.Users;
public sealed class UserService(
IUserRepository users,
IAccountFileCleanup fileCleanup) : IUserService
{
private readonly IUserRepository _users = users;
private readonly IAccountFileCleanup _fileCleanup = fileCleanup;
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
{
var user = await _users.GetAsync(userId, ct);
return user == null ? null : new UserProfileDto(
user.Id,
user.Phone,
user.Role,
user.Name,
user.Gender,
user.BirthDate?.ToString("yyyy-MM-dd"),
user.AvatarUrl);
}
public async Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct)
{
var user = await _users.GetAsync(userId, ct);
if (user == null) return false;
if (request.Name != null) user.Name = request.Name;
if (request.Gender != null) user.Gender = request.Gender;
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl;
user.UpdatedAt = DateTime.UtcNow;
await _users.SaveChangesAsync(ct);
return true;
}
public async Task DeleteAccountAsync(Guid userId, CancellationToken ct)
{
var references = await _users.GetAccountFileReferencesAsync(userId, ct);
await _fileCleanup.DeleteAsync(userId, references, ct);
await _users.DeleteAccountDataAsync(userId, ct);
}
}

View File

@@ -44,6 +44,8 @@ public sealed class Doctor
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Title { get; set; } // 主任医师/副主任医师 public string? Title { get; set; } // 主任医师/副主任医师
public string? Department { get; set; } // 心血管内科/营养科 public string? Department { get; set; } // 心血管内科/营养科
public string? Phone { get; set; } // 医生手机号
public string? ProfessionalDirection { get; set; } // 专业方向
public string? AvatarUrl { get; set; } public string? AvatarUrl { get; set; }
public string? Introduction { get; set; } public string? Introduction { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;

View File

@@ -0,0 +1,23 @@
namespace Health.Domain.Entities;
/// <summary>
/// 医生扩展信息
/// </summary>
public sealed class DoctorProfile
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid? DoctorId { get; set; } // FK → Doctor 实体
public string? Name { get; set; }
public Doctor? Doctor { get; set; } // 导航属性
public string? Title { get; set; }
public string? Department { get; set; }
public string? Hospital { get; set; }
public string? AvatarUrl { get; set; }
public bool IsOnline { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
}

View File

@@ -7,7 +7,9 @@ public sealed class ExercisePlan
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid UserId { get; set; } public Guid UserId { get; set; }
public DateOnly WeekStartDate { get; set; } // 本周一 public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public TimeOnly ReminderTime { get; set; } = new(19, 0);
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -22,7 +24,7 @@ public sealed class ExercisePlanItem
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid PlanId { get; set; } public Guid PlanId { get; set; }
public int DayOfWeek { get; set; } // 0=周一, 6=周日 public DateOnly ScheduledDate { get; set; }
public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳 public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳
public int DurationMinutes { get; set; } public int DurationMinutes { get; set; }
public bool IsCompleted { get; set; } public bool IsCompleted { get; set; }

View File

@@ -35,9 +35,22 @@ public sealed class HealthArchive
public List<string> DietRestrictions { get; set; } = []; // 饮食限制 public List<string> DietRestrictions { get; set; } = []; // 饮食限制
public List<string> ChronicDiseases { get; set; } = []; // 慢病史 public List<string> ChronicDiseases { get; set; } = []; // 慢病史
public string? FamilyHistory { get; set; } // 家族病史 public string? FamilyHistory { get; set; } // 家族病史
public string? SurgeryHistoryStatus { get; set; } // 无/有,区分未填写与无手术史
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!; public User User { get; set; } = null!;
public ICollection<HealthArchiveSurgery> Surgeries { get; set; } = [];
}
public sealed class HealthArchiveSurgery
{
public Guid Id { get; set; }
public Guid HealthArchiveId { get; set; }
public string Type { get; set; } = string.Empty;
public DateOnly? Date { get; set; }
public int SortOrder { get; set; }
public HealthArchive HealthArchive { get; set; } = null!;
} }
/// <summary> /// <summary>
@@ -77,6 +90,17 @@ public sealed class NotificationPreference
public bool FollowUpReminder { get; set; } = true; public bool FollowUpReminder { get; set; } = true;
public bool DoctorReply { get; set; } = true; public bool DoctorReply { get; set; } = true;
public bool AbnormalAlert { get; set; } = true; public bool AbnormalAlert { get; set; } = true;
public bool PushEnabled { get; set; } = true;
public bool DndEnabled { get; set; }
public int DndStartMinutes { get; set; } = 22 * 60;
public int DndEndMinutes { get; set; } = 8 * 60;
// 健康录入提醒——总开关 + 5 子项
public bool HealthRecordReminder { get; set; } = true;
public bool HealthRecordReminderBloodPressure { get; set; } = true;
public bool HealthRecordReminderHeartRate { get; set; } = true;
public bool HealthRecordReminderGlucose { get; set; } = true;
public bool HealthRecordReminderSpO2 { get; set; } = true;
public bool HealthRecordReminderWeight { get; set; } = true;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!; public User User { get; set; } = null!;
@@ -97,3 +121,20 @@ public sealed class DeviceToken
public User User { get; set; } = null!; public User User { get; set; } = null!;
} }
public sealed class UserNotification
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid SourceId { get; set; }
public string Type { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Severity { get; set; } = "info";
public string? ActionType { get; set; }
public string? ActionTargetId { get; set; }
public bool IsRead { get; set; }
public DateTime? ReadAt { get; set; }
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -1,13 +1,17 @@
namespace Health.Domain.Entities; namespace Health.Domain.Entities;
/// <summary> /// <summary>
/// 用户(患者 /// 用户(支持用户/医生双角色
/// </summary> /// </summary>
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 Guid? DoctorId { get; set; } // 患者选择的医生
public string? Name { get; set; } public string? Name { get; set; }
public Doctor? Doctor { get; set; } // 导航属性
public string? Gender { get; set; } public string? Gender { get; set; }
public DateOnly? BirthDate { get; set; } public DateOnly? BirthDate { get; set; }
public string? AvatarUrl { get; set; } public string? AvatarUrl { get; set; }
@@ -26,4 +30,5 @@ public sealed class User
public ICollection<DeviceToken> DeviceTokens { get; set; } = []; public ICollection<DeviceToken> DeviceTokens { get; set; } = [];
public HealthArchive? HealthArchive { get; set; } public HealthArchive? HealthArchive { get; set; }
public NotificationPreference? NotificationPreference { get; set; } public NotificationPreference? NotificationPreference { get; set; }
public DoctorProfile? DoctorProfile { get; set; }
} }

View File

@@ -58,11 +58,12 @@ public enum MedicationLogStatus
/// </summary> /// </summary>
public enum MedicationFrequency public enum MedicationFrequency
{ {
Daily, // 每天一次 Daily, // 每天一次
TwiceDaily, // 每天两次 TwiceDaily, // 每天两次
ThreeTimesDaily, // 每天三次 ThreeTimesDaily, // 每天三次
Weekly, // 每周 EveryOtherDay, // 隔天一次
AsNeeded // 必要时 Weekly, // 每周
AsNeeded // 必要时
} }
/// <summary> /// <summary>
@@ -70,6 +71,8 @@ public enum MedicationFrequency
/// </summary> /// </summary>
public enum ReportStatus public enum ReportStatus
{ {
Analyzing, // AI 分析中
AnalysisFailed, // AI 分析失败
PendingDoctor, // 待医生确认 PendingDoctor, // 待医生确认
DoctorReviewed // 医生已确认 DoctorReviewed // 医生已确认
} }
@@ -139,7 +142,7 @@ public enum AgentType
Medication, // 药管家 Medication, // 药管家
Report, // 看报告 Report, // 看报告
Exercise, // 运动计划 Exercise, // 运动计划
Onboarding // 建档引导 Unified // 统一入口(自动路由)
} }
/// <summary> /// <summary>

View File

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

View File

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

@@ -1,3 +1,6 @@
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
@@ -5,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" },
}
}
} }
}; };
@@ -21,113 +59,126 @@ public static class CommonAgentHandler
public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool]; public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) public static async Task<object> Execute(
string toolName,
JsonElement args,
Guid userId,
IHealthRecordService healthRecords,
IHealthArchiveService archives,
CancellationToken ct = default)
{ {
return toolName switch return toolName switch
{ {
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, args), "query_health_records" => await ExecuteQueryHealthRecords(healthRecords, userId, args, ct),
"check_archive" => await ExecuteCheckArchive(db, userId), "check_archive" => await ExecuteCheckArchive(archives, userId, ct),
_ => new { success = false, message = $"未知工具: {toolName}" } _ => new { success = false, message = $"未知工具: {toolName}" }
}; };
} }
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args) 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))
return new { success = false, message = $"无法识别健康指标类型: {rawType}" };
var query = db.HealthRecords.Where(r => r.UserId == userId); var scope = args.TryGetProperty("scope", out var scopeValue)
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt)) ? scopeValue.GetString()?.ToLowerInvariant()
query = query.Where(r => r.MetricType == mt); : "recent_days";
DateTime? startUtc = null;
DateTime? endUtc = null;
int days;
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days)); switch (scope)
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
{ {
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt, case "today":
}).ToListAsync(); (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;
}
return new { count = records.Count, records }; 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(AppDbContext db, Guid userId) private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
{ {
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId); var archive = await archives.GetAsync(userId, ct);
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,
archive.Diagnosis,
archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, archive.Surgeries,
archive.Allergies,
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
}; };
} }
public static readonly ToolDefinition ManageArchiveTool = new() private static bool TryNormalizeMetricType(string? value, out string? normalized)
{ {
Function = new() normalized = value?.Trim().ToLowerInvariant() switch
{ {
Name = "manage_archive", Description = "管理用户健康档案", null or "" or "all" or "全部" => null,
Parameters = new "bloodpressure" or "blood_pressure" or "血压" => "BloodPressure",
{ "heartrate" or "heart_rate" or "心率" or "脉搏" => "HeartRate",
type = "object", "glucose" or "blood_glucose" or "血糖" => "Glucose",
properties = new "spo2" or "blood_oxygen" or "血氧" or "血氧饱和度" => "SpO2",
{ "weight" or "体重" => "Weight",
action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" }, _ => "__invalid__",
diagnosis = new { type = "string" }, };
surgery_type = new { type = "string" }, return normalized != "__invalid__";
surgery_date = new { type = "string" },
allergies = new { type = "array", items = new { type = "string" } },
chronic_diseases = new { type = "array", items = new { type = "string" } },
diet_restrictions = new { type = "array", items = new { type = "string" } },
},
required = new[] { "action" }
}
}
};
public static async Task<object> ExecuteManageArchive(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
var archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId);
if (archive == null)
{
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
db.HealthArchives.Add(archive);
}
switch (action)
{
case "update_diagnosis":
archive.Diagnosis = args.TryGetProperty("diagnosis", out var d) ? d.GetString() : archive.Diagnosis;
break;
case "update_surgery":
archive.SurgeryType = args.TryGetProperty("surgery_type", out var st) ? st.GetString() : archive.SurgeryType;
if (args.TryGetProperty("surgery_date", out var sd))
archive.SurgeryDate = DateOnly.TryParse(sd.GetString(), out var date) ? date : archive.SurgeryDate;
break;
case "update_allergies":
if (args.TryGetProperty("allergies", out var al) && al.ValueKind == JsonValueKind.Array)
archive.Allergies = [.. al.EnumerateArray().Select(x => x.GetString()!)];
break;
case "update_chronic_diseases":
if (args.TryGetProperty("chronic_diseases", out var cd) && cd.ValueKind == JsonValueKind.Array)
archive.ChronicDiseases = [.. cd.EnumerateArray().Select(x => x.GetString()!)];
break;
case "update_diet_restrictions":
if (args.TryGetProperty("diet_restrictions", out var dr) && dr.ValueKind == JsonValueKind.Array)
archive.DietRestrictions = [.. dr.EnumerateArray().Select(x => x.GetString()!)];
break;
default: // query
return new
{
found = true, archive.Diagnosis, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
};
}
archive.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
} }
private static bool TryReadDate(JsonElement args, string name, out DateOnly date)
{
date = default;
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

@@ -1,27 +1,10 @@
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
/// AI 问诊 Agent 工具处理器——转医生 /// 问诊 Agent 工具处理器(问诊转医生走专门界面,此处只保留档案和记录查询)
/// </summary> /// </summary>
public static class ConsultationAgentHandler public static class ConsultationAgentHandler
{ {
public static readonly ToolDefinition RequestDoctorTool = new() public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool];
{
Function = new()
{
Name = "request_doctor", Description = "请求转接真人医生",
Parameters = new { type = "object", properties = new { reason = new { type = "string" }, urgency_level = new { type = "string" } } }
}
};
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool, RequestDoctorTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" or "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
} }

View File

@@ -1,23 +1,10 @@
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
/// 饮食 Agent 工具处理器——食物估算 /// 饮食 Agent 工具处理器(饮食拍照走专门界面,此处只保留档案查询)
/// </summary> /// </summary>
public static class DietAgentHandler public static class DietAgentHandler
{ {
public static readonly ToolDefinition EstimateFoodTool = new() public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool];
{
Function = new() { Name = "estimate_food_text", Description = "根据文字描述估算食物份量和热量", Parameters = new { type = "object", properties = new { text = new { type = "string" } }, required = new[] { "text" } } }
};
public static List<ToolDefinition> Tools => [EstimateFoodTool, CommonAgentHandler.CheckArchiveTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
} }

View File

@@ -1,70 +1,209 @@
using Health.Application.Exercises;
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 运动计划 Agent 工具处理器
/// </summary>
public static class ExerciseAgentHandler public static class ExerciseAgentHandler
{ {
public static readonly ToolDefinition ManageExerciseTool = new() public static readonly ToolDefinition ManageExerciseTool = new()
{ {
Function = new() Function = new()
{ {
Name = "manage_exercise", Description = "运动计划管理", Name = "manage_exercise",
Parameters = new { type = "object", properties = new { action = new { type = "string" } }, required = new[] { "action" } } Description = "运动计划管理(创建/查询/打卡)",
} Parameters = new
{
type = "object",
properties = new
{
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默认今天" },
duration_days = new { type = "integer", description = "连续运动天数如一周为7天" },
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
duration_minutes = new { type = "integer", description = "每天运动时长,单位分钟" },
reminder_time = new { type = "string", description = "每天提醒时间 HH:mm默认19:00" },
item_id = new { type = "string", description = "打卡条目 ID" },
},
required = new[] { "action" },
},
},
}; };
public static List<ToolDefinition> Tools => [ManageExerciseTool]; public static List<ToolDefinition> Tools => [ManageExerciseTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) public static string? ValidateWriteArguments(JsonElement args)
{ {
return toolName switch 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(
string toolName,
JsonElement args,
Guid userId,
IExerciseService exercises,
CancellationToken ct = default)
{
if (toolName != "manage_exercise")
return new { success = false, message = $"未知工具: {toolName}" };
var action = args.TryGetProperty("action", out var actionValue)
? actionValue.GetString()?.ToLowerInvariant()
: null;
if (action == "create")
{ {
"manage_exercise" => await ExecuteManageExercise(db, userId, args), var validationError = ValidateWriteArguments(args);
_ => new { success = false, message = $"未知工具: {toolName}" } 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)
? parsedStart
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var days = args.TryGetProperty("duration_days", out var daysValue) && daysValue.TryGetInt32(out var parsedDays)
? Math.Clamp(parsedDays, 1, 366)
: 7;
var exerciseType = args.TryGetProperty("exercise_type", out var typeValue) ? typeValue.GetString() : "散步";
var minutes = args.TryGetProperty("duration_minutes", out var minutesValue) && minutesValue.TryGetInt32(out var parsedMinutes) ? parsedMinutes : 30;
var reminder = args.TryGetProperty("reminder_time", out var reminderValue) && TimeOnly.TryParse(reminderValue.GetString(), out var parsedReminder)
? parsedReminder
: new TimeOnly(19, 0);
var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(
startDate, startDate.AddDays(days - 1), exerciseType, minutes, reminder), ct);
return new { success = true, plan_id = planId, exercise_type = exerciseType, duration_minutes = minutes, day_count = days, start_date = startDate, end_date = startDate.AddDays(days - 1), reminder_time = reminder };
}
if (action == "checkin")
{
var itemId = args.TryGetProperty("item_id", out var itemValue) && itemValue.TryGetGuid(out var parsedId) ? parsedId : Guid.Empty;
var success = await exercises.CheckInAsync(userId, itemId, ct);
return success ? new { success = true } : new { success = false, message = "条目不存在" };
}
if (action != "query")
return new { success = false, message = $"未知操作: {action}" };
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
{
plan_id = plan.Id,
plan.StartDate,
plan.EndDate,
phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active",
plan.ReminderTime,
total_items = plan.Items.Count,
completed_items = plan.Items.Count(item => item.IsCompleted),
returned_items = isDateScope
? plan.Items.Count(item => item.ScheduledDate == queryDate)
: Math.Min(plan.Items.Count, 14),
items_truncated = !isDateScope && plan.Items.Count > 14,
items = plan.Items
.Where(item => !isDateScope || item.ScheduledDate == queryDate)
.OrderBy(item => item.ScheduledDate)
.Take(isDateScope ? int.MaxValue : 14)
.Select(item => new
{
item.Id,
item.ScheduledDate,
item.ExerciseType,
item.DurationMinutes,
item.IsCompleted,
item.IsRestDay,
completed_at_beijing = item.CompletedAt.HasValue ? AiDateTime.ToBeijingText(item.CompletedAt.Value) : null,
})
.ToList(),
});
var filtered = scope switch
{
"all_plans" => overview,
"current_plans" => overview.Where(plan => plan.phase == "active"),
"upcoming_plans" => overview
.Where(plan => plan.phase == "upcoming")
.OrderBy(plan => plan.StartDate),
"ended_plans" => overview
.Where(plan => plan.phase == "ended")
.OrderByDescending(plan => plan.EndDate),
_ => overview.Where(plan => plan.phase == "active" && plan.items.Count > 0),
};
var result = filtered.ToList();
var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList();
var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault();
string authoritativeAnswer;
if (result.Count == 0)
{
authoritativeAnswer = isDateScope
? $"没有查到 {queryDate:yyyy-MM-dd} 的运动安排。"
: "当前没有查到符合条件的运动计划。";
}
else if (isDateScope)
{
var taskLines = scheduledTasks.Select(item =>
$"- {item.ExerciseType}{item.DurationMinutes} 分钟,{(item.IsCompleted ? "" : "")}");
authoritativeAnswer =
$"{queryDate:yyyy-MM-dd} 的运动安排:\n{string.Join('\n', taskLines)}";
}
else
{
var planLines = result.Select(plan =>
{
var representative = plan.items.FirstOrDefault(item => !item.IsRestDay);
var exerciseType = representative?.ExerciseType ?? "运动";
var durationMinutes = representative?.DurationMinutes ?? 0;
return $"- {exerciseType}{plan.StartDate:yyyy-MM-dd} 至 {plan.EndDate:yyyy-MM-dd}"
+ $"每天 {durationMinutes} 分钟,提醒时间 {plan.ReminderTime:HH:mm}";
});
authoritativeAnswer =
$"查到 {result.Count} 个符合条件的运动计划:\n{string.Join('\n', planLines)}";
}
return new
{
found = result.Count > 0,
query_date = queryDate.ToString("yyyy-MM-dd"),
scope,
count = result.Count,
all_tasks_completed = isDateScope
? scheduledTasks.Count > 0 && scheduledTasks.All(item => item.IsCompleted)
: (bool?)null,
has_upcoming_plans = nextPlan != null,
next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"),
days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber,
plans = result,
authoritative_answer = authoritativeAnswer,
}; };
} }
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
switch (action)
{
case "create":
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.Now);
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
if (args.TryGetProperty("items", out var items))
{
foreach (var item in items.EnumerateArray())
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
});
}
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync();
return new { success = true, plan_id = plan.Id };
case "checkin":
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
exerciseItem.IsCompleted = true;
exerciseItem.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
default:
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
if (existingPlan == null) return new { found = false };
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
}
}
} }

View File

@@ -1,3 +1,5 @@
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
@@ -5,81 +7,290 @@ 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, AppDbContext db, Guid userId) 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(db, userId, args), "record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args, ct),
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId), "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(AppDbContext db, Guid userId, JsonElement args) private static async Task<object> ExecuteRecordHealthData(
IHealthRecordService healthRecords,
Guid userId,
JsonElement args,
CancellationToken ct)
{ {
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : ""; var validationError = ValidateWriteArguments(args);
var record = new HealthRecord if (validationError != null)
{ return new { success = false, message = validationError };
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
};
switch (type) var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null)
return new { success = false, message = $"未知指标类型: {type}" };
var recordedAt = args.TryGetProperty("recorded_at", out var ra) &&
AiDateTime.TryParseUserDateTime(ra.GetString(), out var parsedRecordedAt)
? parsedRecordedAt
: DateTime.UtcNow;
var request = new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
recordedAt);
var id = await healthRecords.CreateAsync(userId, request, ct);
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
return new
{ {
case "blood_pressure": success = true,
record.MetricType = HealthMetricType.BloodPressure; record_id = id,
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null; type = metricType.Value.ToString(),
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null; value = valStr,
record.Unit = "mmHg"; unit,
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59; isAbnormal = HealthRecordRules.CheckAbnormal(request)
break; };
case "heart_rate": }
record.MetricType = HealthMetricType.HeartRate;
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null; private static async Task<object> ExecuteRecordHealthDataBatch(
record.Unit = "次/分"; IHealthRecordService healthRecords,
record.IsAbnormal = record.Value > 100 || record.Value < 60; Guid userId,
break; JsonElement args,
case "glucose": CancellationToken ct)
record.MetricType = HealthMetricType.Glucose; {
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null; var metrics = GetBatchMetrics(args);
record.Unit = "mmol/L"; if (metrics.Count == 0)
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m; return new { success = false, message = "当前录入批次没有健康指标" };
break;
case "spo2": var requests = new List<HealthRecordUpsertRequest>(metrics.Count);
record.MetricType = HealthMetricType.SpO2; var previews = new List<Dictionary<string, object?>>(metrics.Count);
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null; foreach (var metric in metrics)
record.Unit = "%"; {
record.IsAbnormal = record.Value <= 94; var validationError = ValidateWriteArguments(metric);
break; if (validationError != null)
case "weight": return new { success = false, message = validationError };
record.MetricType = HealthMetricType.Weight;
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null; var type = metric.GetProperty("type").GetString()!;
record.Unit = "kg"; var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, metric);
record.IsAbnormal = false; if (metricType == null)
break;
default:
return new { success = false, message = $"未知指标类型: {type}" }; 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));
} }
db.HealthRecords.Add(record); var ids = await healthRecords.CreateManyAsync(userId, requests, ct);
await db.SaveChangesAsync(); return new
var valStr = record.MetricType switch
{ {
HealthMetricType.BloodPressure => $"{record.Systolic}/{record.Diastolic}", success = true,
_ => record.Value?.ToString() ?? "" 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)
{
return type switch
{
"blood_pressure" => (
HealthMetricType.BloodPressure,
null,
"mmHg",
args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null,
args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null),
"heart_rate" => (
HealthMetricType.HeartRate,
args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null,
"次/分",
null,
null),
"glucose" => (
HealthMetricType.Glucose,
args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null,
"mmol/L",
null,
null),
"spo2" => (
HealthMetricType.SpO2,
args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null,
"%",
null,
null),
"weight" => (
HealthMetricType.Weight,
args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null,
"kg",
null,
null),
_ => (null, null, "", null, null)
}; };
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
} }
} }

View File

@@ -1,7 +1,9 @@
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
{ {
@@ -9,65 +11,256 @@ public static class MedicationAgentHandler
{ {
Function = new() Function = new()
{ {
Name = "manage_medication", Description = "用药管理", Name = "manage_medication",
Parameters = new { type = "object", properties = new { action = new { type = "string" }, name = new { type = "string" }, dosage = new { type = "string" } }, required = new[] { "action" } } Description = "用药管理(创建计划、按日期查询计划与逐顿服药状态、确认某一顿服药)",
Parameters = new
{
type = "object",
properties = new
{
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确认服药时使用" },
scheduled_time = new { type = "string", description = "要确认的计划服药时间 HH:mm" },
name = new { type = "string", description = "药品名称" },
dosage = new { type = "string", description = "剂量,如 100mg" },
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\"]" },
duration_days = new { type = "integer", description = "服用天数" },
long_term = new { type = "boolean", description = "是否明确为长期服用;与 duration_days 二选一" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
},
required = new[] { "action" }
}
} }
}; };
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool]; public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
public static async Task<object> Execute(
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) string toolName,
JsonElement args,
Guid userId,
IMedicationService medications,
CancellationToken ct = default)
{ {
return toolName switch return toolName switch
{ {
"manage_medication" => await ExecuteManageMedication(db, userId, args), "manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
"check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId),
_ => new { success = false, message = $"未知工具: {toolName}" } _ => new { success = false, message = $"未知工具: {toolName}" }
}; };
} }
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args) 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(db, userId, args), "create" => await CreateMedication(medications, userId, args, ct),
"query" => await QueryMedications(db, userId), "query" => await QueryMedications(medications, userId, args, ct),
"confirm" => await ConfirmMedication(db, userId, args), "confirm" => await ConfirmMedication(medications, userId, args, ct),
_ => new { success = false, message = $"未知操作: {action}" } _ => new { success = false, message = $"未知操作: {action}" }
}; };
} }
private static async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args) private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{ {
var med = new Medication var validationError = ValidateWriteArguments(args);
if (validationError != null)
return new { success = false, message = validationError };
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequency = ReadFrequency(args);
var times = ReadTimes(args);
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);
// 结束日为包含式IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
DateOnly? endDate = longTerm ? null : startDate.AddDays(durationDays - 1);
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
name,
dosage,
frequency,
times.Count > 0 ? times : [new TimeOnly(8, 0)],
startDate,
endDate,
MedicationSource.AiEntry,
null), ct);
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
return new
{ {
Id = Guid.NewGuid(), UserId = userId, success = true,
Name = args.TryGetProperty("name", out var n) ? n.GetString()! : "", medication_id = medicationId,
Dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null, name,
Source = MedicationSource.AiEntry, IsActive = true, dosage,
frequency = frequency.ToString(),
time = timeLabels,
start_date = startDate.ToString("yyyy-MM-dd"),
duration_days = durationDays,
long_term = longTerm,
}; };
db.Medications.Add(med);
await db.SaveChangesAsync();
return new { success = true, medication_id = med.Id, med.Name };
} }
private static async Task<object> QueryMedications(AppDbContext db, Guid userId) private static async Task<object> QueryMedications(
IMedicationService medications,
Guid userId,
JsonElement args,
CancellationToken ct)
{ {
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive) var scope = args.TryGetProperty("scope", out var scopeValue)
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync(); ? scopeValue.GetString()?.ToLowerInvariant()
return new { count = meds.Count, medications = meds }; : 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
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args) {
{ success = false,
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty; message = "查询用药时必须明确 scopetoday、scheduled_date、current_plans、upcoming_plans、ended_plans、inactive_plans 或 all_plans",
db.MedicationLogs.Add(new MedicationLog };
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
{ {
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId, "all_plans" => overview.ToList(),
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow, "current_plans" => overview.Where(plan => plan.Phase == "active").ToList(),
}); "upcoming_plans" => overview
await db.SaveChangesAsync(); .Where(plan => plan.Phase == "upcoming")
return new { success = true }; .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)
{
var validationError = ValidateWriteArguments(args);
if (validationError != null)
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)
{
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
return Enum.TryParse<MedicationFrequency>(frequencyStr, ignoreCase: true, out var frequency) ? frequency : MedicationFrequency.Daily;
}
private static DateOnly ReadStartDate(JsonElement args)
{
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
return DateOnly.TryParse(startDateStr, out var parsedDate)
? parsedDate
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
}
private static List<TimeOnly> ReadTimes(JsonElement args)
{
List<TimeOnly> times = [];
if (!args.TryGetProperty("time_of_day", out var tod) || tod.ValueKind != JsonValueKind.Array)
return times;
foreach (var t in tod.EnumerateArray())
{
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
}
return times;
} }
} }

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

@@ -12,15 +12,6 @@ public static class ReportAgentHandler
public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool]; public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient) public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient)
{ {
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : ""; var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
@@ -28,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

@@ -0,0 +1,91 @@
using System.Text.Json;
using Health.Application.AI;
using Health.Application.Exercises;
using Health.Application.Diets;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
using Health.Application.Notifications;
using Health.Application.Reports;
using Health.Infrastructure.AI.AgentHandlers;
namespace Health.Infrastructure.AI;
public sealed class AiToolExecutionService(
AppDbContext db,
VisionClient visionClient,
IHealthArchiveService healthArchives,
IHealthRecordService healthRecords,
IExerciseService exercises,
IMedicationService medications,
IDietService diets,
IReportService reports,
IInAppNotificationService notifications,
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
{
private readonly AppDbContext _db = db;
private readonly VisionClient _visionClient = visionClient;
private readonly IHealthArchiveService _healthArchives = healthArchives;
private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IExerciseService _exercises = exercises;
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;
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
{
using var json = JsonDocument.Parse(arguments);
var root = json.RootElement;
return await (toolName switch
{
"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),
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, userId, _exercises, 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}" }),
});
}
public async Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var command = await _confirmations.TakeAsync(commandId, userId, ct);
if (command == null)
return new AiConfirmedWriteResult(40004, null, "确认请求不存在、已执行或已过期");
try
{
var result = await ExecuteAsync(command.ToolName, command.Arguments, userId, ct);
if (!Succeeded(result, out var error))
{
await transaction.RollbackAsync(ct);
return new AiConfirmedWriteResult(40001, result, error ?? "写入失败,请检查内容后重试");
}
await _confirmations.CompleteAsync(command, ct);
await transaction.CommitAsync(ct);
return new AiConfirmedWriteResult(0, result, null);
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
return new AiConfirmedWriteResult(50001, null, $"写入失败: {ex.Message}");
}
}
private static bool Succeeded(object result, out string? error)
{
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
var root = json.RootElement;
error = root.TryGetProperty("message", out var message) ? message.GetString() : null;
return !root.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.False;
}
}

View File

@@ -0,0 +1,168 @@
using System.Text.Json;
using Health.Application.AI;
using Health.Infrastructure.Files;
using Microsoft.Extensions.Logging;
using UglyToad.PdfPig;
namespace Health.Infrastructure.AI;
public sealed class AttachmentContextBuilder(
VisionClient vision,
ILogger<AttachmentContextBuilder> logger) : IAttachmentContextBuilder
{
private const int MaxPdfChars = 6000;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
private readonly VisionClient _vision = vision;
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
public async Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(imageUrl))
return await BuildImageAsync(userId, imageUrl!, ct);
if (!string.IsNullOrWhiteSpace(pdfUrl))
return await BuildPdfAsync(userId, pdfUrl!, ct);
return null;
}
// ── 图片:调 VLM 输出结构化 JSON ──
private async Task<AttachmentContext?> BuildImageAsync(Guid userId, string imageUrl, CancellationToken ct)
{
var filePath = UserUploadPathResolver.Resolve(userId, imageUrl);
if (filePath == null || !File.Exists(filePath))
{
_logger.LogWarning("Image file not found for {Url}", imageUrl);
return new AttachmentContext("image", null, null, "图片暂时无法读取", "[图片附件读取失败,请描述图片内容]");
}
var bytes = await File.ReadAllBytesAsync(filePath, ct);
var mime = Path.GetExtension(filePath).ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
".heic" => "image/heic",
_ => "image/jpeg",
};
var dataUrl = $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
var prompt = """
JSON
{"category":"food|report|wound|drug|chart|other","summary":"1-2句话描述图片","details":["关键信息1","关键信息2"]}
- food
- reportX光/CT/MRI/B超截图等
- wound
- drug
- chart
- other
details
JSON
""";
string? raw = null;
try
{
var resp = await _vision.VisionAsync(prompt, [dataUrl], userText: null, maxTokens: 1024, ct: ct);
raw = resp.Choices?.FirstOrDefault()?.Message?.Content;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "VLM call failed for image {Url}", imageUrl);
}
if (string.IsNullOrWhiteSpace(raw))
return new AttachmentContext("image", null, null, "图片识别失败", "[图片附件识别失败,请简要描述图片内容]");
try
{
var cleaned = StripCodeFence(raw.Trim());
using var doc = JsonDocument.Parse(cleaned);
var root = doc.RootElement;
var category = GetString(root, "category") ?? "other";
var summary = GetString(root, "summary") ?? "图片内容";
var details = root.TryGetProperty("details", out var d) && d.ValueKind == JsonValueKind.Array
? string.Join("、", d.EnumerateArray().Select(x => x.GetString()).Where(s => !string.IsNullOrWhiteSpace(s)))
: "";
var compact = string.IsNullOrEmpty(details)
? $"图片识别:{summary}"
: $"图片识别:{summary}{details}";
var llmContent = $"[图片识别(类别 {category}{summary}{(string.IsNullOrEmpty(details) ? "" : $" {details}")}]";
return new AttachmentContext("image", category, null, compact, llmContent);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse VLM JSON: {Raw}", raw);
// 兜底:原文也能用
return new AttachmentContext("image", null, null, $"图片识别:{raw[..Math.Min(raw.Length, 80)]}", $"[图片识别:{raw}]");
}
}
// ── PDFPdfPig 抽取文本 ──
private Task<AttachmentContext?> BuildPdfAsync(Guid userId, string pdfUrl, CancellationToken ct)
{
var filePath = UserUploadPathResolver.Resolve(userId, pdfUrl);
var fileName = Path.GetFileName(pdfUrl);
if (filePath == null || !File.Exists(filePath))
{
_logger.LogWarning("PDF file not found for {Url}", pdfUrl);
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
"pdf", null, fileName, "PDF 文件读取失败",
"[PDF 附件读取失败,请描述文档内容]"));
}
try
{
using var pdf = PdfDocument.Open(filePath);
var sb = new System.Text.StringBuilder();
foreach (var page in pdf.GetPages())
{
sb.AppendLine(page.Text);
if (sb.Length > MaxPdfChars) break;
}
var text = sb.ToString().Trim();
if (string.IsNullOrWhiteSpace(text))
{
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
"pdf", null, fileName, "PDF 内容为空或扫描件",
"[PDF 看起来是扫描件或图片版,无法提取文字。请提示用户改用拍照上传,或简要描述报告内容]"));
}
var truncated = text.Length > MaxPdfChars ? text[..MaxPdfChars] + "...(已截断)" : text;
var summary = $"PDF{fileName}";
var llmContent = $"[用户上传 PDF 「{fileName}」,文档内容如下]\n{truncated}";
return Task.FromResult<AttachmentContext?>(new AttachmentContext("pdf", null, fileName, summary, llmContent));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse PDF {Url}", pdfUrl);
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
"pdf", null, fileName, "PDF 解析异常",
"[PDF 解析失败,请提示用户文件可能损坏或加密]"));
}
}
private static string StripCodeFence(string raw)
{
var t = raw.Trim();
if (t.StartsWith("```"))
{
var firstNewline = t.IndexOf('\n');
if (firstNewline > 0) t = t[(firstNewline + 1)..];
if (t.EndsWith("```")) t = t[..^3];
}
return t.Trim();
}
private static string? GetString(JsonElement el, string name) =>
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}

View File

@@ -0,0 +1,68 @@
using Health.Application.AI;
namespace Health.Infrastructure.AI;
public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversationRepository
{
private readonly AppDbContext _db = db;
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) =>
_db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId, ct);
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) =>
_db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId, ct);
public async Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) =>
await _db.Conversations
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.UpdatedAt)
.ToListAsync(ct);
public async Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) =>
await _db.ConversationMessages
.Where(m => m.ConversationId == conversationId)
.OrderBy(m => m.CreatedAt)
.ToListAsync(ct);
public async Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) =>
await _db.ConversationMessages
.Where(m => m.ConversationId == conversationId)
.OrderByDescending(m => m.CreatedAt)
.Take(limit)
.ToListAsync(ct);
public async Task AddConversationAsync(Conversation conversation, CancellationToken ct) =>
await _db.Conversations.AddAsync(conversation, ct);
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
await _db.ConversationMessages.AddAsync(message, ct);
public async Task<int> DeleteLegacyDietCommentaryArtifactsAsync(
Guid userId,
string promptPrefix,
CancellationToken ct)
{
var candidates = await _db.Conversations
.Include(conversation => conversation.Messages)
.Where(conversation =>
conversation.UserId == userId &&
conversation.Messages.Any(message =>
message.Role == MessageRole.User &&
message.Content.StartsWith(promptPrefix)))
.ToListAsync(ct);
var artifacts = candidates
.Where(conversation => DietCommentaryPolicy.IsLegacyArtifact(conversation.Messages))
.ToList();
if (artifacts.Count == 0) return 0;
_db.Conversations.RemoveRange(artifacts);
await _db.SaveChangesAsync(ct);
return artifacts.Count;
}
public void Delete(Conversation conversation) =>
_db.Conversations.Remove(conversation);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

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,98 @@
using Health.Application.AI;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.AI;
public sealed class EfAiWriteConfirmationStore(AppDbContext db) : IAiWriteConfirmationStore
{
private readonly AppDbContext _db = db;
public async Task<PendingAiWriteCommand> CreateAsync(
Guid userId,
string toolName,
string arguments,
TimeSpan lifetime,
CancellationToken ct)
{
var now = DateTime.UtcNow;
if (IsInMemory())
{
var expired = await _db.AiWriteCommands.Where(x => x.Status == "Pending" && x.ExpiresAt <= now).ToListAsync(ct);
foreach (var item in expired) { item.Status = "Expired"; item.UpdatedAt = now; }
}
else
{
await _db.AiWriteCommands
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Expired")
.SetProperty(x => x.UpdatedAt, now), ct);
}
var command = new AiWriteCommandRecord
{
Id = Guid.NewGuid(),
UserId = userId,
ToolName = toolName,
Arguments = arguments,
Status = "Pending",
ExpiresAt = now.Add(lifetime),
CreatedAt = now,
UpdatedAt = now,
};
await _db.AiWriteCommands.AddAsync(command, ct);
await _db.SaveChangesAsync(ct);
return ToCommand(command);
}
public async Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct)
{
var now = DateTime.UtcNow;
if (IsInMemory())
{
var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync(
x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now, ct);
if (tracked == null) return null;
tracked.Status = "Processing";
tracked.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
}
else
{
var updated = await _db.AiWriteCommands
.Where(x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.UpdatedAt, now), ct);
if (updated == 0) return null;
}
var record = await _db.AiWriteCommands.AsNoTracking().FirstAsync(x => x.Id == commandId, ct);
return ToCommand(record);
}
public async Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct)
{
if (IsInMemory())
{
var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync(
x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing", ct);
if (tracked != null) { tracked.Status = "Completed"; tracked.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); }
return;
}
await _db.AiWriteCommands
.Where(x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
private bool IsInMemory() => _db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory";
private static PendingAiWriteCommand ToCommand(AiWriteCommandRecord record) => new(
record.Id,
record.UserId,
record.ToolName,
record.Arguments,
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,
@@ -28,8 +28,13 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{ {
var request = new ChatCompletionRequest var request = new ChatCompletionRequest
{ {
Model = _model, Messages = messages, Stream = true, Model = _model,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools, Messages = messages,
Stream = true,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
Thinking = new { type = "disabled" },
}; };
if (tools?.Count > 0) request.ToolChoice = "auto"; if (tools?.Count > 0) request.ToolChoice = "auto";
@@ -40,7 +45,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct); using var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode(); await AiHttpResponseGuard.ThrowIfFailedAsync(response, "DeepSeek", ct);
using var stream = await response.Content.ReadAsStreamAsync(ct); using var stream = await response.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
@@ -64,23 +69,27 @@ 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,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools, Messages = messages,
Stream = false,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
Thinking = new { 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");
var response = await _http.PostAsync("chat/completions", content, ct); using var response = await _http.PostAsync("chat/completions", content, ct);
response.EnsureSuccessStatusCode(); var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "DeepSeek", ct);
return AiHttpResponseGuard.DeserializeRequired<ChatCompletionResponse>(body, _jsonOptions, "DeepSeek");
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
} }
} }
@@ -119,19 +128,56 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
var request = new ChatCompletionRequest var request = new ChatCompletionRequest
{ {
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false, Model = _model,
Temperature = 0.1f, VlHighResolutionImages = true, Messages = messages,
MaxTokens = maxTokens,
Stream = false,
Temperature = 0.1f,
VlHighResolutionImages = true,
EnableThinking = false,
}; };
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");
var response = await _http.PostAsync("chat/completions", content, ct); using var response = await _http.PostAsync("chat/completions", content, ct);
if (!response.IsSuccessStatusCode) var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "VLM", ct);
{ return AiHttpResponseGuard.DeserializeRequired<ChatCompletionResponse>(body, _jsonOptions, "VLM");
var errorBody = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"VLM API {response.StatusCode}: {errorBody}");
}
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
} }
} }
internal static class AiHttpResponseGuard
{
private const int MaxErrorBodyLength = 2000;
public static async Task ThrowIfFailedAsync(HttpResponseMessage response, string provider, CancellationToken ct)
{
if (response.IsSuccessStatusCode) return;
var body = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"{provider} API {(int)response.StatusCode} ({response.StatusCode}): {Truncate(body)}");
}
public static async Task<string> ReadSuccessBodyAsync(HttpResponseMessage response, string provider, CancellationToken ct)
{
await ThrowIfFailedAsync(response, provider, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (string.IsNullOrWhiteSpace(body))
throw new InvalidDataException($"{provider} API返回了空响应");
return body;
}
public static T DeserializeRequired<T>(string body, JsonSerializerOptions options, string provider)
{
try
{
return JsonSerializer.Deserialize<T>(body, options)
?? throw new InvalidDataException($"{provider} API响应内容为空");
}
catch (JsonException ex)
{
throw new InvalidDataException($"{provider} API响应格式无效", ex);
}
}
private static string Truncate(string value) =>
value.Length <= MaxErrorBodyLength ? value : value[..MaxErrorBodyLength] + "...";
}

View File

@@ -0,0 +1,85 @@
namespace Health.Infrastructure.AI;
public sealed class ChatCompletionRequest
{
public string Model { get; set; } = string.Empty;
public List<ChatMessage> Messages { get; set; } = [];
public bool Stream { get; set; }
public int MaxTokens { get; set; } = 2048;
public float Temperature { get; set; } = 0.7f;
public float? TopP { get; set; }
public List<ToolDefinition>? Tools { get; set; }
public string? ToolChoice { get; set; }
public object? Thinking { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
public bool VlHighResolutionImages { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("enable_thinking")]
public bool? EnableThinking { get; set; }
}
public sealed class ChatMessage
{
public string Role { get; set; } = string.Empty;
public object? Content { get; set; } = string.Empty;
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public string? ToolCallId { get; set; }
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public List<ToolCall>? ToolCalls { get; set; }
}
public sealed class ToolDefinition
{
public string Type { get; set; } = "function";
public ToolFunction Function { get; set; } = new();
}
public sealed class ToolFunction
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public object Parameters { get; set; } = new();
}
public sealed class ToolCall
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = "function";
public ToolCallFunction Function { get; set; } = new();
}
public sealed class ToolCallFunction
{
public string Name { get; set; } = string.Empty;
public string Arguments { get; set; } = string.Empty;
}
public sealed class ChatCompletionResponse
{
public string Id { get; set; } = string.Empty;
public List<Choice> Choices { get; set; } = [];
}
public sealed class Choice
{
public int Index { get; set; }
public ResponseMessage? Message { get; set; }
public ResponseDelta? Delta { get; set; }
public string? FinishReason { get; set; }
}
public sealed class ResponseMessage
{
public string Role { get; set; } = string.Empty;
public string? Content { get; set; }
public List<ToolCall>? ToolCalls { get; set; }
}
public sealed class ResponseDelta
{
public string? Content { get; set; }
public string? Role { get; set; }
}

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

@@ -0,0 +1,71 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.AI;
/// <summary>
/// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。
/// 我们后端的 DeepSeek 依旧负责对话生成与工具调用;
/// 检索结果作为按需工具结果返回给主对话,提升回答准确度。
/// </summary>
public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger<FastGptKnowledgeClient> logger)
{
private readonly HttpClient _http = http;
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 int _limit = int.TryParse(config["FASTGPT_LIMIT"], out var l) ? l : 3000;
private readonly ILogger<FastGptKnowledgeClient> _logger = logger;
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId);
/// <summary>
/// 按用户问题检索知识库,返回最相关的文档片段。
/// 失败或未配置时返回空字符串调用方应当容错——RAG 只是增强,不是必需。
/// </summary>
public async Task<string> SearchAsync(string question, CancellationToken ct = default)
{
if (!IsEnabled || string.IsNullOrWhiteSpace(question)) return "";
try
{
var payload = new
{
datasetId = _datasetId,
text = question,
limit = _limit,
similarity = _similarity,
searchMode = "embedding",
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _http.PostAsync("core/dataset/searchTest", content, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("FastGPT 检索失败: {Status}", response.StatusCode);
return "";
}
var body = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("data", out var data)) return "";
if (!data.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) return "";
var snippets = new List<string>();
foreach (var item in list.EnumerateArray())
{
var q = item.TryGetProperty("q", out var qEl) ? qEl.GetString() ?? "" : "";
var a = item.TryGetProperty("a", out var aEl) ? aEl.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(q) && string.IsNullOrWhiteSpace(a)) continue;
snippets.Add(string.IsNullOrWhiteSpace(a) ? q : $"{q}{a}");
}
return snippets.Count == 0 ? "" : string.Join("\n", snippets);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "FastGPT 检索异常,已忽略");
return "";
}
}
}

View File

@@ -1,237 +0,0 @@
using System.Net.Http.Headers;
namespace Health.Infrastructure.AI;
/// <summary>
/// OpenAI 兼容协议 HTTP 客户端,统一调用 DeepSeek / 千问 VL
/// </summary>
public sealed class OpenAiCompatibleClient(string baseUrl, string apiKey, string model)
{
private readonly HttpClient _http = CreateHttpClient(baseUrl, apiKey);
private readonly string _model = model;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
private static HttpClient CreateHttpClient(string baseUrl, string apiKey)
{
var client = new HttpClient
{
BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(60)
};
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return client;
}
/// <summary>
/// 流式 Chat CompletionsSSE
/// </summary>
public async IAsyncEnumerable<string> ChatStreamAsync(
List<ChatMessage> messages,
List<ToolDefinition>? tools = null,
int maxTokens = 2048,
float temperature = 0.7f)
{
var request = new ChatCompletionRequest
{
Model = _model,
Messages = messages,
Stream = true,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
};
if (tools?.Count > 0)
request.ToolChoice = "auto";
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
{
Content = content
};
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
if (!line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
if (data == "[DONE]") break;
yield return data;
}
}
/// <summary>
/// 非流式 Chat Completions
/// </summary>
public async Task<ChatCompletionResponse> ChatAsync(
List<ChatMessage> messages,
List<ToolDefinition>? tools = null,
int maxTokens = 2048,
float temperature = 0.7f)
{
var request = new ChatCompletionRequest
{
Model = _model,
Messages = messages,
Stream = false,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
};
if (tools?.Count > 0)
request.ToolChoice = "auto";
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.PostAsync("chat/completions", content);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
}
/// <summary>
/// Vision 图片理解(非流式)
/// </summary>
public async Task<ChatCompletionResponse> VisionAsync(
string systemPrompt,
List<string> imageUrls,
string? userText = null,
int maxTokens = 2048)
{
var messages = new List<ChatMessage>();
if (!string.IsNullOrEmpty(systemPrompt))
messages.Add(new ChatMessage { Role = "system", Content = systemPrompt });
// 构建多模态消息内容
var contentParts = new List<object>();
foreach (var url in imageUrls)
contentParts.Add(new { type = "image_url", image_url = new { url } });
if (!string.IsNullOrEmpty(userText))
contentParts.Add(new { type = "text", text = userText });
var userMessage = new ChatMessage
{
Role = "user",
Content = JsonSerializer.Serialize(contentParts, _jsonOptions)
};
messages.Add(userMessage);
var request = new ChatCompletionRequest
{
Model = _model,
Messages = messages,
MaxTokens = maxTokens,
Stream = false,
};
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.PostAsync("chat/completions", content);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
}
}
#region /
public sealed class ChatCompletionRequest
{
public string Model { get; set; } = string.Empty;
public List<ChatMessage> Messages { get; set; } = [];
public bool Stream { get; set; }
public int MaxTokens { get; set; } = 2048;
public float Temperature { get; set; } = 0.7f;
public float? TopP { get; set; }
public List<ToolDefinition>? Tools { get; set; }
public string? ToolChoice { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
public bool VlHighResolutionImages { get; set; }
}
public sealed class ChatMessage
{
public string Role { get; set; } = string.Empty;
public object? Content { get; set; } = string.Empty;
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public string? ToolCallId { get; set; }
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public List<ToolCall>? ToolCalls { get; set; }
}
public sealed class ToolDefinition
{
public string Type { get; set; } = "function";
public ToolFunction Function { get; set; } = new();
}
public sealed class ToolFunction
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public object Parameters { get; set; } = new();
}
public sealed class ToolCall
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = "function";
public ToolCallFunction Function { get; set; } = new();
}
public sealed class ToolCallFunction
{
public string Name { get; set; } = string.Empty;
public string Arguments { get; set; } = string.Empty;
}
public sealed class ChatCompletionResponse
{
public string Id { get; set; } = string.Empty;
public List<Choice> Choices { get; set; } = [];
}
public sealed class Choice
{
public int Index { get; set; }
public ResponseMessage? Message { get; set; }
public ResponseDelta? Delta { get; set; }
public string? FinishReason { get; set; }
}
public sealed class ResponseMessage
{
public string Role { get; set; } = string.Empty;
public string? Content { get; set; }
public List<ToolCall>? ToolCalls { get; set; }
}
public sealed class ResponseDelta
{
public string? Content { get; set; }
public string? Role { get; set; }
}
#endregion

View File

@@ -1,141 +1,123 @@
using System.Reflection;
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
{ {
private static readonly Assembly PromptAssembly = typeof(PromptManager).Assembly;
private static readonly IReadOnlyDictionary<string, string> PromptCache = LoadPrompts();
private static readonly string[] GlobalSections =
[
"global/identity.md",
"global/medical_safety.md",
"global/response_style.md",
];
public string GetIntentRouterPrompt() => Read("router/intent_router.md");
public string ComposeForIntents(IEnumerable<string> intents)
{
var normalized = intents
.Select(NormalizeIntent)
.Where(intent => intent.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalized.Count == 0) normalized.Add("general_chat");
var sections = new List<string>(GlobalSections);
foreach (var intent in normalized)
{
sections.Add(intent switch
{
"health_entry" => "modules/health_entry.md",
"medication_entry" => "modules/medication_entry.md",
"exercise_entry" => "modules/exercise_entry.md",
"personal_query" => "modules/personal_query.md",
"medical_consultation" => "modules/medical_consultation.md",
"report_analysis" => "modules/report_analysis.md",
"diet_analysis" => "modules/diet_analysis.md",
"app_help" or "general_chat" => "modules/general_chat.md",
_ => "modules/general_chat.md",
});
}
if (normalized.Contains("medical_consultation") ||
normalized.Contains("report_analysis") ||
normalized.Contains("diet_analysis"))
{
sections.Add("rag/retrieval_rules.md");
}
return Compose(sections);
}
/// <summary> /// <summary>
/// 获取指定 Agent 的 System Prompt /// Compatibility entry for older clients that still address a specific agent.
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
/// </summary> /// </summary>
public string GetSystemPrompt(AgentType agentType) => agentType switch public string GetSystemPrompt(AgentType agentType) => agentType switch
{ {
AgentType.Default => DefaultPrompt, AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]),
AgentType.Consultation => ConsultationPrompt, AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]),
AgentType.Health => HealthDataPrompt, AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]),
AgentType.Diet => DietPrompt, AgentType.Consultation => ComposeForIntents(["medical_consultation"]),
AgentType.Medication => MedicationPrompt, AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]),
AgentType.Report => ReportPrompt, AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]),
AgentType.Exercise => ExercisePrompt, AgentType.Unified => ComposeForIntents([
AgentType.Onboarding => OnboardingPrompt, "health_entry",
_ => DefaultPrompt "medication_entry",
"exercise_entry",
"personal_query",
"medical_consultation",
"report_analysis",
"diet_analysis",
"general_chat",
]),
_ => ComposeForIntents(["general_chat", "medical_consultation"]),
}; };
private const string DefaultPrompt = """ private static string Compose(IEnumerable<string> sections) =>
AI健康管家 string.Join(
"\n\n",
sections
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(Read)
.Where(content => !string.IsNullOrWhiteSpace(content)));
private static string NormalizeIntent(string? intent) =>
1. intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? "";
2.
3.
4.
private static string Read(string path) =>
- PromptCache.TryGetValue(NormalizePath(path), out var content)
- ? content
- / : throw new InvalidOperationException($"AI prompt resource not found: {path}");
- 怀
""";
private const string ConsultationPrompt = """ private static IReadOnlyDictionary<string, string> LoadPrompts()
{
const string marker = ".AI.Prompts.";
var prompts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var resourceName in PromptAssembly.GetManifestResourceNames())
{
var markerIndex = resourceName.IndexOf(marker, StringComparison.Ordinal);
if (markerIndex < 0 || !resourceName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
continue;
using var stream = PromptAssembly.GetManifestResourceStream(resourceName)
1. ?? throw new InvalidOperationException($"Unable to open AI prompt resource: {resourceName}");
2. 2-3 using var reader = new StreamReader(stream);
3. var relative = resourceName[(markerIndex + marker.Length)..];
4. var lastDot = relative.LastIndexOf(".md", StringComparison.OrdinalIgnoreCase);
5. >160/100>120<50 relative = relative[..lastDot].Replace('.', '/') + ".md";
6. "以上为AI分析具体请咨询医生" prompts[NormalizePath(relative)] = reader.ReadToEnd().Trim();
7. }
"""; return prompts;
}
private const string HealthDataPrompt = """ private static string NormalizePath(string path) =>
path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
1. ////
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.
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 OnboardingPrompt = """
1//尿/
2PCI支架植入///
3////
4/尿//
5///
- 2-4
- manage_archive
- "跳过"/"以后再说"/"再说"/"不用了" "好的,随时可以跟我说'完善档案'来继续"
-
- 怀
-
""";
} }

View File

@@ -0,0 +1,150 @@
using Health.Application.Admin;
namespace Health.Infrastructure.Admin;
public sealed class AdminService(AppDbContext db) : IAdminService
{
private readonly AppDbContext _db = db;
public async Task<AdminResult> ListDoctorsAsync(CancellationToken ct)
{
var doctors = await _db.Doctors.AsNoTracking().OrderBy(x => x.CreatedAt)
.Select(x => new { x.Id, x.Name, x.Title, x.Department, x.Phone, x.ProfessionalDirection, x.IsActive, x.AvatarUrl, x.Introduction, x.CreatedAt })
.ToListAsync(ct);
return Ok(doctors);
}
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
{
var phone = command.Phone.Trim();
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
{
Id = Guid.NewGuid(), Name = name, Title = command.Title?.Trim(), Department = command.Department?.Trim(),
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.Users.Add(user);
_db.DoctorProfiles.Add(new DoctorProfile
{
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = name,
Title = doctor.Title, Department = doctor.Department, IsActive = true,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
});
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.Name });
}
public async Task<AdminResult> UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
var profile = await _db.DoctorProfiles.Include(x => x.User)
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
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);
return Ok(new { doctor.Id });
}
public async Task<AdminResult> ToggleDoctorAsync(Guid id, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
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;
profile.IsActive = doctor.IsActive;
profile.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.IsActive });
}
public async Task<AdminResult> DeleteDoctorAsync(Guid id, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
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);
await _db.SaveChangesAsync(ct);
return Ok(new { success = true });
}
public async Task<AdminResult> ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 100);
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
if (!string.IsNullOrWhiteSpace(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 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 })
.ToListAsync(ct);
return Ok(new { patients, total, page, pageSize });
}
private static AdminResult Ok(object data) => new(0, data);
private static AdminResult Error(int code, string message) => new(code, null, message);
}

View File

@@ -0,0 +1,217 @@
using Health.Application.Auth;
using Health.Infrastructure.Services;
namespace Health.Infrastructure.Auth;
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 AdminSmsCode = "000000";
private static readonly Guid AdminId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private readonly AppDbContext _db = db;
private readonly JwtProvider _jwt = jwt;
private readonly SmsService _sms = sms;
private readonly AppleTokenValidator _appleValidator = appleValidator;
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
{
// 60 秒冷却AdminPhone 豁免)
if (phone != AdminPhone)
{
var last = await _db.VerificationCodes
.Where(x => x.Phone == phone)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (last != null && DateTime.UtcNow - last.CreatedAt < TimeSpan.FromSeconds(60))
return Error(40009, "请求过于频繁,请 1 分钟后重试");
}
var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode();
await _db.VerificationCodes.AddAsync(new VerificationCode
{
Id = Guid.NewGuid(), Phone = phone, Code = code,
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
}, ct);
await _db.SaveChangesAsync(ct);
if (phone != AdminPhone)
{
var sent = await _sms.SendCodeAsync(phone, code);
if (!sent) return Error(40010, "短信发送失败,请稍后重试");
}
return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null });
}
public async Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct)
{
var code = await TakeCodeAsync(command.Phone, command.SmsCode, ct);
if (code == null) return Error(40001, "验证码错误或已过期");
if (await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct)) return Error(40002, "该手机号已注册,请直接登录");
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40003, "请输入姓名");
if (!await _db.Doctors.AnyAsync(x => x.Id == command.DoctorId && x.IsActive, ct)) return Error(40004, "所选医生不存在或已停诊");
code.IsUsed = true;
var user = new User
{
Id = Guid.NewGuid(), Phone = command.Phone, Role = "User", Name = command.Name,
DoctorId = command.DoctorId, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
_db.Users.Add(user);
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id });
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id });
var tokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { user.Id, user.Phone, user.Role, isNew = true } });
}
public async Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct)
{
var code = await TakeCodeAsync(phone, smsCode, ct);
if (code == null) return Error(40001, "验证码错误或已过期");
code.IsUsed = true;
if (phone == AdminPhone)
{
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { id = AdminId, phone = AdminPhone, role = "Admin", name = "管理员", isNew = false } });
}
var user = await _db.Users.FirstOrDefaultAsync(x => x.Phone == phone, ct);
if (user == null) return Error(40004, "该手机号未注册,请先注册");
user.UpdatedAt = DateTime.UtcNow;
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
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"), isNew = false }
});
}
public async Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct)
{
var oldToken = await _db.RefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken && !x.IsRevoked, ct);
if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow) return Error(40002, "登录已过期,请重新登录");
oldToken.IsRevoked = true;
if (oldToken.UserId == AdminId)
{
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct);
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);
if (user == null) return Error(40002, "用户不存在");
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
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)
{
var token = await _db.RefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken, ct);
if (token != null) token.IsRevoked = true;
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) =>
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
{
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
var refresh = _jwt.GenerateRefreshToken();
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
return (access, refresh);
}
private static AuthResult Error(int code, string message) => new(code, null, message);
}

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