Compare commits
39 Commits
aa44d6f0f0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4bc15b9b9 | ||
|
|
25a4078688 | ||
|
|
32aae40b12 | ||
|
|
28f704c98e | ||
|
|
0cb5b8e85a | ||
|
|
9cea41705e | ||
|
|
0d4fd88ce7 | ||
|
|
ae94ced2d5 | ||
|
|
e1f4a4b91f | ||
|
|
6e5d3e64cd | ||
|
|
528859686f | ||
|
|
fade61ac21 | ||
|
|
e654c1e0cc | ||
|
|
335a3e6440 | ||
|
|
1a9d56b07c | ||
|
|
e52e21d295 | ||
| ec5af19d30 | |||
|
|
bd4350c17f | ||
|
|
fe4c81c54f | ||
|
|
d82e006cf4 | ||
|
|
1c020b8ae5 | ||
|
|
7a93237069 | ||
|
|
4507083f3f | ||
|
|
a748c316f2 | ||
|
|
fa2cc994fa | ||
|
|
df37076c46 | ||
|
|
86c7957144 | ||
|
|
18df2f3285 | ||
|
|
53ce19e8c3 | ||
|
|
7ff429e071 | ||
|
|
73d99d56f6 | ||
|
|
80540e2191 | ||
|
|
6ce010e46a | ||
|
|
431b72d49a | ||
|
|
a479e5e95d | ||
|
|
a7fb6e5ff3 | ||
|
|
415c7ca082 | ||
|
|
13714d9ed8 | ||
|
|
b57d0d16f4 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -4,9 +4,17 @@
|
||||
*.key
|
||||
*.pfx
|
||||
|
||||
# Android release keystore
|
||||
health_app/android/**/*.jks
|
||||
health_app/android/key.properties
|
||||
|
||||
# .NET build outputs
|
||||
backend/**/bin/
|
||||
backend/**/obj/
|
||||
backend/artifacts/
|
||||
|
||||
# Generated local audit reports
|
||||
audit/*.html
|
||||
|
||||
# PostgreSQL local data
|
||||
backend/pgdata/
|
||||
@@ -42,6 +50,7 @@ backend/src/Health.WebApi/data-protection-keys/
|
||||
*.txt
|
||||
*.jpg
|
||||
*.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
|
||||
|
||||
57
AGENTS.md
Normal file
57
AGENTS.md
Normal 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. 执行后进行完整验证并报告结果。
|
||||
|
||||
用户在具体任务中的直接指令始终优先于本文件。
|
||||
1
audit/2026-07-13/window.xml
Normal file
1
audit/2026-07-13/window.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/01-current.xml
Normal file
1
audit/2026-07-14/01-current.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/02-drawer.xml
Normal file
1
audit/2026-07-14/02-drawer.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/03-health-archive.xml
Normal file
1
audit/2026-07-14/03-health-archive.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/04-reports.xml
Normal file
1
audit/2026-07-14/04-reports.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/05-medications.xml
Normal file
1
audit/2026-07-14/05-medications.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/06-diet-records.xml
Normal file
1
audit/2026-07-14/06-diet-records.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/07-calendar.xml
Normal file
1
audit/2026-07-14/07-calendar.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/08-followups.xml
Normal file
1
audit/2026-07-14/08-followups.xml
Normal 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>
|
||||
1
audit/2026-07-14/09-exercise.xml
Normal file
1
audit/2026-07-14/09-exercise.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/10-devices.xml
Normal file
1
audit/2026-07-14/10-devices.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/11-notifications.xml
Normal file
1
audit/2026-07-14/11-notifications.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/12-settings.xml
Normal file
1
audit/2026-07-14/12-settings.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/13-profile.xml
Normal file
1
audit/2026-07-14/13-profile.xml
Normal file
File diff suppressed because one or more lines are too long
261
audit/2026-07-14/project-audit-report.md
Normal file
261
audit/2026-07-14/project-audit-report.md
Normal 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 签名和商店账号,因此只检查了当前准备缺口。
|
||||
1
audit/2026-07-18-ui/00-current.xml
Normal file
1
audit/2026-07-18-ui/00-current.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/ui-pages/window.xml
Normal file
1
audit/ui-pages/window.xml
Normal file
File diff suppressed because one or more lines are too long
@@ -9,13 +9,21 @@ JWT_AUDIENCE=health-manager-app
|
||||
# DeepSeek LLM
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
DEEPSEEK_API_KEY=sk-your-key-here
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
|
||||
# 千问 VLM
|
||||
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
QWEN_API_KEY=sk-your-key-here
|
||||
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_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
|
||||
13
backend/dotnet-tools.json
Normal file
13
backend/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.8",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,15 @@ public sealed record OpenConversationResult(
|
||||
public interface IAiConversationService
|
||||
{
|
||||
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
|
||||
Task AddUserMessageAsync(Guid conversationId, string content, 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 DeleteAsync(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
|
||||
@@ -45,6 +48,7 @@ public interface IAiConversationRepository
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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(
|
||||
@@ -33,14 +34,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
||||
};
|
||||
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, CancellationToken ct) =>
|
||||
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct);
|
||||
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, 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)
|
||||
{
|
||||
@@ -50,8 +55,12 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
||||
|
||||
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.Select(ToDto).ToList();
|
||||
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||
@@ -63,16 +72,40 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
||||
return messages.Select(ToMessageDto).ToList();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||
public async Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct)
|
||||
{
|
||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||
if (conversation == null) return;
|
||||
var conversation = await _conversations.GetAsync(conversationId, ct)
|
||||
?? throw new InvalidOperationException("会话不存在");
|
||||
var normalized = summary.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized)) return;
|
||||
|
||||
_conversations.Delete(conversation);
|
||||
conversation.Summary = normalized.Length > 24 ? normalized[..24] : normalized;
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken 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("会话不存在");
|
||||
@@ -82,6 +115,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
||||
ConversationId = conversationId,
|
||||
Role = role,
|
||||
Content = content,
|
||||
MetadataJson = metadataJson,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, ct);
|
||||
|
||||
@@ -90,6 +124,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
||||
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(
|
||||
|
||||
24
backend/src/Health.Application/AI/AiEntryDraftContracts.cs
Normal file
24
backend/src/Health.Application/AI/AiEntryDraftContracts.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
35
backend/src/Health.Application/AI/DietCommentaryPolicy.cs
Normal file
35
backend/src/Health.Application/AI/DietCommentaryPolicy.cs
Normal 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 中的饮食建议助手。根据用户本餐食物和健康档案,直接输出2到3条简短建议。
|
||||
每条建议12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 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;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,42 @@
|
||||
using System.Text;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
using Health.Application.Medications;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed class PatientContextService(
|
||||
IHealthArchiveService archives,
|
||||
IHealthRecordService healthRecords,
|
||||
IMedicationService medications) : IPatientContextService
|
||||
IHealthRecordService healthRecords) : IPatientContextService
|
||||
{
|
||||
private readonly IHealthArchiveService _archives = archives;
|
||||
private readonly IHealthRecordService _healthRecords = healthRecords;
|
||||
private readonly IMedicationService _medications = medications;
|
||||
|
||||
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 activeMedications = await _medications.ListActiveAsync(userId, ct);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (archive != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||
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 (activeMedications.Count > 0)
|
||||
{
|
||||
sb.AppendLine("当前用药:");
|
||||
foreach (var medication in activeMedications.Take(20))
|
||||
{
|
||||
var times = medication.TimeOfDay.Count > 0
|
||||
? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm")))
|
||||
: "未设置时间";
|
||||
sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}");
|
||||
}
|
||||
}
|
||||
|
||||
if (recentRecords.Count > 0)
|
||||
{
|
||||
sb.AppendLine("近期健康数据:");
|
||||
foreach (var record in recentRecords)
|
||||
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})");
|
||||
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({ToBeijing(record.RecordedAt):MM-dd HH:mm} 北京时间)");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
@@ -62,4 +51,15 @@ public sealed class PatientContextService(
|
||||
"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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ 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);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
||||
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
|
||||
.ToList();
|
||||
var followUps = snapshot.FollowUps
|
||||
.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)
|
||||
.Where(f => BeijingDate(f.ScheduledAt) == date)
|
||||
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
|
||||
.ToList();
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
||||
entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate }));
|
||||
}
|
||||
|
||||
foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date))
|
||||
foreach (var followUp in snapshot.FollowUps.Where(f => BeijingDate(f.ScheduledAt) == date))
|
||||
{
|
||||
entries.Add(new CalendarEntry("followup", new
|
||||
{
|
||||
@@ -88,5 +88,14 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
||||
&& (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);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Health.Domain;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
@@ -5,6 +6,7 @@ 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)
|
||||
@@ -18,6 +20,15 @@ public sealed class DietService(IDietRepository diets) : IDietService
|
||||
|
||||
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(),
|
||||
@@ -34,7 +45,7 @@ public sealed class DietService(IDietRepository diets) : IDietService
|
||||
record.FoodItems.Add(new DietFoodItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = item.Name,
|
||||
Name = item.Name.Trim(),
|
||||
Portion = item.Portion,
|
||||
Calories = item.Calories,
|
||||
SortOrder = item.SortOrder,
|
||||
@@ -61,12 +72,27 @@ public sealed class DietService(IDietRepository diets) : IDietService
|
||||
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(),
|
||||
|
||||
@@ -2,12 +2,6 @@ using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Exercises;
|
||||
|
||||
public sealed record ExercisePlanItemInput(
|
||||
DateOnly ScheduledDate,
|
||||
string? ExerciseType,
|
||||
int DurationMinutes,
|
||||
bool IsRestDay);
|
||||
|
||||
public sealed record ExercisePlanCreateRequest(
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
@@ -47,21 +41,20 @@ public interface IExerciseService
|
||||
{
|
||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
||||
Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, 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);
|
||||
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, 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<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task AddAsync(ExercisePlan plan, CancellationToken ct);
|
||||
void Delete(ExercisePlan plan);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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;
|
||||
|
||||
@@ -17,6 +19,8 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
|
||||
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)
|
||||
@@ -29,39 +33,35 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType,
|
||||
DurationMinutes = duration, IsRestDay = false,
|
||||
Id = Guid.NewGuid(),
|
||||
ScheduledDate = date,
|
||||
ExerciseType = exerciseType,
|
||||
DurationMinutes = duration,
|
||||
IsRestDay = false,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct)
|
||||
|
||||
// 时长上限校验:下限交由各方法兜底为 30,这里只拦截不合理的超大值
|
||||
private static void ValidateDuration(int durationMinutes)
|
||||
{
|
||||
var normalizedEnd = endDate < startDate ? startDate : endDate;
|
||||
var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime);
|
||||
foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First()))
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = item.ScheduledDate,
|
||||
ExerciseType = string.IsNullOrWhiteSpace(item.ExerciseType) ? "散步" : item.ExerciseType.Trim(),
|
||||
DurationMinutes = item.DurationMinutes > 0 ? item.DurationMinutes : 30,
|
||||
IsRestDay = item.IsRestDay,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
return plan.Id;
|
||||
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(p => new ExercisePlanSummaryDto(
|
||||
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt,
|
||||
p.Items.Count, p.Items.Count(i => i.IsCompleted),
|
||||
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList();
|
||||
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)
|
||||
@@ -83,6 +83,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
{
|
||||
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;
|
||||
@@ -94,6 +98,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
{
|
||||
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;
|
||||
@@ -101,12 +109,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetLatestAsync(userId, ct);
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
|
||||
{
|
||||
await _exercises.AddAsync(plan, ct);
|
||||
@@ -115,15 +117,24 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
|
||||
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate,
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
ReminderTime = reminderTime ?? DefaultReminderTime,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
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);
|
||||
|
||||
@@ -2,16 +2,27 @@ 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(
|
||||
@@ -21,12 +32,13 @@ public sealed record HealthArchiveUpdateRequest(
|
||||
IReadOnlyList<string>? Allergies,
|
||||
IReadOnlyList<string>? DietRestrictions,
|
||||
IReadOnlyList<string>? ChronicDiseases,
|
||||
string? FamilyHistory);
|
||||
string? FamilyHistory,
|
||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null,
|
||||
string? SurgeryHistoryStatus = null);
|
||||
|
||||
public interface IHealthArchiveService
|
||||
{
|
||||
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
|
||||
Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct);
|
||||
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||
Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Health.Application.HealthArchives;
|
||||
|
||||
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
||||
@@ -12,15 +14,9 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
return archive == null ? null : ToDto(archive);
|
||||
}
|
||||
|
||||
public async Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||
await _archives.SaveChangesAsync(ct);
|
||||
return 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;
|
||||
@@ -42,7 +38,8 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
"update_diagnosis" => request with
|
||||
{
|
||||
SurgeryType = null, SurgeryDate = null, Allergies = null,
|
||||
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null
|
||||
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null,
|
||||
Surgeries = null
|
||||
},
|
||||
"update_surgery" => request with
|
||||
{
|
||||
@@ -77,12 +74,53 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request)
|
||||
{
|
||||
if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis;
|
||||
if (request.SurgeryType != null) archive.SurgeryType = request.SurgeryType;
|
||||
if (request.SurgeryDate.HasValue) archive.SurgeryDate = request.SurgeryDate.Value;
|
||||
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
|
||||
@@ -91,10 +129,12 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
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(
|
||||
@@ -103,9 +143,42 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ 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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Health.Domain;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.HealthRecords;
|
||||
@@ -8,9 +9,52 @@ public static class HealthRecordRules
|
||||
{
|
||||
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 >= 7.0m || request.Value <= 3.8m,
|
||||
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} 不合理");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
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) : IHealthRecordService
|
||||
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)
|
||||
{
|
||||
@@ -20,6 +26,8 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
HealthRecordRules.Validate(request);
|
||||
|
||||
var record = new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -37,14 +45,55 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
|
||||
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;
|
||||
@@ -55,6 +104,7 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
record.IsAbnormal = HealthRecordRules.CheckAbnormal(request);
|
||||
|
||||
await _records.SaveChangesAsync(ct);
|
||||
await EnqueueAbnormalNotificationAsync(record, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -89,6 +139,7 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
latest.Diastolic,
|
||||
latest.Value,
|
||||
latest.Unit,
|
||||
latest.IsAbnormal,
|
||||
latest.RecordedAt
|
||||
};
|
||||
}
|
||||
@@ -98,9 +149,9 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
|
||||
public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct)
|
||||
{
|
||||
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 };
|
||||
var days = period switch { 7 => 7, 30 => 30, 90 => 90, 365 => 365, _ => 7 };
|
||||
var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct);
|
||||
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
|
||||
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
|
||||
}
|
||||
|
||||
public static HealthRecordDto ToDto(HealthRecord record) => new(
|
||||
@@ -113,4 +164,42 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ public sealed record MedicationPatchRequest(
|
||||
IReadOnlyList<TimeOnly>? TimeOfDay,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
bool ClearEndDate = false);
|
||||
|
||||
public sealed record MedicationDto(
|
||||
Guid Id,
|
||||
@@ -44,6 +45,21 @@ public sealed record MedicationReminderDto(
|
||||
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,
|
||||
@@ -59,12 +75,10 @@ public interface IMedicationService
|
||||
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<bool?> ToggleTodayTakenAsync(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<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationRepository
|
||||
@@ -73,12 +87,9 @@ public interface IMedicationRepository
|
||||
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> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
|
||||
Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task AddMedicationAsync(Medication medication, CancellationToken ct);
|
||||
Task AddLogAsync(MedicationLog log, CancellationToken ct);
|
||||
void DeleteMedication(Medication medication);
|
||||
|
||||
@@ -21,10 +21,18 @@ public sealed class MedicationReminderScanner(IMedicationRepository medications)
|
||||
foreach (var medication in active)
|
||||
{
|
||||
if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue;
|
||||
if (await _medications.HasTakenInWindowAsync(medication.UserId, medication.Id, todayStartUtc, todayEndUtc, ct)) continue;
|
||||
|
||||
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time)))
|
||||
{
|
||||
if (await _medications.DoseLogExistsAsync(
|
||||
medication.UserId,
|
||||
medication.Id,
|
||||
scheduledTime,
|
||||
todayStartUtc,
|
||||
todayEndUtc,
|
||||
ct))
|
||||
continue;
|
||||
|
||||
tasks.Add(new MedicationReminderTask(
|
||||
Guid.NewGuid(),
|
||||
medication.UserId,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Health.Domain;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
@@ -16,14 +17,19 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
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 = request.Name,
|
||||
Name = name,
|
||||
Dosage = request.Dosage,
|
||||
Frequency = request.Frequency,
|
||||
TimeOfDay = request.TimeOfDay.Count > 0 ? request.TimeOfDay.ToList() : [new TimeOnly(8, 0)],
|
||||
TimeOfDay = times,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
Source = request.Source,
|
||||
@@ -43,12 +49,14 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
|
||||
if (med == null) return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name)) med.Name = request.Name;
|
||||
if (request.Name != null) med.Name = ValidateName(request.Name);
|
||||
if (request.Dosage != null) med.Dosage = request.Dosage;
|
||||
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
|
||||
if (request.TimeOfDay != null) med.TimeOfDay = request.TimeOfDay.ToList();
|
||||
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
|
||||
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
||||
if (request.EndDate.HasValue) med.EndDate = request.EndDate.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;
|
||||
|
||||
@@ -56,6 +64,28 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
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);
|
||||
@@ -66,23 +96,6 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
{
|
||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
||||
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct);
|
||||
if (existing != null)
|
||||
{
|
||||
_medications.DeleteLog(existing);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||
@@ -101,15 +114,10 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
foreach (var scheduledTime in med.TimeOfDay)
|
||||
{
|
||||
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
|
||||
string status;
|
||||
if (log != null)
|
||||
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
|
||||
else if (scheduledTime <= now.AddMinutes(-30))
|
||||
status = "overdue";
|
||||
else if (scheduledTime <= now.AddHours(3))
|
||||
status = "upcoming";
|
||||
else
|
||||
continue;
|
||||
var status = MedicationScheduleStatus.Resolve(
|
||||
scheduledTime,
|
||||
now,
|
||||
log?.Status);
|
||||
|
||||
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
|
||||
}
|
||||
@@ -122,7 +130,14 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
|
||||
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
|
||||
{
|
||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
||||
var medication = await _medications.GetOwnedAsync(userId, medicationId, ct);
|
||||
if (medication == null) return null;
|
||||
|
||||
var today = BeijingToday();
|
||||
if (!IsActiveOn(medication, today) ||
|
||||
!GetDosesForToday(medication, today) ||
|
||||
!medication.TimeOfDay.Contains(scheduledTime))
|
||||
return false;
|
||||
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
|
||||
@@ -143,18 +158,48 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) =>
|
||||
ListAsync(userId, "active", ct);
|
||||
|
||||
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
public async Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(
|
||||
Guid userId,
|
||||
DateOnly date,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await ConfirmDoseAsync(
|
||||
userId,
|
||||
medicationId,
|
||||
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
MedicationLogStatus.Taken,
|
||||
ct);
|
||||
return result == true;
|
||||
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)
|
||||
@@ -192,9 +237,28 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
|
||||
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
|
||||
{
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
return (todayStartUtc, todayStartUtc.AddDays(1));
|
||||
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)
|
||||
|
||||
@@ -5,22 +5,63 @@ public sealed record InAppNotificationDto(
|
||||
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 Payload,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Application.Notifications;
|
||||
|
||||
public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService
|
||||
@@ -12,36 +10,22 @@ public sealed class InAppNotificationService(IInAppNotificationRepository notifi
|
||||
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);
|
||||
|
||||
private static InAppNotificationDto ToDto(InAppNotificationRecord record)
|
||||
{
|
||||
if (record.Type == "MedicationReminder")
|
||||
{
|
||||
using var json = JsonDocument.Parse(record.Payload);
|
||||
var root = json.RootElement;
|
||||
var name = root.TryGetProperty("Name", out var nameValue) ? nameValue.GetString() : null;
|
||||
var dosage = root.TryGetProperty("Dosage", out var dosageValue) ? dosageValue.GetString() : null;
|
||||
var time = root.TryGetProperty("ScheduledTime", out var timeValue) ? timeValue.GetString() : null;
|
||||
var details = string.Join(" · ", new[] { dosage, time }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
return new InAppNotificationDto(
|
||||
record.Id,
|
||||
record.Type,
|
||||
"用药提醒",
|
||||
$"该服用{name ?? "药物"}{(details.Length > 0 ? $"({details})" : "")}了",
|
||||
record.CreatedAt);
|
||||
}
|
||||
public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
|
||||
_notifications.MarkAllReadAsync(userId, ct);
|
||||
|
||||
if (record.Type == "ExerciseReminder")
|
||||
{
|
||||
using var json = JsonDocument.Parse(record.Payload);
|
||||
var root = json.RootElement;
|
||||
var type = root.TryGetProperty("ExerciseType", out var typeValue) ? typeValue.GetString() : "运动";
|
||||
var minutes = root.TryGetProperty("DurationMinutes", out var minutesValue) ? minutesValue.GetInt32() : 30;
|
||||
return new InAppNotificationDto(record.Id, record.Type, "运动提醒", $"今天的{type}计划还未完成,目标 {minutes} 分钟", record.CreatedAt);
|
||||
}
|
||||
public Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
|
||||
_notifications.DeleteAsync(userId, notificationId, ct);
|
||||
|
||||
return new InAppNotificationDto(record.Id, record.Type, "健康提醒", "您有一条新的健康提醒", record.CreatedAt);
|
||||
}
|
||||
private static InAppNotificationDto ToDto(InAppNotificationRecord record) => new(
|
||||
record.Id, record.Type, record.Title, record.Message, record.Severity,
|
||||
record.ActionType, record.ActionTargetId, record.IsRead, record.CreatedAt);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ public sealed class ReportService(
|
||||
IReportFileStorage fileStorage,
|
||||
IReportAnalysisQueue queue) : IReportService
|
||||
{
|
||||
private const long MaxReportImageBytes = 10 * 1024 * 1024;
|
||||
private const long MaxReportFileBytes = 20 * 1024 * 1024;
|
||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg", ".jpeg", ".png", ".webp"
|
||||
".jpg", ".jpeg", ".png", ".webp", ".pdf"
|
||||
};
|
||||
|
||||
private readonly IReportRepository _reports = reports;
|
||||
@@ -35,12 +35,12 @@ public sealed class ReportService(
|
||||
if (file.Length <= 0)
|
||||
return Fail(400, "未上传文件");
|
||||
|
||||
if (file.Length > MaxReportImageBytes)
|
||||
return Fail(400, "报告图片不能超过 10MB,请压缩后重新上传");
|
||||
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 格式的报告图片");
|
||||
return Fail(400, "目前支持上传 JPG、PNG、WEBP 图片或 PDF 报告");
|
||||
|
||||
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
|
||||
var report = new Report
|
||||
@@ -48,7 +48,7 @@ public sealed class ReportService(
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
FileUrl = storedFile.FileUrl,
|
||||
FileType = ReportFileType.Image,
|
||||
FileType = ext == ".pdf" ? ReportFileType.Pdf : ReportFileType.Image,
|
||||
Category = ReportCategory.Other,
|
||||
Status = ReportStatus.Analyzing,
|
||||
AiSummary = null,
|
||||
@@ -94,7 +94,7 @@ public sealed class ReportService(
|
||||
public static ReportDto ToDto(Report report) => new(
|
||||
report.Id,
|
||||
report.UserId,
|
||||
report.FileUrl,
|
||||
$"/api/reports/{report.Id}/file",
|
||||
report.FileType.ToString(),
|
||||
report.Category.ToString(),
|
||||
report.Status.ToString(),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace Health.Application.Speech;
|
||||
|
||||
public interface IRealtimeSpeechRecognitionProxy
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
|
||||
Task ProxyAsync(WebSocket clientSocket, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace Health.Application.Users;
|
||||
|
||||
public sealed record UserProfileDto(
|
||||
Guid Id,
|
||||
string Phone,
|
||||
string? Phone, // Apple 用户无手机号
|
||||
string Role,
|
||||
string? Name,
|
||||
string? Gender,
|
||||
@@ -14,7 +14,12 @@ public sealed record UserProfileDto(
|
||||
public sealed record UserProfileUpdateRequest(
|
||||
string? Name,
|
||||
string? Gender,
|
||||
DateOnly? BirthDate);
|
||||
DateOnly? BirthDate,
|
||||
string? AvatarUrl);
|
||||
|
||||
public sealed record AccountFileReferences(
|
||||
IReadOnlyList<string> FileUrls,
|
||||
IReadOnlyList<string> ConversationMetadataJson);
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
@@ -26,6 +31,12 @@ public interface IUserService
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
namespace Health.Application.Users;
|
||||
|
||||
public sealed class UserService(IUserRepository users) : IUserService
|
||||
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)
|
||||
{
|
||||
@@ -25,11 +28,16 @@ public sealed class UserService(IUserRepository users) : IUserService
|
||||
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 Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
|
||||
_users.DeleteAccountDataAsync(userId, ct);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,22 @@ public sealed class HealthArchive
|
||||
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
||||
public List<string> ChronicDiseases { get; set; } = []; // 慢病史
|
||||
public string? FamilyHistory { get; set; } // 家族病史
|
||||
public string? SurgeryHistoryStatus { get; set; } // 无/有,区分未填写与无手术史
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
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>
|
||||
@@ -77,6 +90,17 @@ public sealed class NotificationPreference
|
||||
public bool FollowUpReminder { get; set; } = true;
|
||||
public bool DoctorReply { 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 User User { get; set; } = null!;
|
||||
@@ -97,3 +121,20 @@ public sealed class DeviceToken
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ namespace Health.Domain.Entities;
|
||||
public sealed class User
|
||||
{
|
||||
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; }
|
||||
|
||||
7
backend/src/Health.Domain/ValidationException.cs
Normal file
7
backend/src/Health.Domain/ValidationException.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Health.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// 业务输入校验失败异常——由 Application 层规则抛出,
|
||||
/// 中间件统一映射为 400 / code 40001,message 可安全展示给用户。
|
||||
/// </summary>
|
||||
public sealed class ValidationException(string message) : Exception(message);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -8,12 +8,47 @@ namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_health_records", Description = "查询近期健康数据",
|
||||
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } }
|
||||
Name = "query_health_records",
|
||||
Description = "查询近期健康数据",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight", "all" }, description = "健康指标类型" },
|
||||
scope = new { type = "string", @enum = new[] { "today", "yesterday", "date", "recent_days", "range" }, description = "日期范围,默认 recent_days" },
|
||||
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
|
||||
start_date = new { type = "string", description = "scope=range 时的北京时间开始日期 yyyy-MM-dd" },
|
||||
end_date = new { type = "string", description = "scope=range 时的北京时间结束日期 yyyy-MM-dd(包含)" },
|
||||
days = new { type = "integer", description = "scope=recent_days 时的天数,1到365" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,10 +77,67 @@ public static class CommonAgentHandler
|
||||
|
||||
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
||||
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
|
||||
return new { count = records.Count, records };
|
||||
var rawType = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
if (!TryNormalizeMetricType(rawType, out var metricType))
|
||||
return new { success = false, message = $"无法识别健康指标类型: {rawType}" };
|
||||
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant()
|
||||
: "recent_days";
|
||||
DateTime? startUtc = null;
|
||||
DateTime? endUtc = null;
|
||||
int days;
|
||||
|
||||
switch (scope)
|
||||
{
|
||||
case "today":
|
||||
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
|
||||
days = 2;
|
||||
break;
|
||||
case "yesterday":
|
||||
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday.AddDays(-1));
|
||||
days = 3;
|
||||
break;
|
||||
case "date":
|
||||
if (!TryReadDate(args, "date", out var date))
|
||||
return new { success = false, message = "查询指定日期时必须提供有效的 date" };
|
||||
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
|
||||
days = DaysSince(startUtc.Value);
|
||||
break;
|
||||
case "range":
|
||||
if (!TryReadDate(args, "start_date", out var startDate) ||
|
||||
!TryReadDate(args, "end_date", out var endDate) || endDate < startDate)
|
||||
return new { success = false, message = "必须提供有效的 start_date 和 end_date,且结束日期不能早于开始日期" };
|
||||
startUtc = AiDateTime.BeijingDateWindowUtc(startDate).StartUtc;
|
||||
endUtc = AiDateTime.BeijingDateWindowUtc(endDate).EndUtc;
|
||||
days = DaysSince(startUtc.Value);
|
||||
break;
|
||||
default:
|
||||
days = args.TryGetProperty("days", out var d) && d.TryGetInt32(out var parsedDays)
|
||||
? Math.Clamp(parsedDays, 1, 365)
|
||||
: 7;
|
||||
scope = "recent_days";
|
||||
break;
|
||||
}
|
||||
|
||||
var records = await healthRecords.GetRecordsAsync(userId, metricType, days, ct);
|
||||
var filtered = records
|
||||
.Where(record => !startUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) >= startUtc.Value)
|
||||
.Where(record => !endUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) < endUtc.Value)
|
||||
.Select(record => new
|
||||
{
|
||||
record.Id,
|
||||
record.Type,
|
||||
record.Systolic,
|
||||
record.Diastolic,
|
||||
record.Value,
|
||||
record.Unit,
|
||||
record.Source,
|
||||
record.IsAbnormal,
|
||||
recorded_at_beijing = AiDateTime.ToBeijingText(record.RecordedAt),
|
||||
})
|
||||
.ToList();
|
||||
return new { success = true, scope, count = filtered.Count, records = filtered };
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
|
||||
@@ -54,55 +146,39 @@ public static class CommonAgentHandler
|
||||
if (archive == null) return new { found = false };
|
||||
return new
|
||||
{
|
||||
found = true, archive.Diagnosis, archive.SurgeryType,
|
||||
found = true,
|
||||
archive.Diagnosis,
|
||||
archive.SurgeryType,
|
||||
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 = "管理用户健康档案",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" },
|
||||
diagnosis = new { type = "string" },
|
||||
surgery_type = new { type = "string" },
|
||||
surgery_date = new { type = "string" },
|
||||
allergies = new { type = "array", items = new { type = "string" } },
|
||||
chronic_diseases = new { type = "array", items = new { type = "string" } },
|
||||
diet_restrictions = new { type = "array", items = new { type = "string" } },
|
||||
},
|
||||
required = new[] { "action" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static Task<object> ExecuteManageArchive(
|
||||
IHealthArchiveService archives,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
var request = new HealthArchiveUpdateRequest(
|
||||
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
|
||||
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
|
||||
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
|
||||
ReadStringArray(args, "allergies"),
|
||||
ReadStringArray(args, "diet_restrictions"),
|
||||
ReadStringArray(args, "chronic_diseases"),
|
||||
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
|
||||
return archives.ExecuteAiActionAsync(userId, action, request, ct);
|
||||
null or "" or "all" or "全部" => null,
|
||||
"bloodpressure" or "blood_pressure" or "血压" => "BloodPressure",
|
||||
"heartrate" or "heart_rate" or "心率" or "脉搏" => "HeartRate",
|
||||
"glucose" or "blood_glucose" or "血糖" => "Glucose",
|
||||
"spo2" or "blood_oxygen" or "血氧" or "血氧饱和度" => "SpO2",
|
||||
"weight" or "体重" => "Weight",
|
||||
_ => "__invalid__",
|
||||
};
|
||||
return normalized != "__invalid__";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
|
||||
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
|
||||
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
|
||||
: null;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ public static class ExerciseAgentHandler
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "create/query/checkin" },
|
||||
action = new { type = "string", @enum = new[] { "create", "query", "checkin" }, description = "create/query/checkin" },
|
||||
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "all_plans" }, description = "查询时必填:今日任务/指定日期任务/当前计划/即将开始/已结束/全部计划" },
|
||||
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
|
||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd,默认今天" },
|
||||
duration_days = new { type = "integer", description = "连续运动天数,如一周为7天" },
|
||||
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
|
||||
@@ -30,20 +32,50 @@ public static class ExerciseAgentHandler
|
||||
|
||||
public static List<ToolDefinition> Tools => [ManageExerciseTool];
|
||||
|
||||
public static string? ValidateWriteArguments(JsonElement args)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var actionValue)
|
||||
? actionValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (action != "create") return null;
|
||||
|
||||
if (!args.TryGetProperty("exercise_type", out var typeValue) ||
|
||||
string.IsNullOrWhiteSpace(typeValue.GetString()))
|
||||
return "创建运动计划前需要确认运动项目";
|
||||
if (!args.TryGetProperty("duration_minutes", out var minutesValue) ||
|
||||
!minutesValue.TryGetInt32(out var minutes) || minutes is <= 0 or > 300)
|
||||
return "创建运动计划前需要确认合理的单次运动时长";
|
||||
if (!args.TryGetProperty("duration_days", out var daysValue) ||
|
||||
!daysValue.TryGetInt32(out var days) || days is <= 0 or > 366)
|
||||
return "创建运动计划前需要确认计划持续天数";
|
||||
if (!args.TryGetProperty("start_date", out var startValue) ||
|
||||
!DateOnly.TryParse(startValue.GetString(), out _))
|
||||
return "创建运动计划前需要确认开始日期";
|
||||
if (!args.TryGetProperty("reminder_time", out var reminderValue) ||
|
||||
!TimeOnly.TryParse(reminderValue.GetString(), out _))
|
||||
return "创建运动计划前需要确认具体提醒时间";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
AppDbContext db,
|
||||
Guid userId,
|
||||
IExerciseService? exercises = null,
|
||||
IExerciseService exercises,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (toolName != "manage_exercise" || exercises == null)
|
||||
if (toolName != "manage_exercise")
|
||||
return new { success = false, message = $"未知工具: {toolName}" };
|
||||
|
||||
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
|
||||
var action = args.TryGetProperty("action", out var actionValue)
|
||||
? actionValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (action == "create")
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
|
||||
? parsedStart
|
||||
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
@@ -67,17 +99,111 @@ public static class ExerciseAgentHandler
|
||||
return success ? new { success = true } : new { success = false, message = "条目不存在" };
|
||||
}
|
||||
|
||||
var plan = await exercises.GetLatestAsync(userId, ct);
|
||||
return plan == null
|
||||
? new { found = false }
|
||||
: new
|
||||
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
|
||||
{
|
||||
found = true,
|
||||
plan_id = plan.Id,
|
||||
plan.StartDate,
|
||||
plan.EndDate,
|
||||
plan.ReminderTime,
|
||||
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
|
||||
success = false,
|
||||
message = "查询运动计划时必须明确 scope:today、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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,37 +7,162 @@ namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
/// </summary>
|
||||
public static class HealthDataAgentHandler
|
||||
{
|
||||
private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"];
|
||||
|
||||
public static readonly ToolDefinition RecordHealthDataTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
|
||||
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" } }
|
||||
Name = "record_health_data",
|
||||
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, IHealthRecordService? healthRecords = null)
|
||||
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
|
||||
{
|
||||
"record_health_data" => healthRecords == null
|
||||
? await ExecuteRecordHealthData(db, userId, args)
|
||||
: await ExecuteRecordHealthData(healthRecords, userId, args),
|
||||
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args, ct),
|
||||
"record_health_data_batch" => await ExecuteRecordHealthDataBatch(healthRecords, userId, args, ct),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
|
||||
private static async Task<object> ExecuteRecordHealthData(
|
||||
IHealthRecordService healthRecords,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||
var (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) && ra.TryGetDateTime(out var dt)
|
||||
? dt
|
||||
var recordedAt = args.TryGetProperty("recorded_at", out var ra) &&
|
||||
AiDateTime.TryParseUserDateTime(ra.GetString(), out var parsedRecordedAt)
|
||||
? parsedRecordedAt
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var request = new HealthRecordUpsertRequest(
|
||||
@@ -49,7 +174,7 @@ public static class HealthDataAgentHandler
|
||||
HealthRecordSource.AiEntry,
|
||||
recordedAt);
|
||||
|
||||
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
|
||||
var id = await healthRecords.CreateAsync(userId, request, ct);
|
||||
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
|
||||
return new
|
||||
{
|
||||
@@ -62,63 +187,75 @@ public static class HealthDataAgentHandler
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
|
||||
private static async Task<object> ExecuteRecordHealthDataBatch(
|
||||
IHealthRecordService healthRecords,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||
var record = new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
|
||||
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var metrics = GetBatchMetrics(args);
|
||||
if (metrics.Count == 0)
|
||||
return new { success = false, message = "当前录入批次没有健康指标" };
|
||||
|
||||
switch (type)
|
||||
var requests = new List<HealthRecordUpsertRequest>(metrics.Count);
|
||||
var previews = new List<Dictionary<string, object?>>(metrics.Count);
|
||||
foreach (var metric in metrics)
|
||||
{
|
||||
case "blood_pressure":
|
||||
record.MetricType = HealthMetricType.BloodPressure;
|
||||
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
|
||||
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
|
||||
record.Unit = "mmHg";
|
||||
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
|
||||
break;
|
||||
case "heart_rate":
|
||||
record.MetricType = HealthMetricType.HeartRate;
|
||||
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
|
||||
record.Unit = "次/分";
|
||||
record.IsAbnormal = record.Value > 100 || record.Value < 60;
|
||||
break;
|
||||
case "glucose":
|
||||
record.MetricType = HealthMetricType.Glucose;
|
||||
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
|
||||
record.Unit = "mmol/L";
|
||||
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
|
||||
break;
|
||||
case "spo2":
|
||||
record.MetricType = HealthMetricType.SpO2;
|
||||
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
|
||||
record.Unit = "%";
|
||||
record.IsAbnormal = record.Value <= 94;
|
||||
break;
|
||||
case "weight":
|
||||
record.MetricType = HealthMetricType.Weight;
|
||||
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
|
||||
record.Unit = "kg";
|
||||
record.IsAbnormal = false;
|
||||
break;
|
||||
default:
|
||||
var validationError = ValidateWriteArguments(metric);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var type = metric.GetProperty("type").GetString()!;
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, metric);
|
||||
if (metricType == null)
|
||||
return new { success = false, message = $"未知指标类型: {type}" };
|
||||
|
||||
var recordedAt = metric.TryGetProperty("recorded_at", out var recordedAtValue) &&
|
||||
AiDateTime.TryParseUserDateTime(recordedAtValue.GetString(), out var parsedRecordedAt)
|
||||
? parsedRecordedAt
|
||||
: DateTime.UtcNow;
|
||||
requests.Add(new HealthRecordUpsertRequest(
|
||||
metricType.Value,
|
||||
systolic,
|
||||
diastolic,
|
||||
value,
|
||||
unit,
|
||||
HealthRecordSource.AiEntry,
|
||||
recordedAt));
|
||||
previews.Add(CreatePreview(metric));
|
||||
}
|
||||
|
||||
db.HealthRecords.Add(record);
|
||||
await db.SaveChangesAsync();
|
||||
var valStr = record.MetricType switch
|
||||
var ids = await healthRecords.CreateManyAsync(userId, requests, ct);
|
||||
return new
|
||||
{
|
||||
HealthMetricType.BloodPressure => $"{record.Systolic}/{record.Diastolic}",
|
||||
_ => record.Value?.ToString() ?? ""
|
||||
success = true,
|
||||
record_ids = ids,
|
||||
items = previews,
|
||||
};
|
||||
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -12,19 +12,23 @@ public static class MedicationAgentHandler
|
||||
Function = new()
|
||||
{
|
||||
Name = "manage_medication",
|
||||
Description = "用药管理(创建/查询/确认服药)",
|
||||
Description = "用药管理(创建计划、按日期查询计划与逐顿服药状态、确认某一顿服药)",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "create/query/confirm" },
|
||||
action = new { type = "string", @enum = new[] { "create", "query", "confirm" }, description = "create/query/confirm" },
|
||||
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "inactive_plans", "all_plans" }, description = "查询时必填:今日安排/指定日期安排/当前计划/即将开始/自然结束/已停用/全部计划" },
|
||||
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
|
||||
medication_id = new { type = "string", description = "药品 ID,确认服药时使用" },
|
||||
scheduled_time = new { type = "string", description = "要确认的计划服药时间 HH:mm" },
|
||||
name = new { type = "string", description = "药品名称" },
|
||||
dosage = new { type = "string", description = "剂量,如 100mg" },
|
||||
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
|
||||
frequency = new { type = "string", @enum = new[] { "Daily", "TwiceDaily", "ThreeTimesDaily", "EveryOtherDay", "Weekly", "AsNeeded" }, description = "服药频率" },
|
||||
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
|
||||
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" }
|
||||
@@ -33,31 +37,27 @@ public static class MedicationAgentHandler
|
||||
};
|
||||
|
||||
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
|
||||
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
AppDbContext db,
|
||||
Guid userId,
|
||||
IMedicationService? medications = null,
|
||||
IMedicationService medications,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"manage_medication" => medications != null
|
||||
? await ExecuteManageMedication(medications, userId, args, ct)
|
||||
: await ExecuteManageMedication(db, userId, args),
|
||||
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
"create" => await CreateMedication(medications, userId, args, ct),
|
||||
"query" => await QueryMedications(medications, userId, ct),
|
||||
"query" => await QueryMedications(medications, userId, args, ct),
|
||||
"confirm" => await ConfirmMedication(medications, userId, args, ct),
|
||||
_ => new { success = false, message = $"未知操作: {action}" }
|
||||
};
|
||||
@@ -65,13 +65,19 @@ public static class MedicationAgentHandler
|
||||
|
||||
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
|
||||
var 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);
|
||||
var endDate = durationDays > 0 ? startDate.AddDays(durationDays) : (DateOnly?)null;
|
||||
// 结束日为包含式(IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
|
||||
DateOnly? endDate = longTerm ? null : startDate.AddDays(durationDays - 1);
|
||||
|
||||
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
|
||||
name,
|
||||
@@ -94,103 +100,146 @@ public static class MedicationAgentHandler
|
||||
time = timeLabels,
|
||||
start_date = startDate.ToString("yyyy-MM-dd"),
|
||||
duration_days = durationDays,
|
||||
long_term = longTerm,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
|
||||
private static async Task<object> QueryMedications(
|
||||
IMedicationService medications,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var meds = await medications.ListActiveAsync(userId, ct);
|
||||
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) };
|
||||
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 "inactive_plans" or "all_plans"))
|
||||
return new
|
||||
{
|
||||
success = false,
|
||||
message = "查询用药时必须明确 scope:today、scheduled_date、current_plans、upcoming_plans、ended_plans、inactive_plans 或 all_plans",
|
||||
};
|
||||
if (scope == "scheduled_date" &&
|
||||
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
|
||||
return new { success = false, message = "查询指定日期的用药安排时必须提供有效的 date" };
|
||||
var date = scope == "scheduled_date"
|
||||
? DateOnly.Parse(args.GetProperty("date").GetString()!)
|
||||
: AiDateTime.BeijingToday;
|
||||
var isDateScope = scope is "today" or "scheduled_date";
|
||||
var overview = await medications.GetAiOverviewAsync(userId, date, ct);
|
||||
var plans = scope switch
|
||||
{
|
||||
"all_plans" => overview.ToList(),
|
||||
"current_plans" => overview.Where(plan => plan.Phase == "active").ToList(),
|
||||
"upcoming_plans" => overview
|
||||
.Where(plan => plan.Phase == "upcoming")
|
||||
.OrderBy(plan => plan.StartDate)
|
||||
.ToList(),
|
||||
"ended_plans" => overview
|
||||
.Where(plan => plan.Phase == "ended")
|
||||
.OrderByDescending(plan => plan.EndDate)
|
||||
.ToList(),
|
||||
"inactive_plans" => overview
|
||||
.Where(plan => plan.Phase == "inactive")
|
||||
.OrderByDescending(plan => plan.EndDate)
|
||||
.ToList(),
|
||||
_ => overview.Where(plan => plan.ScheduledOnDate).ToList(),
|
||||
};
|
||||
var doseStatuses = plans.SelectMany(plan => plan.Doses).ToList();
|
||||
var nextPlan = overview
|
||||
.Where(plan => plan.Phase == "upcoming" && plan.StartDate.HasValue)
|
||||
.OrderBy(plan => plan.StartDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new
|
||||
{
|
||||
query_date = date.ToString("yyyy-MM-dd"),
|
||||
scope,
|
||||
count = plans.Count,
|
||||
all_doses_completed = isDateScope
|
||||
? doseStatuses.Count > 0 && doseStatuses.All(dose => dose.Status == "taken")
|
||||
: (bool?)null,
|
||||
has_upcoming_plans = nextPlan != null,
|
||||
next_start_date = nextPlan?.StartDate?.ToString("yyyy-MM-dd"),
|
||||
days_until_next_start = nextPlan?.StartDate is DateOnly nextStart
|
||||
? nextStart.DayNumber - date.DayNumber
|
||||
: (int?)null,
|
||||
plans,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
||||
var success = await medications.ConfirmNowAsync(userId, medId, ct);
|
||||
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
||||
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 ? "药品不存在" : "该时间不属于今天的服药安排,或这一顿已经记录" };
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||
public static string? ValidateWriteArguments(JsonElement args)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
return action switch
|
||||
var action = args.TryGetProperty("action", out var actionValue)
|
||||
? actionValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (action == "confirm")
|
||||
{
|
||||
"create" => await CreateMedication(db, userId, args),
|
||||
"query" => await QueryMedications(db, userId),
|
||||
"confirm" => await ConfirmMedication(db, userId, args),
|
||||
_ => new { success = false, message = $"未知操作: {action}" }
|
||||
};
|
||||
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 async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||
{
|
||||
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 startDate = ReadStartDate(args);
|
||||
|
||||
var med = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = name,
|
||||
Dosage = dosage,
|
||||
Frequency = frequency,
|
||||
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
|
||||
Source = MedicationSource.AiEntry,
|
||||
IsActive = true,
|
||||
StartDate = startDate,
|
||||
EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null,
|
||||
};
|
||||
db.Medications.Add(med);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
medication_id = med.Id,
|
||||
name = med.Name,
|
||||
dosage = med.Dosage,
|
||||
frequency = med.Frequency.ToString(),
|
||||
time = timeLabels,
|
||||
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
|
||||
duration_days = durationDays,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> QueryMedications(AppDbContext db, Guid userId)
|
||||
{
|
||||
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
|
||||
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
|
||||
return new { count = meds.Count, medications = meds };
|
||||
}
|
||||
|
||||
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||
{
|
||||
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
||||
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == medId && m.UserId == userId);
|
||||
if (med == null) return new { success = false, message = "药品不存在" };
|
||||
|
||||
db.MedicationLogs.Add(new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MedicationId = medId,
|
||||
UserId = userId,
|
||||
Status = MedicationLogStatus.Taken,
|
||||
ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
ConfirmedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return new { success = true };
|
||||
}
|
||||
|
||||
private static MedicationFrequency ReadFrequency(JsonElement args)
|
||||
{
|
||||
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
|
||||
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
|
||||
return Enum.TryParse<MedicationFrequency>(frequencyStr, ignoreCase: true, out var frequency) ? frequency : MedicationFrequency.Daily;
|
||||
}
|
||||
|
||||
private static DateOnly ReadStartDate(JsonElement args)
|
||||
|
||||
@@ -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 = "查询复查安排时必须明确 scope:next、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] + "…";
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
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;
|
||||
@@ -15,6 +18,9 @@ public sealed class AiToolExecutionService(
|
||||
IHealthRecordService healthRecords,
|
||||
IExerciseService exercises,
|
||||
IMedicationService medications,
|
||||
IDietService diets,
|
||||
IReportService reports,
|
||||
IInAppNotificationService notifications,
|
||||
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
@@ -23,6 +29,9 @@ public sealed class AiToolExecutionService(
|
||||
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)
|
||||
@@ -31,13 +40,17 @@ public sealed class AiToolExecutionService(
|
||||
var root = json.RootElement;
|
||||
return await (toolName switch
|
||||
{
|
||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, _db, userId, _healthRecords),
|
||||
"record_health_data" or "record_health_data_batch"
|
||||
=> HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords, ct),
|
||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
|
||||
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
|
||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
|
||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
|
||||
"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}" }),
|
||||
});
|
||||
}
|
||||
|
||||
168
backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs
Normal file
168
backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs
Normal 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:任何食物、饮品、餐食
|
||||
- report:医学检查报告、化验单、影像报告(X光/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}]");
|
||||
}
|
||||
}
|
||||
|
||||
// ── PDF:PdfPig 抽取文本 ──
|
||||
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;
|
||||
}
|
||||
@@ -37,6 +37,29 @@ public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversatio
|
||||
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);
|
||||
|
||||
|
||||
108
backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs
Normal file
108
backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs
Normal 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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
你是患者端统一 AI 健康助手“小脉”。你的任务是理解用户当前真正想做的事情,调用经过授权的业务工具,并用自然、温和、清楚的中文回复。
|
||||
|
||||
核心原则:
|
||||
- 个人健康数据、计划、完成状态和报告状态必须以当前业务工具返回为准,不能根据聊天历史或患者背景猜测。
|
||||
- 工具没有返回的数据不得编造;暂时没有对应能力时应如实说明。
|
||||
- 一条消息可能包含多个意图,必须分别处理,不能为了简化而遗漏其中一项。
|
||||
- 当前工具结果与历史聊天冲突时,以当前工具结果为准。
|
||||
- 普通聊天历史用于理解语境,不等同于待写入数据;只有后端明确提供的待补充草稿才允许跨消息合并录入字段。
|
||||
@@ -0,0 +1,6 @@
|
||||
医疗安全边界:
|
||||
- 你是健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以解释健康数据、报告指标和症状可能方向,但不能作确定诊断。
|
||||
- 不要求用户自行新增、停用、更换药物或调整剂量;涉及治疗变化时建议咨询医生或药师。
|
||||
- 出现剧烈胸痛、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等危险信号时,立即停止普通流程并优先建议及时就医或急诊评估。
|
||||
- 医疗分析使用“可能、建议、需要结合医生判断”等表达。
|
||||
@@ -0,0 +1,8 @@
|
||||
回复要求:
|
||||
- 语气自然、温和、专业,不使用系统报错式措辞。
|
||||
- 信息不完整时,每次只追问最关键的缺失内容;可以在一句话中列出同一批次明确缺少的字段。
|
||||
- 禁止使用粗体、斜体、删除线、HTML 和 Markdown 表格。
|
||||
- 标题只使用三级标题;列表使用短横线。
|
||||
- 数值和单位之间留空格,例如 120 mmHg、6.2 mmol/L。
|
||||
- 不输出任何以 $ 开头的占位符。
|
||||
- 不重复堆砌免责声明。
|
||||
@@ -0,0 +1,6 @@
|
||||
新饮食图片分析模块:
|
||||
- 仅用于本轮新上传的饮食图片;查询已经保存的饮食记录应进入个人数据查询模块。
|
||||
- 识别食物名称、估算份量和热量,并结合患者健康档案评价。
|
||||
- 对过敏、低盐、低脂、低糖等限制给出清楚提醒,不作绝对化医疗结论。
|
||||
- 饮食建议需要医学资料支撑时可以按需调用知识库。
|
||||
- 图片分析结果不能在用户未确认时冒充已经保存的饮食记录。
|
||||
@@ -0,0 +1,9 @@
|
||||
运动计划录入模块:
|
||||
- 用户明确创建运动计划时调用 manage_exercise(action="create")。
|
||||
- 完整计划需要:运动项目、每天运动时长、持续天数、开始日期和具体提醒时间。
|
||||
- 中文时长和周期应正确换算,例如半小时=30 分钟、一小时=60 分钟、一周=7 天、一个月=30 天。
|
||||
- 只传用户明确提供的字段;当前产品不自动补默认运动项目、天数、开始日期或提醒时间。
|
||||
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮补充时与草稿合并。
|
||||
- 用户明确开始新的运动计划时不得继承旧草稿内容。
|
||||
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
|
||||
- 单纯创建运动计划不调用医学知识库。用户先询问该运动是否适合自己时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,4 @@
|
||||
普通聊天与应用帮助模块:
|
||||
- 问候、感谢和普通闲聊自然简短回复,不调用业务工具或医学知识库。
|
||||
- 用户询问应用功能位置或使用方法时可以直接说明;已有固定 App 内链接时可自然提供一个跳转链接。
|
||||
- 不确定用户是想咨询数值还是保存记录时,只做一次简短澄清,不生成确认卡。
|
||||
@@ -0,0 +1,13 @@
|
||||
健康指标录入模块:
|
||||
- 支持血压、心率、血糖、血氧、体重;“脉搏”与“心率”是同一指标,统一使用 heart_rate。
|
||||
- 必须理解常见完整血压表达,例如 113/86、113-86、113—86,前者为收缩压,后者为舒张压。
|
||||
- 用户明确要求录入时必须调用 record_health_data_batch,而且每个录入批次只调用一次;即使只有一个指标,也必须放进 metrics 数组。信息不完整也要调用,只传用户明确提供的字段,让后端保存整个待补充批次草稿。
|
||||
- 不能自行补测量数值。测量时间未说明时可以由后端使用当前时间。
|
||||
- 一次消息可以包含多个指标,必须完整提取,不得遗漏。多指标属于同一录入批次,最终应汇总在一张确认卡中。
|
||||
- metrics 必须包含用户本轮明确要录入的全部指标;不要为每个指标分别调用工具。
|
||||
- 如果同一批次存在不完整或明显无效的指标,应保留本批次其他已识别指标,先根据后端结果追问缺失或错误部分;全部补齐后再生成整批确认卡。
|
||||
- 用户只补充一个缺失值时,必须结合后端提供的当前草稿,不能只记录最后一句。
|
||||
- 已经生成确认卡的批次立即结束;无论用户是否点击,下一条新的录入请求都属于新批次,不能带入上一张卡的数据。
|
||||
- 普通聊天历史中的指标不得自动加入当前卡;只有明确的待补充草稿可以跨消息合并。
|
||||
- 后端返回 pendingConfirmation=true 才代表确认卡真实生成。确认卡出现前不得要求用户点击卡片,点击前不得声称已经保存或录入成功。
|
||||
- 后端返回危险提醒或无效数值时,不生成成功话术,按返回内容提醒用户。
|
||||
@@ -0,0 +1,7 @@
|
||||
医疗咨询与健康建议模块:
|
||||
- 症状求助时先判断是否存在危险信号;非紧急情况下每轮只追问一个最关键问题,逐步了解部位、开始时间、程度、诱因和伴随表现。
|
||||
- 信息足够后给出可能相关方向、严重程度以及观察、门诊、尽快就医或急诊建议,不作正式诊断。
|
||||
- 解释用户个人指标或趋势时,应先调用相应个人数据查询工具获取真实数据。
|
||||
- 疾病、药品、检查指标、康复、饮食或运动建议需要医学资料支撑时,调用 search_medical_knowledge。
|
||||
- 不需要外部医学资料的简单澄清、问候或业务操作不调用知识库。
|
||||
- 用户同时要求录入或创建计划时,医疗解释不能代替对应录入工具和确认卡。
|
||||
@@ -0,0 +1,9 @@
|
||||
用药计划录入模块:
|
||||
- 用户明确创建用药计划时调用 manage_medication(action="create")。
|
||||
- 只传用户明确提供的字段,不自行新增药品、猜测剂量、频率、具体服药时间、开始日期或疗程。
|
||||
- 完整计划需要:药品名称、每次剂量、频率、具体服药时间、开始日期,以及疗程天数或明确长期服用。
|
||||
- “早饭后、晚上”等模糊时间不能擅自换算为具体时刻,应追问具体时间。
|
||||
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮只补缺失字段时与草稿合并。
|
||||
- 存在旧草稿但用户明确开始另一种药品的新计划时,应开始新批次,禁止继承旧药品的剂量、时间、频率或疗程。
|
||||
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
|
||||
- 用药计划录入本身不调用医学知识库。用户同时询问药品知识或副作用时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,7 @@
|
||||
个人数据查询模块:
|
||||
- 查询用户自己的健康记录、健康档案、用药计划、运动计划、饮食记录、复查安排、已有报告和通知时,必须调用对应只读业务工具。
|
||||
- 不能根据患者背景、聊天历史或以前的工具结果回答当前状态。
|
||||
- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“任务已经完成”。
|
||||
- 查询某一天任务与查询计划生命周期不能混用;时间意图不明确时只追问时间范围。
|
||||
- 工具没有返回数据时明确说明未查到,不得编造。
|
||||
- 个人数据查询通常不调用医学知识库;用户同时要求解释或建议时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,7 @@
|
||||
新报告分析模块:
|
||||
- 仅用于本轮新上传的医学检查报告图片或报告文件;查询以前保存的报告应进入个人数据查询模块。
|
||||
- 提取报告名称、日期、关键指标、参考范围、异常标记和原文结论。
|
||||
- 区分结构化检验指标与影像/医生描述,不把影像描述改写成确定诊断。
|
||||
- 给出患者可理解的初步解释和下一步建议,注明需要结合医生判断。
|
||||
- 报告内容需要进一步医学解释时可以按需调用知识库。
|
||||
- 用户没有明确要求时,不把报告中的指标直接写入健康记录。
|
||||
@@ -0,0 +1,7 @@
|
||||
医学知识库使用规则:
|
||||
- 知识库是医疗咨询、指标解释、药品知识、报告解释和健康建议的辅助能力,不是独立用户意图。
|
||||
- 健康指标录入、用药或运动计划创建、个人数据查询、服药确认、运动打卡、普通聊天时禁止调用。
|
||||
- 一条消息同时包含业务操作和医学咨询时,先完成必要业务工具调用;只有咨询部分确实需要资料时才检索。
|
||||
- 检索问题应保留关键症状、疾病、药品或指标名称,删除无关寒暄和操作指令。
|
||||
- 只能依据真实返回的知识片段作参考;没有结果时应谨慎回答,不得虚构检索内容或来源。
|
||||
- 知识库内容不能覆盖患者当前业务数据,也不能替代紧急安全规则。
|
||||
@@ -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 工具返回结构化结果,不得输出解释文字。
|
||||
@@ -9,7 +9,7 @@ namespace Health.Infrastructure.AI;
|
||||
public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
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()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
@@ -28,8 +28,13 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, Stream = true,
|
||||
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
Stream = true,
|
||||
MaxTokens = maxTokens,
|
||||
Temperature = temperature,
|
||||
Tools = tools,
|
||||
Thinking = new { type = "disabled" },
|
||||
};
|
||||
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||
|
||||
@@ -64,14 +69,20 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
List<ToolDefinition>? tools = null,
|
||||
int maxTokens = 2048,
|
||||
float temperature = 0.7f,
|
||||
string? toolChoice = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, Stream = false,
|
||||
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||
Model = _model,
|
||||
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 content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
@@ -117,8 +128,13 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
|
||||
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
||||
Temperature = 0.1f, VlHighResolutionImages = true,
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
MaxTokens = maxTokens,
|
||||
Stream = false,
|
||||
Temperature = 0.1f,
|
||||
VlHighResolutionImages = true,
|
||||
EnableThinking = false,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
|
||||
@@ -10,9 +10,13 @@ public sealed class ChatCompletionRequest
|
||||
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
|
||||
|
||||
185
backend/src/Health.Infrastructure/AI/ai_intent_router.cs
Normal file
185
backend/src/Health.Infrastructure/AI/ai_intent_router.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,169 +1,123 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
/// <summary>
|
||||
/// System Prompt 模板管理
|
||||
/// Loads small, responsibility-focused prompt resources and composes only the
|
||||
/// sections required by the current intent route.
|
||||
/// </summary>
|
||||
public sealed class PromptManager
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定 Agent 的 System Prompt
|
||||
/// </summary>
|
||||
public string GetSystemPrompt(AgentType agentType)
|
||||
{
|
||||
var prompt = agentType switch
|
||||
{
|
||||
AgentType.Default => DefaultPrompt,
|
||||
AgentType.Consultation => ConsultationPrompt,
|
||||
AgentType.Health => HealthDataPrompt,
|
||||
AgentType.Diet => DietPrompt,
|
||||
AgentType.Medication => MedicationPrompt,
|
||||
AgentType.Report => ReportPrompt,
|
||||
AgentType.Exercise => ExercisePrompt,
|
||||
AgentType.Unified => UnifiedPrompt,
|
||||
_ => DefaultPrompt
|
||||
};
|
||||
private static readonly Assembly PromptAssembly = typeof(PromptManager).Assembly;
|
||||
private static readonly IReadOnlyDictionary<string, string> PromptCache = LoadPrompts();
|
||||
|
||||
return $"{prompt}\n\n{MedicalBoundaryRules}";
|
||||
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);
|
||||
}
|
||||
|
||||
private const string MedicalBoundaryRules = """
|
||||
医疗边界(必须遵守):
|
||||
- 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以解释健康数据、报告指标和症状可能方向,但不要给出确定诊断,不要说“你就是/一定是/已经确诊”。
|
||||
- 不要要求用户自行新增、停用、更换药物或调整剂量;涉及用药变化时,必须建议咨询医生或药师。
|
||||
- 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等情况,优先建议及时就医或急诊评估。
|
||||
- 回答用语使用“可能、建议、需要结合医生判断”等表达,避免绝对化结论。
|
||||
- 医疗相关分析末尾用一句自然的话提醒:以上为 AI 预分析,不能替代医生诊断和治疗建议。
|
||||
""";
|
||||
/// <summary>
|
||||
/// Compatibility entry for older clients that still address a specific agent.
|
||||
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
|
||||
/// </summary>
|
||||
public string GetSystemPrompt(AgentType agentType) => agentType switch
|
||||
{
|
||||
AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]),
|
||||
AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]),
|
||||
AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]),
|
||||
AgentType.Consultation => ComposeForIntents(["medical_consultation"]),
|
||||
AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]),
|
||||
AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]),
|
||||
AgentType.Unified => ComposeForIntents([
|
||||
"health_entry",
|
||||
"medication_entry",
|
||||
"exercise_entry",
|
||||
"personal_query",
|
||||
"medical_consultation",
|
||||
"report_analysis",
|
||||
"diet_analysis",
|
||||
"general_chat",
|
||||
]),
|
||||
_ => ComposeForIntents(["general_chat", "medical_consultation"]),
|
||||
};
|
||||
|
||||
private const string DefaultPrompt = """
|
||||
你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。
|
||||
private static string Compose(IEnumerable<string> sections) =>
|
||||
string.Join(
|
||||
"\n\n",
|
||||
sections
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Read)
|
||||
.Where(content => !string.IsNullOrWhiteSpace(content)));
|
||||
|
||||
职责:
|
||||
1. 理解用户的健康需求,解析健康数据
|
||||
2. 主动查看患者近期数据,发现异常时提醒
|
||||
3. 回答健康知识问题
|
||||
4. 每次回复末尾,如有需要提醒的事项,简短温馨地提醒一句
|
||||
private static string NormalizeIntent(string? intent) =>
|
||||
intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? "";
|
||||
|
||||
规则:
|
||||
- 不要提供超出你能力范围的医疗建议
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 饮食/运动建议要结合患者档案中的疾病和限制
|
||||
- 回复语气温暖、专业、像朋友一样关怀患者
|
||||
""";
|
||||
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 = """
|
||||
你是一个患者端 AI 预问诊助手,负责帮助心脏术后患者梳理症状和就医前信息。
|
||||
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;
|
||||
|
||||
规则:
|
||||
1. 每次只问一个问题,不要一次问多个
|
||||
2. 给出 2-3 个快捷选项让患者点击
|
||||
3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析
|
||||
4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸
|
||||
5. 遇到以下情况建议咨询医生:血压持续>160/100、心率>120或<50
|
||||
6. 只给出初步分析和下一步建议,不作诊断结论
|
||||
7. 问诊结束给出结构化小结
|
||||
""";
|
||||
using var stream = PromptAssembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Unable to open AI prompt resource: {resourceName}");
|
||||
using var reader = new StreamReader(stream);
|
||||
var relative = resourceName[(markerIndex + marker.Length)..];
|
||||
var lastDot = relative.LastIndexOf(".md", StringComparison.OrdinalIgnoreCase);
|
||||
relative = relative[..lastDot].Replace('.', '/') + ".md";
|
||||
prompts[NormalizePath(relative)] = reader.ReadToEnd().Trim();
|
||||
}
|
||||
return prompts;
|
||||
}
|
||||
|
||||
private const string HealthDataPrompt = """
|
||||
你是一个健康数据录入助手。
|
||||
|
||||
规则:
|
||||
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. 解析完成后调用 manage_medication(action="create") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存
|
||||
5. 处方拍照→提取药品信息→生成用药计划→让用户确认
|
||||
6. 可以解释常见用药注意事项,但不要建议用户自行新增、停用、更换药物或调整剂量
|
||||
""";
|
||||
|
||||
private const string ReportPrompt = """
|
||||
你是一个患者端医学报告预解读助手。
|
||||
|
||||
规则:
|
||||
1. 收到报告图片后,提取所有指标及其数值
|
||||
2. 标注异常指标(偏高/偏低/正常)
|
||||
3. 给出初步分析和可能方向,不作诊断结论
|
||||
4. 所有内容标注"AI预解读,待医生确认"
|
||||
5. 图像类报告(彩超/CT)注明"需医生人工审阅"
|
||||
""";
|
||||
|
||||
private const string ExercisePrompt = """
|
||||
你是一个运动康复教练,专门为心脏术后患者制定运动计划。
|
||||
|
||||
规则:
|
||||
1. 帮助用户设定每周运动计划(类型/时长/天数)
|
||||
2. 以周为单位,每天指定运动类型和时长
|
||||
3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等
|
||||
4. 运动强度要循序渐进
|
||||
5. 避免剧烈运动
|
||||
""";
|
||||
|
||||
private const string UnifiedPrompt = """
|
||||
你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。
|
||||
|
||||
核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
|
||||
|
||||
领域判断与可用工具:
|
||||
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
||||
2. 饮食分析 → 引导用户使用「拍饮食」功能拍照识别
|
||||
3. 用药管理 → 调用 manage_medication
|
||||
4. 运动计划 → 调用 manage_exercise
|
||||
5. 健康档案查询/修改 → 调用 check_archive / manage_archive
|
||||
6. 历史数据查询 → 调用 query_health_records
|
||||
|
||||
运动计划参数格式(manage_exercise):
|
||||
- action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
|
||||
- duration_days=连续天数,exercise_type="散步",duration_minutes=30,reminder_time="19:00"
|
||||
- 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字
|
||||
- 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天
|
||||
- 用户说"坚持X天/月/周"则创建对应天数
|
||||
- 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目
|
||||
|
||||
用药管理参数格式(manage_medication):
|
||||
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
|
||||
|
||||
规则:
|
||||
- 用户可能一句话涉及多个领域,全部处理
|
||||
- 数值明确+指标明确→直接生成待确认命令,不要追问
|
||||
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
||||
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温暖、专业
|
||||
""";
|
||||
private static string NormalizePath(string path) =>
|
||||
path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -16,25 +16,32 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
|
||||
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空");
|
||||
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空");
|
||||
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 = command.Name, Title = command.Title, Department = command.Department,
|
||||
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
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);
|
||||
if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct))
|
||||
_db.Users.Add(user);
|
||||
_db.DoctorProfiles.Add(new DoctorProfile
|
||||
{
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = command.Phone, Role = "Doctor", Name = command.Name, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
_db.Users.Add(user);
|
||||
_db.DoctorProfiles.Add(new DoctorProfile
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = command.Name,
|
||||
Title = command.Title, Department = command.Department, IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
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 });
|
||||
}
|
||||
@@ -43,11 +50,43 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
{
|
||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null) return Error(404, "医生不存在");
|
||||
if (command.Name != null) doctor.Name = command.Name;
|
||||
if (command.Title != null) doctor.Title = command.Title;
|
||||
if (command.Department != null) doctor.Department = command.Department;
|
||||
if (command.Phone != null) doctor.Phone = command.Phone;
|
||||
if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection;
|
||||
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 });
|
||||
}
|
||||
@@ -56,7 +95,11 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
{
|
||||
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 });
|
||||
}
|
||||
@@ -65,7 +108,22 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
{
|
||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null) return Error(404, "医生不存在");
|
||||
await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct);
|
||||
|
||||
if (await _db.Users.AnyAsync(x => x.Role == "User" && x.DoctorId == id, ct))
|
||||
return Error(40004, "该医生仍有绑定患者,请先停用,不能直接删除");
|
||||
if (await _db.Consultations.AnyAsync(x => x.DoctorId == id, ct))
|
||||
return Error(40005, "该医生已有问诊记录,只能停用,不能删除");
|
||||
|
||||
var profile = await _db.DoctorProfiles.Include(x => x.User)
|
||||
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
|
||||
if (profile != null)
|
||||
{
|
||||
var user = profile.User;
|
||||
var refreshTokens = await _db.RefreshTokens.Where(x => x.UserId == user.Id).ToListAsync(ct);
|
||||
_db.RefreshTokens.RemoveRange(refreshTokens);
|
||||
_db.DoctorProfiles.Remove(profile);
|
||||
_db.Users.Remove(user);
|
||||
}
|
||||
_db.Doctors.Remove(doctor);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true });
|
||||
@@ -77,7 +135,9 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
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.Contains(search));
|
||||
query = query.Where(x =>
|
||||
(x.Name != null && x.Name.Contains(search)) ||
|
||||
(x.Phone != null && x.Phone.Contains(search)));
|
||||
var total = await query.CountAsync(ct);
|
||||
var 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 })
|
||||
|
||||
@@ -3,7 +3,12 @@ using Health.Infrastructure.Services;
|
||||
|
||||
namespace Health.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
|
||||
public sealed class AuthService(
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
SmsService sms,
|
||||
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
|
||||
) : IAuthService
|
||||
{
|
||||
private const string AdminPhone = "12345678910";
|
||||
private const string AdminSmsCode = "000000";
|
||||
@@ -11,9 +16,21 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
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
|
||||
{
|
||||
@@ -21,7 +38,12 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
|
||||
}, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
if (phone != AdminPhone) await _sms.SendCodeAsync(phone, code);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -82,14 +104,39 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
{
|
||||
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } });
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
user = new
|
||||
{
|
||||
id = AdminId,
|
||||
phone = AdminPhone,
|
||||
role = "Admin",
|
||||
name = "管理员"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var user = await _db.Users.FindAsync([oldToken.UserId], ct);
|
||||
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.Role } });
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
userTokens.accessToken,
|
||||
userTokens.refreshToken,
|
||||
user = new
|
||||
{
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Role,
|
||||
user.Name,
|
||||
user.Gender,
|
||||
user.AvatarUrl,
|
||||
BirthDate = user.BirthDate?.ToString("yyyy-MM-dd")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(string refreshToken, CancellationToken ct)
|
||||
@@ -99,13 +146,68 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
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)
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
||||
{
|
||||
var access = _jwt.GenerateAccessToken(userId, phone, role);
|
||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
||||
var refresh = _jwt.GenerateRefreshToken();
|
||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
return (access, refresh);
|
||||
|
||||
@@ -8,8 +8,9 @@ public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository
|
||||
|
||||
public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct)
|
||||
{
|
||||
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
// 日历的日期边界按北京时间计算,数据库仍统一使用 UTC。
|
||||
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
|
||||
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
|
||||
|
||||
var medications = await _db.Medications
|
||||
.Where(m => m.UserId == userId && m.IsActive)
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Health.Infrastructure.Data;
|
||||
|
||||
public sealed class DatabaseSchemaMigrator(AppDbContext db, ILogger<DatabaseSchemaMigrator> logger)
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly ILogger<DatabaseSchemaMigrator> _logger = logger;
|
||||
|
||||
public async Task ApplyAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
|
||||
await _db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS "__AppSchemaMigrations" (
|
||||
"Id" varchar(128) PRIMARY KEY,
|
||||
"AppliedAt" timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AiWriteCommands" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"UserId" uuid NOT NULL,
|
||||
"ToolName" varchar(64) NOT NULL,
|
||||
"Arguments" jsonb NOT NULL,
|
||||
"Status" varchar(32) NOT NULL,
|
||||
"ExpiresAt" timestamptz NOT NULL,
|
||||
"CreatedAt" timestamptz NOT NULL,
|
||||
"UpdatedAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "IX_AiWriteCommands_UserId_Status_ExpiresAt"
|
||||
ON "AiWriteCommands" ("UserId", "Status", "ExpiresAt");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "ReportAnalysisTasks" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"ReportId" uuid NOT NULL,
|
||||
"FilePath" text NOT NULL,
|
||||
"Status" varchar(32) NOT NULL,
|
||||
"Attempts" integer NOT NULL,
|
||||
"AvailableAt" timestamptz NOT NULL,
|
||||
"LastError" text NULL,
|
||||
"CreatedAt" timestamptz NOT NULL,
|
||||
"UpdatedAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_Status_AvailableAt"
|
||||
ON "ReportAnalysisTasks" ("Status", "AvailableAt");
|
||||
CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_ReportId"
|
||||
ON "ReportAnalysisTasks" ("ReportId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "DietImageAnalysisTasks" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"FilePaths" jsonb NOT NULL,
|
||||
"Status" varchar(32) NOT NULL,
|
||||
"Attempts" integer NOT NULL,
|
||||
"AvailableAt" timestamptz NOT NULL,
|
||||
"ResultCode" integer NULL,
|
||||
"ResultData" text NULL,
|
||||
"ResultMessage" text NULL,
|
||||
"LastError" text NULL,
|
||||
"CreatedAt" timestamptz NOT NULL,
|
||||
"UpdatedAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "IX_DietImageAnalysisTasks_Status_AvailableAt"
|
||||
ON "DietImageAnalysisTasks" ("Status", "AvailableAt");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "MedicationReminderTasks" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"UserId" uuid NOT NULL,
|
||||
"MedicationId" uuid NOT NULL,
|
||||
"Name" text NOT NULL,
|
||||
"Dosage" text NULL,
|
||||
"ScheduledDate" date NOT NULL,
|
||||
"ScheduledTime" time without time zone NOT NULL,
|
||||
"Status" varchar(32) NOT NULL,
|
||||
"Attempts" integer NOT NULL,
|
||||
"AvailableAt" timestamptz NOT NULL,
|
||||
"LastError" text NULL,
|
||||
"CreatedAt" timestamptz NOT NULL,
|
||||
"UpdatedAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_Status_AvailableAt"
|
||||
ON "MedicationReminderTasks" ("Status", "AvailableAt");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_MedicationId_ScheduledDate_ScheduledTime"
|
||||
ON "MedicationReminderTasks" ("MedicationId", "ScheduledDate", "ScheduledTime");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "NotificationOutbox" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"UserId" uuid NOT NULL,
|
||||
"SourceTaskId" uuid NOT NULL,
|
||||
"Type" varchar(64) NOT NULL,
|
||||
"Payload" jsonb NOT NULL,
|
||||
"Status" varchar(32) NOT NULL,
|
||||
"Attempts" integer NOT NULL,
|
||||
"AvailableAt" timestamptz NOT NULL,
|
||||
"LastError" text NULL,
|
||||
"CreatedAt" timestamptz NOT NULL,
|
||||
"UpdatedAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_NotificationOutbox_SourceTaskId"
|
||||
ON "NotificationOutbox" ("SourceTaskId");
|
||||
CREATE INDEX IF NOT EXISTS "IX_NotificationOutbox_Status_AvailableAt"
|
||||
ON "NotificationOutbox" ("Status", "AvailableAt");
|
||||
|
||||
ALTER TABLE IF EXISTS "ExercisePlans"
|
||||
ADD COLUMN IF NOT EXISTS "StartDate" date NULL,
|
||||
ADD COLUMN IF NOT EXISTS "EndDate" date NULL,
|
||||
ADD COLUMN IF NOT EXISTS "ReminderTime" time without time zone NOT NULL DEFAULT TIME '19:00';
|
||||
ALTER TABLE IF EXISTS "ExercisePlanItems"
|
||||
ADD COLUMN IF NOT EXISTS "ScheduledDate" date NULL;
|
||||
|
||||
DO $exercise_migration$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'ExercisePlans' AND column_name = 'WeekStartDate'
|
||||
) THEN
|
||||
UPDATE "ExercisePlans"
|
||||
SET "StartDate" = COALESCE("StartDate", "WeekStartDate")
|
||||
WHERE "StartDate" IS NULL;
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT i."Id", p."StartDate",
|
||||
ROW_NUMBER() OVER (PARTITION BY i."PlanId" ORDER BY i."Id") - 1 AS day_offset
|
||||
FROM "ExercisePlanItems" i
|
||||
JOIN "ExercisePlans" p ON p."Id" = i."PlanId"
|
||||
WHERE i."ScheduledDate" IS NULL
|
||||
)
|
||||
UPDATE "ExercisePlanItems" i
|
||||
SET "ScheduledDate" = ranked."StartDate" + ranked.day_offset::integer
|
||||
FROM ranked
|
||||
WHERE i."Id" = ranked."Id";
|
||||
|
||||
UPDATE "ExercisePlans" p
|
||||
SET "EndDate" = COALESCE(
|
||||
p."EndDate",
|
||||
(SELECT MAX(i."ScheduledDate") FROM "ExercisePlanItems" i WHERE i."PlanId" = p."Id"),
|
||||
p."StartDate"
|
||||
)
|
||||
WHERE p."EndDate" IS NULL;
|
||||
END IF;
|
||||
END $exercise_migration$;
|
||||
|
||||
ALTER TABLE IF EXISTS "ExercisePlans"
|
||||
ALTER COLUMN "StartDate" SET NOT NULL,
|
||||
ALTER COLUMN "EndDate" SET NOT NULL;
|
||||
ALTER TABLE IF EXISTS "ExercisePlanItems"
|
||||
ALTER COLUMN "ScheduledDate" SET NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS "IX_ExercisePlans_UserId_StartDate_EndDate"
|
||||
ON "ExercisePlans" ("UserId", "StartDate", "EndDate");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_ExercisePlanItems_PlanId_ScheduledDate"
|
||||
ON "ExercisePlanItems" ("PlanId", "ScheduledDate");
|
||||
|
||||
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
|
||||
VALUES ('20260619_01_persistent_ai_commands_and_report_tasks', now())
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
|
||||
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
|
||||
VALUES ('20260619_02_persistent_diet_and_medication_tasks', now())
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
|
||||
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
|
||||
VALUES ('20260620_03_date_based_exercise_plans', now())
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
""", ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
_logger.LogInformation("数据库结构迁移已检查: 20260620_03");
|
||||
}
|
||||
}
|
||||
1443
backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs
generated
Normal file
1443
backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,946 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AiWriteCommands",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ToolName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Arguments = table.Column<string>(type: "jsonb", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AiWriteCommands", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DietImageAnalysisTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FilePaths = table.Column<string>(type: "jsonb", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ResultCode = table.Column<int>(type: "integer", nullable: true),
|
||||
ResultData = table.Column<string>(type: "text", nullable: true),
|
||||
ResultMessage = table.Column<string>(type: "text", nullable: true),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DietImageAnalysisTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Doctors",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Department = table.Column<string>(type: "text", nullable: true),
|
||||
Phone = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||
ProfessionalDirection = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "text", nullable: true),
|
||||
Introduction = table.Column<string>(type: "text", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Doctors", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MedicationReminderTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Dosage = table.Column<string>(type: "text", nullable: true),
|
||||
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MedicationReminderTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationOutbox",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SourceTaskId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Payload = table.Column<string>(type: "jsonb", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationOutbox", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RefreshTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Token = table.Column<string>(type: "text", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsRevoked = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ReportAnalysisTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ReportId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FilePath = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ReportAnalysisTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserNotifications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SourceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
Message = table.Column<string>(type: "text", nullable: false),
|
||||
Severity = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
ActionType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
ActionTargetId = table.Column<string>(type: "text", nullable: true),
|
||||
IsRead = table.Column<bool>(type: "boolean", nullable: false),
|
||||
ReadAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserNotifications", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VerificationCodes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Phone = table.Column<string>(type: "text", nullable: false),
|
||||
Code = table.Column<string>(type: "text", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsUsed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VerificationCodes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Phone = table.Column<string>(type: "text", nullable: false),
|
||||
Role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "User"),
|
||||
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Name = table.Column<string>(type: "text", nullable: true),
|
||||
Gender = table.Column<string>(type: "text", nullable: true),
|
||||
BirthDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Users_Doctors_DoctorId",
|
||||
column: x => x.DoctorId,
|
||||
principalTable: "Doctors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Consultations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DoctorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
Month = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Consultations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Consultations_Doctors_DoctorId",
|
||||
column: x => x.DoctorId,
|
||||
principalTable: "Doctors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Consultations_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Conversations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AgentType = table.Column<string>(type: "text", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Summary = table.Column<string>(type: "text", nullable: true),
|
||||
MessageCount = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Conversations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Conversations_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DeviceTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Platform = table.Column<string>(type: "text", nullable: false),
|
||||
PushToken = table.Column<string>(type: "text", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DeviceTokens", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DeviceTokens_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DietRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MealType = table.Column<string>(type: "text", nullable: false),
|
||||
TotalCalories = table.Column<int>(type: "integer", nullable: true),
|
||||
HealthScore = table.Column<int>(type: "integer", nullable: true),
|
||||
RecordedAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DietRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DietRecords_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DoctorProfiles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Name = table.Column<string>(type: "text", nullable: true),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Department = table.Column<string>(type: "text", nullable: true),
|
||||
Hospital = table.Column<string>(type: "text", nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "text", nullable: true),
|
||||
IsOnline = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DoctorProfiles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DoctorProfiles_Doctors_DoctorId",
|
||||
column: x => x.DoctorId,
|
||||
principalTable: "Doctors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_DoctorProfiles_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExercisePlans",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ReminderTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExercisePlans", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExercisePlans_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FollowUps",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
DoctorName = table.Column<string>(type: "text", nullable: true),
|
||||
Department = table.Column<string>(type: "text", nullable: true),
|
||||
ScheduledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Notes = table.Column<string>(type: "text", nullable: true),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FollowUps", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FollowUps_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HealthArchives",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Diagnosis = table.Column<string>(type: "text", nullable: true),
|
||||
SurgeryType = table.Column<string>(type: "text", nullable: true),
|
||||
SurgeryDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
Allergies = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
DietRestrictions = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
ChronicDiseases = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
FamilyHistory = table.Column<string>(type: "text", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HealthArchives", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HealthArchives_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HealthRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MetricType = table.Column<string>(type: "text", nullable: false),
|
||||
Systolic = table.Column<int>(type: "integer", nullable: true),
|
||||
Diastolic = table.Column<int>(type: "integer", nullable: true),
|
||||
Value = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
Unit = table.Column<string>(type: "text", nullable: true),
|
||||
Source = table.Column<string>(type: "text", nullable: false),
|
||||
IsAbnormal = table.Column<bool>(type: "boolean", nullable: false),
|
||||
RecordedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HealthRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HealthRecords_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Medications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Dosage = table.Column<string>(type: "text", nullable: true),
|
||||
Frequency = table.Column<string>(type: "text", nullable: false),
|
||||
TimeOfDay = table.Column<List<TimeOnly>>(type: "time[]", nullable: false),
|
||||
StartDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
EndDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Source = table.Column<string>(type: "text", nullable: false),
|
||||
Notes = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Medications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Medications_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationPreferences",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MedicationReminder = table.Column<bool>(type: "boolean", nullable: false),
|
||||
FollowUpReminder = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DoctorReply = table.Column<bool>(type: "boolean", nullable: false),
|
||||
AbnormalAlert = table.Column<bool>(type: "boolean", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationPreferences", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationPreferences_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Reports",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileUrl = table.Column<string>(type: "text", nullable: false),
|
||||
FileType = table.Column<string>(type: "text", nullable: false),
|
||||
Category = table.Column<string>(type: "text", nullable: false),
|
||||
AiSummary = table.Column<string>(type: "text", nullable: true),
|
||||
AiIndicators = table.Column<string>(type: "jsonb", nullable: true),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
Severity = table.Column<string>(type: "text", nullable: true),
|
||||
DoctorComment = table.Column<string>(type: "text", nullable: true),
|
||||
DoctorRecommendation = table.Column<string>(type: "text", nullable: true),
|
||||
DoctorName = table.Column<string>(type: "text", nullable: true),
|
||||
ReviewedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Reports", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Reports_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConsultationMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ConsultationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SenderType = table.Column<string>(type: "text", nullable: false),
|
||||
SenderName = table.Column<string>(type: "text", nullable: true),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConsultationMessages", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ConsultationMessages_Consultations_ConsultationId",
|
||||
column: x => x.ConsultationId,
|
||||
principalTable: "Consultations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConversationMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Role = table.Column<string>(type: "text", nullable: false),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
Intent = table.Column<string>(type: "text", nullable: true),
|
||||
MetadataJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConversationMessages", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ConversationMessages_Conversations_ConversationId",
|
||||
column: x => x.ConversationId,
|
||||
principalTable: "Conversations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DietFoodItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DietRecordId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Portion = table.Column<string>(type: "text", nullable: true),
|
||||
Calories = table.Column<int>(type: "integer", nullable: true),
|
||||
ProteinGrams = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
CarbsGrams = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
FatGrams = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
Warning = table.Column<string>(type: "text", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DietFoodItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DietFoodItems_DietRecords_DietRecordId",
|
||||
column: x => x.DietRecordId,
|
||||
principalTable: "DietRecords",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExercisePlanItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PlanId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ExerciseType = table.Column<string>(type: "text", nullable: false),
|
||||
DurationMinutes = table.Column<int>(type: "integer", nullable: false),
|
||||
IsCompleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
IsRestDay = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExercisePlanItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExercisePlanItems_ExercisePlans_PlanId",
|
||||
column: x => x.PlanId,
|
||||
principalTable: "ExercisePlans",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HealthArchiveSurgeries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
HealthArchiveId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Date = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HealthArchiveSurgeries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HealthArchiveSurgeries_HealthArchives_HealthArchiveId",
|
||||
column: x => x.HealthArchiveId,
|
||||
principalTable: "HealthArchives",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MedicationLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
|
||||
ConfirmedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MedicationLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_MedicationLogs_Medications_MedicationId",
|
||||
column: x => x.MedicationId,
|
||||
principalTable: "Medications",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AiWriteCommands_UserId_Status_ExpiresAt",
|
||||
table: "AiWriteCommands",
|
||||
columns: new[] { "UserId", "Status", "ExpiresAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConsultationMessages_ConsultationId_CreatedAt",
|
||||
table: "ConsultationMessages",
|
||||
columns: new[] { "ConsultationId", "CreatedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Consultations_DoctorId",
|
||||
table: "Consultations",
|
||||
column: "DoctorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Consultations_UserId",
|
||||
table: "Consultations",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConversationMessages_ConversationId_CreatedAt",
|
||||
table: "ConversationMessages",
|
||||
columns: new[] { "ConversationId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Conversations_UserId_UpdatedAt",
|
||||
table: "Conversations",
|
||||
columns: new[] { "UserId", "UpdatedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeviceTokens_UserId",
|
||||
table: "DeviceTokens",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DietFoodItems_DietRecordId",
|
||||
table: "DietFoodItems",
|
||||
column: "DietRecordId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DietImageAnalysisTasks_Status_AvailableAt",
|
||||
table: "DietImageAnalysisTasks",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DietRecords_UserId_RecordedAt",
|
||||
table: "DietRecords",
|
||||
columns: new[] { "UserId", "RecordedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DoctorProfiles_DoctorId",
|
||||
table: "DoctorProfiles",
|
||||
column: "DoctorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DoctorProfiles_UserId",
|
||||
table: "DoctorProfiles",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExercisePlanItems_PlanId_ScheduledDate",
|
||||
table: "ExercisePlanItems",
|
||||
columns: new[] { "PlanId", "ScheduledDate" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExercisePlans_UserId_StartDate_EndDate",
|
||||
table: "ExercisePlans",
|
||||
columns: new[] { "UserId", "StartDate", "EndDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FollowUps_UserId",
|
||||
table: "FollowUps",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthArchives_UserId",
|
||||
table: "HealthArchives",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthArchiveSurgeries_HealthArchiveId_SortOrder",
|
||||
table: "HealthArchiveSurgeries",
|
||||
columns: new[] { "HealthArchiveId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthRecords_UserId_MetricType",
|
||||
table: "HealthRecords",
|
||||
columns: new[] { "UserId", "MetricType" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthRecords_UserId_RecordedAt",
|
||||
table: "HealthRecords",
|
||||
columns: new[] { "UserId", "RecordedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MedicationLogs_MedicationId_CreatedAt",
|
||||
table: "MedicationLogs",
|
||||
columns: new[] { "MedicationId", "CreatedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MedicationReminderTasks_MedicationId_ScheduledDate_Schedule~",
|
||||
table: "MedicationReminderTasks",
|
||||
columns: new[] { "MedicationId", "ScheduledDate", "ScheduledTime" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MedicationReminderTasks_Status_AvailableAt",
|
||||
table: "MedicationReminderTasks",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Medications_UserId_IsActive",
|
||||
table: "Medications",
|
||||
columns: new[] { "UserId", "IsActive" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationOutbox_SourceTaskId",
|
||||
table: "NotificationOutbox",
|
||||
column: "SourceTaskId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationOutbox_Status_AvailableAt",
|
||||
table: "NotificationOutbox",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationPreferences_UserId",
|
||||
table: "NotificationPreferences",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReportAnalysisTasks_ReportId",
|
||||
table: "ReportAnalysisTasks",
|
||||
column: "ReportId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReportAnalysisTasks_Status_AvailableAt",
|
||||
table: "ReportAnalysisTasks",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reports_UserId",
|
||||
table: "Reports",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserNotifications_SourceId",
|
||||
table: "UserNotifications",
|
||||
column: "SourceId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserNotifications_UserId_IsDeleted_IsRead_CreatedAt",
|
||||
table: "UserNotifications",
|
||||
columns: new[] { "UserId", "IsDeleted", "IsRead", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_DoctorId",
|
||||
table: "Users",
|
||||
column: "DoctorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_Phone",
|
||||
table: "Users",
|
||||
column: "Phone",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VerificationCodes_Phone_CreatedAt",
|
||||
table: "VerificationCodes",
|
||||
columns: new[] { "Phone", "CreatedAt" },
|
||||
descending: new[] { false, true });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AiWriteCommands");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ConsultationMessages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ConversationMessages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DeviceTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DietFoodItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DietImageAnalysisTasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DoctorProfiles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExercisePlanItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FollowUps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HealthArchiveSurgeries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HealthRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MedicationLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MedicationReminderTasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationOutbox");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RefreshTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ReportAnalysisTasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Reports");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserNotifications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VerificationCodes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Consultations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Conversations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DietRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExercisePlans");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HealthArchives");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Medications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Doctors");
|
||||
}
|
||||
}
|
||||
}
|
||||
1461
backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs
generated
Normal file
1461
backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHealthRecordReminder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminder",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderBloodPressure",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderGlucose",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderHeartRate",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderSpO2",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderWeight",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminder",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderBloodPressure",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderGlucose",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderHeartRate",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderSpO2",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderWeight",
|
||||
table: "NotificationPreferences");
|
||||
}
|
||||
}
|
||||
}
|
||||
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAppleSignIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Phone",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AppleUserId",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_AppleUserId",
|
||||
table: "Users",
|
||||
column: "AppleUserId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_AppleUserId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AppleUserId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Phone",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
1473
backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.Designer.cs
generated
Normal file
1473
backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPushAndDndPreferences : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "DndEnabled",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DndEndMinutes",
|
||||
table: "NotificationPreferences",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DndStartMinutes",
|
||||
table: "NotificationPreferences",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PushEnabled",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DndEnabled",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DndEndMinutes",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DndStartMinutes",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PushEnabled",
|
||||
table: "NotificationPreferences");
|
||||
}
|
||||
}
|
||||
}
|
||||
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHealthArchiveSurgeryStatus : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SurgeryHistoryStatus",
|
||||
table: "HealthArchives",
|
||||
type: "character varying(8)",
|
||||
maxLength: 8,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SurgeryHistoryStatus",
|
||||
table: "HealthArchives");
|
||||
}
|
||||
}
|
||||
}
|
||||
1528
backend/src/Health.Infrastructure/Data/Migrations/20260723054148_AddAiEntryDrafts.Designer.cs
generated
Normal file
1528
backend/src/Health.Infrastructure/Data/Migrations/20260723054148_AddAiEntryDrafts.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAiEntryDrafts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AiEntryDrafts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
EntryType = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Payload = table.Column<string>(type: "jsonb", nullable: false),
|
||||
MissingFields = table.Column<string>(type: "jsonb", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AiEntryDrafts", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AiEntryDrafts_UserId_ConversationId_EntryType_Status_Expire~",
|
||||
table: "AiEntryDrafts",
|
||||
columns: new[] { "UserId", "ConversationId", "EntryType", "Status", "ExpiresAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AiEntryDrafts");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
namespace Health.Infrastructure.Data.Records;
|
||||
|
||||
/// <summary>
|
||||
/// Stores an incomplete AI-assisted entry until the user provides every required field.
|
||||
/// This is deliberately separate from confirmed health, medication and exercise data.
|
||||
/// </summary>
|
||||
public sealed class AiEntryDraftRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public Guid ConversationId { get; set; }
|
||||
public string EntryType { get; set; } = string.Empty;
|
||||
public string Payload { get; set; } = "{}";
|
||||
public string MissingFields { get; set; } = "[]";
|
||||
public string Status { get; set; } = "Pending";
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -28,13 +28,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
public DbSet<DoctorProfile> DoctorProfiles => Set<DoctorProfile>();
|
||||
public DbSet<FollowUp> FollowUps => Set<FollowUp>();
|
||||
public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>();
|
||||
public DbSet<HealthArchiveSurgery> HealthArchiveSurgeries => Set<HealthArchiveSurgery>();
|
||||
|
||||
// 支撑表
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
|
||||
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
|
||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
|
||||
public DbSet<AiEntryDraftRecord> AiEntryDrafts => Set<AiEntryDraftRecord>();
|
||||
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
|
||||
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
|
||||
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
|
||||
@@ -48,6 +51,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
builder.Entity<User>(e =>
|
||||
{
|
||||
e.HasIndex(u => u.Phone).IsUnique();
|
||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识
|
||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
@@ -154,10 +158,32 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.HasIndex(n => n.UserId).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<UserNotification>(e =>
|
||||
{
|
||||
e.ToTable("UserNotifications");
|
||||
e.HasIndex(n => n.SourceId).IsUnique();
|
||||
e.HasIndex(n => new { n.UserId, n.IsDeleted, n.IsRead, n.CreatedAt });
|
||||
e.Property(n => n.Type).HasMaxLength(64);
|
||||
e.Property(n => n.Severity).HasMaxLength(16);
|
||||
e.Property(n => n.ActionType).HasMaxLength(64);
|
||||
});
|
||||
|
||||
// ---- HealthArchive ----
|
||||
builder.Entity<HealthArchive>(e =>
|
||||
{
|
||||
e.HasIndex(a => a.UserId).IsUnique();
|
||||
e.Property(a => a.SurgeryHistoryStatus).HasMaxLength(8);
|
||||
});
|
||||
|
||||
builder.Entity<HealthArchiveSurgery>(e =>
|
||||
{
|
||||
e.ToTable("HealthArchiveSurgeries");
|
||||
e.HasIndex(s => new { s.HealthArchiveId, s.SortOrder });
|
||||
e.Property(s => s.Type).HasMaxLength(256);
|
||||
e.HasOne(s => s.HealthArchive)
|
||||
.WithMany(a => a.Surgeries)
|
||||
.HasForeignKey(s => s.HealthArchiveId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<AiWriteCommandRecord>(e =>
|
||||
@@ -169,6 +195,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.Property(x => x.Arguments).HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
builder.Entity<AiEntryDraftRecord>(e =>
|
||||
{
|
||||
e.ToTable("AiEntryDrafts");
|
||||
e.HasIndex(x => new { x.UserId, x.ConversationId, x.EntryType, x.Status, x.ExpiresAt });
|
||||
e.Property(x => x.EntryType).HasMaxLength(32);
|
||||
e.Property(x => x.Status).HasMaxLength(32);
|
||||
e.Property(x => x.Payload).HasColumnType("jsonb");
|
||||
e.Property(x => x.MissingFields).HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
builder.Entity<ReportAnalysisTaskRecord>(e =>
|
||||
{
|
||||
e.ToTable("ReportAnalysisTasks");
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace Health.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库种子数据
|
||||
/// </summary>
|
||||
public static class DataSeeder
|
||||
{
|
||||
public static async Task SeedAsync(AppDbContext db)
|
||||
{
|
||||
// 医生不再使用种子数据,通过注册创建
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Health.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 开发环境测试数据填充。生产环境不要调用!
|
||||
/// 开关:DEVDATA_ENABLED=true 才会执行
|
||||
/// </summary>
|
||||
public static class DevDataSeeder
|
||||
{
|
||||
public static async Task SeedIfEnabled(AppDbContext db, IConfiguration config)
|
||||
{
|
||||
// 通过环境变量控制:DEVDATA_ENABLED=true 才填充测试数据
|
||||
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
|
||||
if (enabled != "true") return;
|
||||
|
||||
// 已有任何真实用户时跳过(避免污染真实数据)
|
||||
if (db.Users.Any(u => u.Phone != "13800000001")) return;
|
||||
|
||||
// 检查是否已有测试用户(避免重复填充)
|
||||
if (db.Users.Any(u => u.Phone == "13800000001")) return;
|
||||
|
||||
// ---- 种子医生数据 ----
|
||||
if (!db.Doctors.Any())
|
||||
{
|
||||
var doctor1 = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = "张明", Title = "主任医师",
|
||||
Department = "心脏康复科", Phone = "13800000002",
|
||||
ProfessionalDirection = "冠心病康复、术后管理",
|
||||
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var doctor2 = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = "李芳", Title = "副主任医师",
|
||||
Department = "营养科", Phone = "13800000003",
|
||||
ProfessionalDirection = "糖尿病管理、饮食指导",
|
||||
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var doctor3 = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = "王建国", Title = "主任医师",
|
||||
Department = "心血管内科", Phone = "13800000004",
|
||||
ProfessionalDirection = "高血压管理、PCI术后随访",
|
||||
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Doctors.AddRange(doctor1, doctor2, doctor3);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---- 创建测试患者 ----
|
||||
var doctorWang = db.Doctors.FirstOrDefault(d => d.Name == "王建国");
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(), Phone = "13800000001", Name = "张三",
|
||||
Gender = "男", BirthDate = new DateOnly(1970, 3, 15),
|
||||
DoctorId = doctorWang?.Id,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// ---- 健康档案 ----
|
||||
db.HealthArchives.Add(new HealthArchive
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id,
|
||||
Diagnosis = "冠心病", SurgeryType = "PCI支架植入术",
|
||||
SurgeryDate = new DateOnly(2026, 3, 15),
|
||||
Allergies = ["青霉素"], DietRestrictions = ["低盐", "低脂"],
|
||||
ChronicDiseases = ["高血压", "高血脂"],
|
||||
FamilyHistory = "父亲冠心病",
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
// ---- 健康数据(过去 7 天)----
|
||||
var random = new Random(42);
|
||||
for (int i = 7; i >= 0; i--)
|
||||
{
|
||||
var date = DateTime.UtcNow.AddDays(-i);
|
||||
// 血压
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
|
||||
Systolic = 120 + random.Next(-5, 15), Diastolic = 75 + random.Next(-5, 10),
|
||||
Unit = "mmHg", Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
// 心率
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.HeartRate,
|
||||
Value = 68 + random.Next(-5, 10), Unit = "次/分",
|
||||
Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
// 血糖
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.Glucose,
|
||||
Value = 5.0m + (decimal)(random.NextDouble() * 1.5),
|
||||
Unit = "mmol/L", Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
// 血氧
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.SpO2,
|
||||
Value = 96 + random.Next(0, 3), Unit = "%",
|
||||
Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
}
|
||||
// 一条异常血压
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
|
||||
Systolic = 148, Diastolic = 92, Unit = "mmHg",
|
||||
Source = HealthRecordSource.AiEntry, IsAbnormal = true,
|
||||
RecordedAt = DateTime.UtcNow.AddDays(-1),
|
||||
});
|
||||
|
||||
// ---- 用药计划 ----
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿司匹林", Dosage = "100mg",
|
||||
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)],
|
||||
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
|
||||
});
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿托伐他汀", Dosage = "20mg",
|
||||
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(20, 0)],
|
||||
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
|
||||
});
|
||||
|
||||
// ---- 运动计划 ----
|
||||
var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1));
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(6), ReminderTime = new TimeOnly(19, 0) };
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), ExerciseType = "散步", DurationMinutes = 30 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(3), IsRestDay = true });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(4), ExerciseType = "太极", DurationMinutes = 40 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(5), IsRestDay = true });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(6), ExerciseType = "散步", DurationMinutes = 30 });
|
||||
db.ExercisePlans.Add(plan);
|
||||
|
||||
// ---- 饮食记录 ----
|
||||
var lunch = new DietRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch,
|
||||
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
};
|
||||
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 });
|
||||
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 });
|
||||
db.DietRecords.Add(lunch);
|
||||
|
||||
// ---- 复查计划 ----
|
||||
db.FollowUps.Add(new FollowUp
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Title = "心内科复查",
|
||||
DoctorName = "王建国", Department = "心血管内科",
|
||||
ScheduledAt = DateTime.UtcNow.AddDays(3), Status = FollowUpStatus.Upcoming,
|
||||
});
|
||||
db.FollowUps.Add(new FollowUp
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Title = "术后3周复查",
|
||||
DoctorName = "王建国", Department = "心血管内科",
|
||||
ScheduledAt = DateTime.UtcNow.AddDays(-14), Status = FollowUpStatus.Completed,
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
Console.WriteLine($"[DEV] 测试数据已填充:用户 {user.Phone}");
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,28 @@ public sealed class DietImageAnalysisQueue(AppDbContext db) : IDietImageAnalysis
|
||||
return task.Id;
|
||||
}
|
||||
|
||||
public Task RecoverStaleAsync(CancellationToken ct)
|
||||
public async Task RecoverStaleAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return _db.DietImageAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
|
||||
var staleCutoff = now.AddMinutes(-5);
|
||||
|
||||
// 超时但仍有重试余量 → 重新排队
|
||||
await _db.DietImageAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Pending")
|
||||
.SetProperty(x => x.AvailableAt, now)
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
|
||||
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing(也让 WaitForResultAsync 能尽快返回失败)
|
||||
await _db.DietImageAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Failed")
|
||||
.SetProperty(x => x.ResultCode, 50001)
|
||||
.SetProperty(x => x.ResultMessage, "食物识别超时且已达最大重试次数")
|
||||
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
public async Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user