Compare commits
12 Commits
sccsbc
...
32aae40b12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32aae40b12 | ||
|
|
28f704c98e | ||
|
|
0cb5b8e85a | ||
|
|
9cea41705e | ||
|
|
0d4fd88ce7 | ||
|
|
ae94ced2d5 | ||
|
|
e1f4a4b91f | ||
|
|
6e5d3e64cd | ||
|
|
528859686f | ||
|
|
fade61ac21 | ||
|
|
e654c1e0cc | ||
|
|
335a3e6440 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,6 +46,7 @@ backend/src/Health.WebApi/data-protection-keys/
|
|||||||
*.txt
|
*.txt
|
||||||
*.jpg
|
*.jpg
|
||||||
*.png
|
*.png
|
||||||
|
!backend/src/Health.WebApi/wwwroot/app-icon.png
|
||||||
!health_app/assets/branding/*.png
|
!health_app/assets/branding/*.png
|
||||||
!health_app/android/app/src/main/res/drawable/launch_brand.png
|
!health_app/android/app/src/main/res/drawable/launch_brand.png
|
||||||
!health_app/android/app/src/main/res/drawable-v21/launch_brand.png
|
!health_app/android/app/src/main/res/drawable-v21/launch_brand.png
|
||||||
|
|||||||
57
AGENTS.md
Normal file
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
@@ -16,6 +16,14 @@ QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
|||||||
QWEN_API_KEY=sk-your-key-here
|
QWEN_API_KEY=sk-your-key-here
|
||||||
QWEN_VISION_MODEL=qwen-vl-max
|
QWEN_VISION_MODEL=qwen-vl-max
|
||||||
|
|
||||||
|
# 阿里云百炼实时语音输入(Fun-ASR)
|
||||||
|
DASHSCOPE_ASR_API_KEY=sk-your-dashscope-key-here
|
||||||
|
DASHSCOPE_ASR_WORKSPACE_ID=your-workspace-id
|
||||||
|
DASHSCOPE_ASR_MODEL=fun-asr-realtime
|
||||||
|
DASHSCOPE_ASR_MAX_DURATION_SECONDS=60
|
||||||
|
# 可选;不填时按 Workspace ID 自动生成北京地域专属地址
|
||||||
|
# DASHSCOPE_ASR_WEBSOCKET_URL=wss://your-workspace-id.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference
|
||||||
|
|
||||||
# MinIO
|
# MinIO
|
||||||
MINIO_ENDPOINT=localhost:9000
|
MINIO_ENDPOINT=localhost:9000
|
||||||
MINIO_ACCESS_KEY=minioadmin
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public interface IAiConversationService
|
|||||||
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||||
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||||
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
||||||
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||||
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
|
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ public interface IAiConversationRepository
|
|||||||
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||||
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
||||||
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
||||||
|
Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct);
|
||||||
void Delete(Conversation conversation);
|
void Delete(Conversation conversation);
|
||||||
Task SaveChangesAsync(CancellationToken ct);
|
Task SaveChangesAsync(CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace Health.Application.AI;
|
|||||||
|
|
||||||
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
||||||
{
|
{
|
||||||
private const int HistoryConversationLimit = 7;
|
private const int HistoryConversationLimit = 30;
|
||||||
private readonly IAiConversationRepository _conversations = conversations;
|
private readonly IAiConversationRepository _conversations = conversations;
|
||||||
|
|
||||||
public async Task<OpenConversationResult> OpenAsync(
|
public async Task<OpenConversationResult> OpenAsync(
|
||||||
@@ -55,6 +55,10 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await _conversations.DeleteLegacyDietCommentaryArtifactsAsync(
|
||||||
|
userId,
|
||||||
|
DietCommentaryPolicy.LegacyPromptPrefix,
|
||||||
|
ct);
|
||||||
var conversations = await _conversations.ListAsync(userId, ct);
|
var conversations = await _conversations.ListAsync(userId, ct);
|
||||||
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
||||||
}
|
}
|
||||||
@@ -79,13 +83,14 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
public async Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||||
if (conversation == null) return;
|
if (conversation == null) return false;
|
||||||
|
|
||||||
_conversations.Delete(conversation);
|
_conversations.Delete(conversation);
|
||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
||||||
|
|||||||
@@ -16,5 +16,5 @@ public interface IAttachmentContextBuilder
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
|
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
|
Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
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,30 +1,28 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Health.Application.HealthArchives;
|
using Health.Application.HealthArchives;
|
||||||
using Health.Application.HealthRecords;
|
using Health.Application.HealthRecords;
|
||||||
using Health.Application.Medications;
|
|
||||||
|
|
||||||
namespace Health.Application.AI;
|
namespace Health.Application.AI;
|
||||||
|
|
||||||
public sealed class PatientContextService(
|
public sealed class PatientContextService(
|
||||||
IHealthArchiveService archives,
|
IHealthArchiveService archives,
|
||||||
IHealthRecordService healthRecords,
|
IHealthRecordService healthRecords) : IPatientContextService
|
||||||
IMedicationService medications) : IPatientContextService
|
|
||||||
{
|
{
|
||||||
private readonly IHealthArchiveService _archives = archives;
|
private readonly IHealthArchiveService _archives = archives;
|
||||||
private readonly IHealthRecordService _healthRecords = healthRecords;
|
private readonly IHealthRecordService _healthRecords = healthRecords;
|
||||||
private readonly IMedicationService _medications = medications;
|
|
||||||
|
|
||||||
public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
|
public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var archive = await _archives.GetAsync(userId, ct);
|
var archive = await _archives.GetAsync(userId, ct);
|
||||||
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
|
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
|
||||||
var activeMedications = await _medications.ListActiveAsync(userId, ct);
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
if (archive != null)
|
if (archive != null)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||||
if (archive.Surgeries.Count > 0)
|
if (archive.SurgeryHistoryStatus == "无")
|
||||||
|
sb.AppendLine("手术史: 无");
|
||||||
|
else if (archive.Surgeries.Count > 0)
|
||||||
sb.AppendLine($"手术: {string.Join(";", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
|
sb.AppendLine($"手术: {string.Join(";", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
|
||||||
else if (!string.IsNullOrEmpty(archive.SurgeryType))
|
else if (!string.IsNullOrEmpty(archive.SurgeryType))
|
||||||
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||||
@@ -34,23 +32,11 @@ public sealed class PatientContextService(
|
|||||||
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
|
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeMedications.Count > 0)
|
|
||||||
{
|
|
||||||
sb.AppendLine("当前用药:");
|
|
||||||
foreach (var medication in activeMedications.Take(20))
|
|
||||||
{
|
|
||||||
var times = medication.TimeOfDay.Count > 0
|
|
||||||
? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm")))
|
|
||||||
: "未设置时间";
|
|
||||||
sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recentRecords.Count > 0)
|
if (recentRecords.Count > 0)
|
||||||
{
|
{
|
||||||
sb.AppendLine("近期健康数据:");
|
sb.AppendLine("近期健康数据:");
|
||||||
foreach (var record in recentRecords)
|
foreach (var record in recentRecords)
|
||||||
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})");
|
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({ToBeijing(record.RecordedAt):MM-dd HH:mm} 北京时间)");
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -65,4 +51,15 @@ public sealed class PatientContextService(
|
|||||||
"Weight" => $"{record.Value}kg",
|
"Weight" => $"{record.Value}kg",
|
||||||
_ => "—"
|
_ => "—"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static DateTime ToBeijing(DateTime value)
|
||||||
|
{
|
||||||
|
var utc = value.Kind switch
|
||||||
|
{
|
||||||
|
DateTimeKind.Utc => value,
|
||||||
|
DateTimeKind.Local => value.ToUniversalTime(),
|
||||||
|
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
|
||||||
|
};
|
||||||
|
return utc.AddHours(8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
|||||||
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
|
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
|
||||||
.ToList();
|
.ToList();
|
||||||
var followUps = snapshot.FollowUps
|
var followUps = snapshot.FollowUps
|
||||||
.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)
|
.Where(f => BeijingDate(f.ScheduledAt) == date)
|
||||||
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
|
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
|||||||
entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate }));
|
entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate }));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date))
|
foreach (var followUp in snapshot.FollowUps.Where(f => BeijingDate(f.ScheduledAt) == date))
|
||||||
{
|
{
|
||||||
entries.Add(new CalendarEntry("followup", new
|
entries.Add(new CalendarEntry("followup", new
|
||||||
{
|
{
|
||||||
@@ -88,5 +88,14 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
|||||||
&& (medication.StartDate == null || medication.StartDate <= date)
|
&& (medication.StartDate == null || medication.StartDate <= date)
|
||||||
&& (medication.EndDate == null || medication.EndDate >= date);
|
&& (medication.EndDate == null || medication.EndDate >= date);
|
||||||
|
|
||||||
|
private static DateOnly BeijingDate(DateTime value)
|
||||||
|
{
|
||||||
|
// EF 从 timestamptz 读取的是 UTC;单元测试及旧内存对象可能是 Unspecified。
|
||||||
|
var beijing = value.Kind == DateTimeKind.Unspecified
|
||||||
|
? value
|
||||||
|
: value.ToUniversalTime().AddHours(8);
|
||||||
|
return DateOnly.FromDateTime(beijing);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record CalendarEntry(string Type, object Value);
|
private sealed record CalendarEntry(string Type, object Value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,19 +42,19 @@ public interface IExerciseService
|
|||||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
||||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||||
|
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct);
|
||||||
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||||
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||||
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||||
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||||
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IExerciseRepository
|
public interface IExerciseRepository
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
|
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||||
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
|
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
|
||||||
|
Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct);
|
||||||
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
|
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||||
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
|
|
||||||
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
|
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||||
Task AddAsync(ExercisePlan plan, CancellationToken ct);
|
Task AddAsync(ExercisePlan plan, CancellationToken ct);
|
||||||
void Delete(ExercisePlan plan);
|
void Delete(ExercisePlan plan);
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
|||||||
{
|
{
|
||||||
plan.Items.Add(new ExercisePlanItem
|
plan.Items.Add(new ExercisePlanItem
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType,
|
Id = Guid.NewGuid(),
|
||||||
DurationMinutes = duration, IsRestDay = false,
|
ScheduledDate = date,
|
||||||
|
ExerciseType = exerciseType,
|
||||||
|
DurationMinutes = duration,
|
||||||
|
IsRestDay = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await SaveNewAsync(plan, ct);
|
await SaveNewAsync(plan, ct);
|
||||||
@@ -52,10 +55,13 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
|||||||
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
|
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var plans = await _exercises.ListAsync(userId, 20, ct);
|
var plans = await _exercises.ListAsync(userId, 20, ct);
|
||||||
return plans.Select(p => new ExercisePlanSummaryDto(
|
return plans.Select(ToSummaryDto).ToList();
|
||||||
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt,
|
}
|
||||||
p.Items.Count, p.Items.Count(i => i.IsCompleted),
|
|
||||||
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList();
|
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var plans = await _exercises.ListAllAsync(userId, ct);
|
||||||
|
return plans.Select(ToSummaryDto).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
|
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
|
||||||
@@ -103,12 +109,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var plan = await _exercises.GetLatestAsync(userId, ct);
|
|
||||||
return plan == null ? null : ToDto(plan);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
|
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _exercises.AddAsync(plan, ct);
|
await _exercises.AddAsync(plan, ct);
|
||||||
@@ -117,15 +117,24 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
|||||||
|
|
||||||
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
|
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate,
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
StartDate = startDate,
|
||||||
|
EndDate = endDate,
|
||||||
ReminderTime = reminderTime ?? DefaultReminderTime,
|
ReminderTime = reminderTime ?? DefaultReminderTime,
|
||||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
|
private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
|
||||||
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
|
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
|
||||||
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
|
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
|
||||||
|
|
||||||
|
private static ExercisePlanSummaryDto ToSummaryDto(ExercisePlan plan) => new(
|
||||||
|
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt,
|
||||||
|
plan.Items.Count, plan.Items.Count(i => i.IsCompleted),
|
||||||
|
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
|
||||||
|
|
||||||
private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
|
private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
|
||||||
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
|
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
|
||||||
item.IsCompleted, item.CompletedAt, item.IsRestDay);
|
item.IsCompleted, item.CompletedAt, item.IsRestDay);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed record HealthArchiveDto(
|
|||||||
IReadOnlyList<string> DietRestrictions,
|
IReadOnlyList<string> DietRestrictions,
|
||||||
IReadOnlyList<string> ChronicDiseases,
|
IReadOnlyList<string> ChronicDiseases,
|
||||||
string? FamilyHistory,
|
string? FamilyHistory,
|
||||||
|
string? SurgeryHistoryStatus,
|
||||||
DateTime UpdatedAt);
|
DateTime UpdatedAt);
|
||||||
|
|
||||||
public sealed record HealthArchiveUpdateRequest(
|
public sealed record HealthArchiveUpdateRequest(
|
||||||
@@ -32,7 +33,8 @@ public sealed record HealthArchiveUpdateRequest(
|
|||||||
IReadOnlyList<string>? DietRestrictions,
|
IReadOnlyList<string>? DietRestrictions,
|
||||||
IReadOnlyList<string>? ChronicDiseases,
|
IReadOnlyList<string>? ChronicDiseases,
|
||||||
string? FamilyHistory,
|
string? FamilyHistory,
|
||||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
|
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null,
|
||||||
|
string? SurgeryHistoryStatus = null);
|
||||||
|
|
||||||
public interface IHealthArchiveService
|
public interface IHealthArchiveService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
|
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Health.Application.HealthArchives;
|
namespace Health.Application.HealthArchives;
|
||||||
|
|
||||||
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
||||||
@@ -14,6 +16,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
|
|
||||||
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
Validate(request);
|
||||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||||
Apply(archive, request);
|
Apply(archive, request);
|
||||||
archive.UpdatedAt = DateTime.UtcNow;
|
archive.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -110,6 +113,14 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
||||||
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
||||||
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
|
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
|
private static object ToAiResult(HealthArchiveDto archive) => new
|
||||||
@@ -123,6 +134,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
archive.DietRestrictions,
|
archive.DietRestrictions,
|
||||||
archive.ChronicDiseases,
|
archive.ChronicDiseases,
|
||||||
archive.FamilyHistory,
|
archive.FamilyHistory,
|
||||||
|
archive.SurgeryHistoryStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
|
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
|
||||||
@@ -136,6 +148,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
archive.DietRestrictions,
|
archive.DietRestrictions,
|
||||||
archive.ChronicDiseases,
|
archive.ChronicDiseases,
|
||||||
archive.FamilyHistory,
|
archive.FamilyHistory,
|
||||||
|
archive.SurgeryHistoryStatus,
|
||||||
archive.UpdatedAt);
|
archive.UpdatedAt);
|
||||||
|
|
||||||
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public interface IHealthRecordService
|
|||||||
{
|
{
|
||||||
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
||||||
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, 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> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||||
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
||||||
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public static class HealthRecordRules
|
|||||||
{
|
{
|
||||||
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
||||||
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
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.SpO2 => request.Value <= 94,
|
||||||
HealthMetricType.Weight => false,
|
HealthMetricType.Weight => false,
|
||||||
_ => false
|
_ => false
|
||||||
@@ -58,4 +58,3 @@ public static class HealthRecordRules
|
|||||||
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,44 @@ public sealed class HealthRecordService(
|
|||||||
return record.Id;
|
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)
|
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||||
@@ -101,6 +139,7 @@ public sealed class HealthRecordService(
|
|||||||
latest.Diastolic,
|
latest.Diastolic,
|
||||||
latest.Value,
|
latest.Value,
|
||||||
latest.Unit,
|
latest.Unit,
|
||||||
|
latest.IsAbnormal,
|
||||||
latest.RecordedAt
|
latest.RecordedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -140,7 +179,7 @@ public sealed class HealthRecordService(
|
|||||||
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
||||||
HealthMetricType.HeartRate =>
|
HealthMetricType.HeartRate =>
|
||||||
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
||||||
HealthMetricType.Glucose when record.Value >= 7.0m =>
|
HealthMetricType.Glucose when record.Value > 6.1m =>
|
||||||
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
||||||
HealthMetricType.Glucose =>
|
HealthMetricType.Glucose =>
|
||||||
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public sealed record MedicationPatchRequest(
|
|||||||
IReadOnlyList<TimeOnly>? TimeOfDay,
|
IReadOnlyList<TimeOnly>? TimeOfDay,
|
||||||
DateOnly? StartDate,
|
DateOnly? StartDate,
|
||||||
DateOnly? EndDate,
|
DateOnly? EndDate,
|
||||||
string? Notes);
|
string? Notes,
|
||||||
|
bool ClearEndDate = false);
|
||||||
|
|
||||||
public sealed record MedicationDto(
|
public sealed record MedicationDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -44,6 +45,21 @@ public sealed record MedicationReminderDto(
|
|||||||
string ScheduledTime,
|
string ScheduledTime,
|
||||||
string Status);
|
string Status);
|
||||||
|
|
||||||
|
public sealed record AiMedicationDoseDto(
|
||||||
|
string ScheduledTime,
|
||||||
|
string Status);
|
||||||
|
|
||||||
|
public sealed record AiMedicationPlanDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Dosage,
|
||||||
|
string Frequency,
|
||||||
|
DateOnly? StartDate,
|
||||||
|
DateOnly? EndDate,
|
||||||
|
string Phase,
|
||||||
|
bool ScheduledOnDate,
|
||||||
|
IReadOnlyList<AiMedicationDoseDto> Doses);
|
||||||
|
|
||||||
public sealed record MedicationReminderTask(
|
public sealed record MedicationReminderTask(
|
||||||
Guid TaskId,
|
Guid TaskId,
|
||||||
Guid UserId,
|
Guid UserId,
|
||||||
@@ -59,12 +75,10 @@ public interface IMedicationService
|
|||||||
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
|
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
|
||||||
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
|
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
|
||||||
Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||||
Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
|
||||||
Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
|
Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
|
||||||
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct);
|
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct);
|
||||||
Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
|
Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
|
||||||
Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct);
|
Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||||
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IMedicationRepository
|
public interface IMedicationRepository
|
||||||
@@ -73,8 +87,6 @@ public interface IMedicationRepository
|
|||||||
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
|
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
|
||||||
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||||
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||||
Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
|
||||||
Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
|
||||||
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||||
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||||
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
|
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,8 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
|||||||
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
|
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
|
||||||
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
|
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
|
||||||
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
||||||
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
if (request.ClearEndDate) med.EndDate = null;
|
||||||
|
else if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
||||||
ValidateDateRange(med.StartDate, med.EndDate);
|
ValidateDateRange(med.StartDate, med.EndDate);
|
||||||
if (request.Notes != null) med.Notes = request.Notes;
|
if (request.Notes != null) med.Notes = request.Notes;
|
||||||
med.UpdatedAt = DateTime.UtcNow;
|
med.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -95,23 +96,6 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
|
||||||
|
|
||||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
|
||||||
var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct);
|
|
||||||
if (existing != null)
|
|
||||||
{
|
|
||||||
_medications.DeleteLog(existing);
|
|
||||||
await _medications.SaveChangesAsync(ct);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
|
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||||
@@ -130,15 +114,10 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
|||||||
foreach (var scheduledTime in med.TimeOfDay)
|
foreach (var scheduledTime in med.TimeOfDay)
|
||||||
{
|
{
|
||||||
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
|
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
|
||||||
string status;
|
var status = MedicationScheduleStatus.Resolve(
|
||||||
if (log != null)
|
scheduledTime,
|
||||||
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
|
now,
|
||||||
else if (scheduledTime <= now.AddMinutes(-30))
|
log?.Status);
|
||||||
status = "overdue";
|
|
||||||
else if (scheduledTime <= now.AddHours(3))
|
|
||||||
status = "upcoming";
|
|
||||||
else
|
|
||||||
continue;
|
|
||||||
|
|
||||||
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
|
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
|
||||||
}
|
}
|
||||||
@@ -151,7 +130,14 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
|||||||
|
|
||||||
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
|
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
var medication = await _medications.GetOwnedAsync(userId, medicationId, ct);
|
||||||
|
if (medication == null) return null;
|
||||||
|
|
||||||
|
var today = BeijingToday();
|
||||||
|
if (!IsActiveOn(medication, today) ||
|
||||||
|
!GetDosesForToday(medication, today) ||
|
||||||
|
!medication.TimeOfDay.Contains(scheduledTime))
|
||||||
|
return false;
|
||||||
|
|
||||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||||
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
|
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
|
||||||
@@ -172,18 +158,48 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) =>
|
public async Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(
|
||||||
ListAsync(userId, "active", ct);
|
Guid userId,
|
||||||
|
DateOnly date,
|
||||||
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await ConfirmDoseAsync(
|
var medications = await _medications.ListAsync(userId, null, ct);
|
||||||
userId,
|
var (startUtc, endUtc) = GetBeijingDateUtcWindow(date);
|
||||||
medicationId,
|
var logs = await _medications.ListLogsInWindowAsync(userId, startUtc, endUtc, ct);
|
||||||
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
var today = BeijingToday();
|
||||||
MedicationLogStatus.Taken,
|
var now = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
ct);
|
|
||||||
return result == true;
|
return medications.Select(medication =>
|
||||||
|
{
|
||||||
|
var phase = ResolvePhase(medication, date);
|
||||||
|
var scheduled = phase == "active" && GetDosesForToday(medication, date);
|
||||||
|
var doses = scheduled
|
||||||
|
? medication.TimeOfDay.OrderBy(time => time).Select(time =>
|
||||||
|
{
|
||||||
|
var log = logs.FirstOrDefault(item =>
|
||||||
|
item.MedicationId == medication.Id && item.ScheduledTime == time);
|
||||||
|
var status = log != null
|
||||||
|
? log.Status.ToString().ToLowerInvariant()
|
||||||
|
: date < today
|
||||||
|
? "missed"
|
||||||
|
: date > today
|
||||||
|
? "scheduled"
|
||||||
|
: MedicationScheduleStatus.Resolve(time, now, null);
|
||||||
|
return new AiMedicationDoseDto(time.ToString("HH:mm"), status);
|
||||||
|
}).ToList()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return new AiMedicationPlanDto(
|
||||||
|
medication.Id,
|
||||||
|
medication.Name,
|
||||||
|
medication.Dosage,
|
||||||
|
medication.Frequency.ToString(),
|
||||||
|
medication.StartDate,
|
||||||
|
medication.EndDate,
|
||||||
|
phase,
|
||||||
|
scheduled,
|
||||||
|
doses);
|
||||||
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct)
|
private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct)
|
||||||
@@ -221,9 +237,28 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
|||||||
|
|
||||||
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
|
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
|
||||||
{
|
{
|
||||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
return GetBeijingDateUtcWindow(BeijingToday());
|
||||||
var todayStartUtc = beijingToday.AddHours(-8);
|
}
|
||||||
return (todayStartUtc, todayStartUtc.AddDays(1));
|
|
||||||
|
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingDateUtcWindow(DateOnly date)
|
||||||
|
{
|
||||||
|
var startUtc = DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue).AddHours(-8), DateTimeKind.Utc);
|
||||||
|
return (startUtc, startUtc.AddDays(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
|
||||||
|
private static bool IsActiveOn(Medication medication, DateOnly date) =>
|
||||||
|
medication.IsActive &&
|
||||||
|
(!medication.StartDate.HasValue || medication.StartDate.Value <= date) &&
|
||||||
|
(!medication.EndDate.HasValue || medication.EndDate.Value >= date);
|
||||||
|
|
||||||
|
private static string ResolvePhase(Medication medication, DateOnly date)
|
||||||
|
{
|
||||||
|
if (!medication.IsActive) return "inactive";
|
||||||
|
if (medication.StartDate.HasValue && medication.StartDate.Value > date) return "upcoming";
|
||||||
|
if (medication.EndDate.HasValue && medication.EndDate.Value < date) return "ended";
|
||||||
|
return "active";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool GetDosesForToday(Medication med, DateOnly today)
|
private static bool GetDosesForToday(Medication med, DateOnly today)
|
||||||
|
|||||||
@@ -58,3 +58,10 @@ public interface INotificationOutboxProcessor
|
|||||||
Task RecoverStaleAsync(CancellationToken ct);
|
Task RecoverStaleAsync(CancellationToken ct);
|
||||||
Task<bool> ProcessNextAsync(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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public sealed class ReportService(
|
|||||||
public static ReportDto ToDto(Report report) => new(
|
public static ReportDto ToDto(Report report) => new(
|
||||||
report.Id,
|
report.Id,
|
||||||
report.UserId,
|
report.UserId,
|
||||||
report.FileUrl,
|
$"/api/reports/{report.Id}/file",
|
||||||
report.FileType.ToString(),
|
report.FileType.ToString(),
|
||||||
report.Category.ToString(),
|
report.Category.ToString(),
|
||||||
report.Status.ToString(),
|
report.Status.ToString(),
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
|
|
||||||
|
namespace Health.Application.Speech;
|
||||||
|
|
||||||
|
public interface IRealtimeSpeechRecognitionProxy
|
||||||
|
{
|
||||||
|
bool IsConfigured { get; }
|
||||||
|
|
||||||
|
Task ProxyAsync(WebSocket clientSocket, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ public sealed record UserProfileUpdateRequest(
|
|||||||
string? Gender,
|
string? Gender,
|
||||||
DateOnly? BirthDate);
|
DateOnly? BirthDate);
|
||||||
|
|
||||||
|
public sealed record AccountFileReferences(
|
||||||
|
IReadOnlyList<string> FileUrls,
|
||||||
|
IReadOnlyList<string> ConversationMetadataJson);
|
||||||
|
|
||||||
public interface IUserService
|
public interface IUserService
|
||||||
{
|
{
|
||||||
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
||||||
@@ -26,6 +30,12 @@ public interface IUserService
|
|||||||
public interface IUserRepository
|
public interface IUserRepository
|
||||||
{
|
{
|
||||||
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
||||||
|
Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct);
|
||||||
Task SaveChangesAsync(CancellationToken ct);
|
Task SaveChangesAsync(CancellationToken ct);
|
||||||
Task DeleteAccountDataAsync(Guid userId, 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;
|
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 IUserRepository _users = users;
|
||||||
|
private readonly IAccountFileCleanup _fileCleanup = fileCleanup;
|
||||||
|
|
||||||
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -30,6 +33,10 @@ public sealed class UserService(IUserRepository users) : IUserService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
|
public async Task DeleteAccountAsync(Guid userId, CancellationToken ct)
|
||||||
_users.DeleteAccountDataAsync(userId, ct);
|
{
|
||||||
|
var references = await _users.GetAccountFileReferencesAsync(userId, ct);
|
||||||
|
await _fileCleanup.DeleteAsync(userId, references, ct);
|
||||||
|
await _users.DeleteAccountDataAsync(userId, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public sealed class HealthArchive
|
|||||||
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
||||||
public List<string> ChronicDiseases { get; set; } = []; // 慢病史
|
public List<string> ChronicDiseases { get; set; } = []; // 慢病史
|
||||||
public string? FamilyHistory { get; set; } // 家族病史
|
public string? FamilyHistory { get; set; } // 家族病史
|
||||||
|
public string? SurgeryHistoryStatus { get; set; } // 无/有,区分未填写与无手术史
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public User User { get; set; } = null!;
|
public User User { get; set; } = null!;
|
||||||
@@ -89,6 +90,10 @@ public sealed class NotificationPreference
|
|||||||
public bool FollowUpReminder { get; set; } = true;
|
public bool FollowUpReminder { get; set; } = true;
|
||||||
public bool DoctorReply { get; set; } = true;
|
public bool DoctorReply { get; set; } = true;
|
||||||
public bool AbnormalAlert { get; set; } = true;
|
public bool AbnormalAlert { get; set; } = true;
|
||||||
|
public bool PushEnabled { get; set; } = true;
|
||||||
|
public bool DndEnabled { get; set; }
|
||||||
|
public int DndStartMinutes { get; set; } = 22 * 60;
|
||||||
|
public int DndEndMinutes { get; set; } = 8 * 60;
|
||||||
// 健康录入提醒——总开关 + 5 子项
|
// 健康录入提醒——总开关 + 5 子项
|
||||||
public bool HealthRecordReminder { get; set; } = true;
|
public bool HealthRecordReminder { get; set; } = true;
|
||||||
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -12,8 +12,21 @@ public static class CommonAgentHandler
|
|||||||
{
|
{
|
||||||
Function = new()
|
Function = new()
|
||||||
{
|
{
|
||||||
Name = "query_health_records", Description = "查询近期健康数据",
|
Name = "query_health_records",
|
||||||
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } }
|
Description = "查询近期健康数据",
|
||||||
|
Parameters = new
|
||||||
|
{
|
||||||
|
type = "object",
|
||||||
|
properties = new
|
||||||
|
{
|
||||||
|
type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight", "all" }, description = "健康指标类型" },
|
||||||
|
scope = new { type = "string", @enum = new[] { "today", "yesterday", "date", "recent_days", "range" }, description = "日期范围,默认 recent_days" },
|
||||||
|
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
|
||||||
|
start_date = new { type = "string", description = "scope=range 时的北京时间开始日期 yyyy-MM-dd" },
|
||||||
|
end_date = new { type = "string", description = "scope=range 时的北京时间结束日期 yyyy-MM-dd(包含)" },
|
||||||
|
days = new { type = "integer", description = "scope=recent_days 时的天数,1到365" },
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,10 +55,67 @@ public static class CommonAgentHandler
|
|||||||
|
|
||||||
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
|
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
var rawType = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||||
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
if (!TryNormalizeMetricType(rawType, out var metricType))
|
||||||
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
|
return new { success = false, message = $"无法识别健康指标类型: {rawType}" };
|
||||||
return new { count = records.Count, records };
|
|
||||||
|
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||||
|
? scopeValue.GetString()?.ToLowerInvariant()
|
||||||
|
: "recent_days";
|
||||||
|
DateTime? startUtc = null;
|
||||||
|
DateTime? endUtc = null;
|
||||||
|
int days;
|
||||||
|
|
||||||
|
switch (scope)
|
||||||
|
{
|
||||||
|
case "today":
|
||||||
|
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
|
||||||
|
days = 2;
|
||||||
|
break;
|
||||||
|
case "yesterday":
|
||||||
|
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday.AddDays(-1));
|
||||||
|
days = 3;
|
||||||
|
break;
|
||||||
|
case "date":
|
||||||
|
if (!TryReadDate(args, "date", out var date))
|
||||||
|
return new { success = false, message = "查询指定日期时必须提供有效的 date" };
|
||||||
|
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
|
||||||
|
days = DaysSince(startUtc.Value);
|
||||||
|
break;
|
||||||
|
case "range":
|
||||||
|
if (!TryReadDate(args, "start_date", out var startDate) ||
|
||||||
|
!TryReadDate(args, "end_date", out var endDate) || endDate < startDate)
|
||||||
|
return new { success = false, message = "必须提供有效的 start_date 和 end_date,且结束日期不能早于开始日期" };
|
||||||
|
startUtc = AiDateTime.BeijingDateWindowUtc(startDate).StartUtc;
|
||||||
|
endUtc = AiDateTime.BeijingDateWindowUtc(endDate).EndUtc;
|
||||||
|
days = DaysSince(startUtc.Value);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
days = args.TryGetProperty("days", out var d) && d.TryGetInt32(out var parsedDays)
|
||||||
|
? Math.Clamp(parsedDays, 1, 365)
|
||||||
|
: 7;
|
||||||
|
scope = "recent_days";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var records = await healthRecords.GetRecordsAsync(userId, metricType, days, ct);
|
||||||
|
var filtered = records
|
||||||
|
.Where(record => !startUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) >= startUtc.Value)
|
||||||
|
.Where(record => !endUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) < endUtc.Value)
|
||||||
|
.Select(record => new
|
||||||
|
{
|
||||||
|
record.Id,
|
||||||
|
record.Type,
|
||||||
|
record.Systolic,
|
||||||
|
record.Diastolic,
|
||||||
|
record.Value,
|
||||||
|
record.Unit,
|
||||||
|
record.Source,
|
||||||
|
record.IsAbnormal,
|
||||||
|
recorded_at_beijing = AiDateTime.ToBeijingText(record.RecordedAt),
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
return new { success = true, scope, count = filtered.Count, records = filtered };
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
|
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
|
||||||
@@ -54,55 +124,39 @@ public static class CommonAgentHandler
|
|||||||
if (archive == null) return new { found = false };
|
if (archive == null) return new { found = false };
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
found = true, archive.Diagnosis, archive.SurgeryType,
|
found = true,
|
||||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries,
|
archive.Diagnosis,
|
||||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
archive.SurgeryType,
|
||||||
|
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||||
|
archive.Surgeries,
|
||||||
|
archive.Allergies,
|
||||||
|
archive.DietRestrictions,
|
||||||
|
archive.ChronicDiseases,
|
||||||
|
archive.FamilyHistory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static readonly ToolDefinition ManageArchiveTool = new()
|
private static bool TryNormalizeMetricType(string? value, out string? normalized)
|
||||||
{
|
{
|
||||||
Function = new()
|
normalized = value?.Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
Name = "manage_archive", Description = "管理用户健康档案",
|
null or "" or "all" or "全部" => null,
|
||||||
Parameters = new
|
"bloodpressure" or "blood_pressure" or "血压" => "BloodPressure",
|
||||||
{
|
"heartrate" or "heart_rate" or "心率" or "脉搏" => "HeartRate",
|
||||||
type = "object",
|
"glucose" or "blood_glucose" or "血糖" => "Glucose",
|
||||||
properties = new
|
"spo2" or "blood_oxygen" or "血氧" or "血氧饱和度" => "SpO2",
|
||||||
{
|
"weight" or "体重" => "Weight",
|
||||||
action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" },
|
_ => "__invalid__",
|
||||||
diagnosis = new { type = "string" },
|
};
|
||||||
surgery_type = new { type = "string" },
|
return normalized != "__invalid__";
|
||||||
surgery_date = new { type = "string" },
|
|
||||||
allergies = new { type = "array", items = new { type = "string" } },
|
|
||||||
chronic_diseases = new { type = "array", items = new { type = "string" } },
|
|
||||||
diet_restrictions = new { type = "array", items = new { type = "string" } },
|
|
||||||
},
|
|
||||||
required = new[] { "action" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public static Task<object> ExecuteManageArchive(
|
|
||||||
IHealthArchiveService archives,
|
|
||||||
Guid userId,
|
|
||||||
JsonElement args,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
|
||||||
var request = new HealthArchiveUpdateRequest(
|
|
||||||
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
|
|
||||||
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
|
|
||||||
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
|
|
||||||
ReadStringArray(args, "allergies"),
|
|
||||||
ReadStringArray(args, "diet_restrictions"),
|
|
||||||
ReadStringArray(args, "chronic_diseases"),
|
|
||||||
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
|
|
||||||
return archives.ExecuteAiActionAsync(userId, action, request, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
|
private static bool TryReadDate(JsonElement args, string name, out DateOnly date)
|
||||||
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
|
{
|
||||||
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
|
date = default;
|
||||||
: null;
|
return args.TryGetProperty(name, out var value) && DateOnly.TryParse(value.GetString(), out date);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int DaysSince(DateTime startUtc) =>
|
||||||
|
Math.Clamp((int)Math.Ceiling((DateTime.UtcNow - startUtc).TotalDays) + 1, 1, 3650);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ public static class ExerciseAgentHandler
|
|||||||
type = "object",
|
type = "object",
|
||||||
properties = new
|
properties = new
|
||||||
{
|
{
|
||||||
action = new { type = "string", description = "create/query/checkin" },
|
action = new { type = "string", @enum = new[] { "create", "query", "checkin" }, description = "create/query/checkin" },
|
||||||
|
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "all_plans" }, description = "查询时必填:今日任务/指定日期任务/当前计划/即将开始/已结束/全部计划" },
|
||||||
|
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
|
||||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd,默认今天" },
|
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd,默认今天" },
|
||||||
duration_days = new { type = "integer", description = "连续运动天数,如一周为7天" },
|
duration_days = new { type = "integer", description = "连续运动天数,如一周为7天" },
|
||||||
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
|
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
|
||||||
@@ -33,15 +35,16 @@ public static class ExerciseAgentHandler
|
|||||||
public static async Task<object> Execute(
|
public static async Task<object> Execute(
|
||||||
string toolName,
|
string toolName,
|
||||||
JsonElement args,
|
JsonElement args,
|
||||||
AppDbContext db,
|
|
||||||
Guid userId,
|
Guid userId,
|
||||||
IExerciseService? exercises = null,
|
IExerciseService exercises,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (toolName != "manage_exercise" || exercises == null)
|
if (toolName != "manage_exercise")
|
||||||
return new { success = false, message = $"未知工具: {toolName}" };
|
return new { success = false, message = $"未知工具: {toolName}" };
|
||||||
|
|
||||||
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
|
var action = args.TryGetProperty("action", out var actionValue)
|
||||||
|
? actionValue.GetString()?.ToLowerInvariant()
|
||||||
|
: null;
|
||||||
if (action == "create")
|
if (action == "create")
|
||||||
{
|
{
|
||||||
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
|
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
|
||||||
@@ -67,17 +70,83 @@ public static class ExerciseAgentHandler
|
|||||||
return success ? new { success = true } : new { success = false, message = "条目不存在" };
|
return success ? new { success = true } : new { success = false, message = "条目不存在" };
|
||||||
}
|
}
|
||||||
|
|
||||||
var plan = await exercises.GetLatestAsync(userId, ct);
|
if (action != "query")
|
||||||
return plan == null
|
return new { success = false, message = $"未知操作: {action}" };
|
||||||
? new { found = false }
|
|
||||||
: new
|
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||||
|
? scopeValue.GetString()?.ToLowerInvariant()
|
||||||
|
: null;
|
||||||
|
if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "all_plans"))
|
||||||
|
return new
|
||||||
{
|
{
|
||||||
found = true,
|
success = false,
|
||||||
plan_id = plan.Id,
|
message = "查询运动计划时必须明确 scope:today、scheduled_date、current_plans、upcoming_plans、ended_plans 或 all_plans",
|
||||||
plan.StartDate,
|
|
||||||
plan.EndDate,
|
|
||||||
plan.ReminderTime,
|
|
||||||
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
|
|
||||||
};
|
};
|
||||||
|
if (scope == "scheduled_date" &&
|
||||||
|
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
|
||||||
|
return new { success = false, message = "查询指定日期的运动安排时必须提供有效的 date" };
|
||||||
|
var queryDate = scope == "scheduled_date"
|
||||||
|
? DateOnly.Parse(args.GetProperty("date").GetString()!)
|
||||||
|
: AiDateTime.BeijingToday;
|
||||||
|
var isDateScope = scope is "today" or "scheduled_date";
|
||||||
|
var plans = await exercises.ListAllAsync(userId, ct);
|
||||||
|
var overview = plans.Select(plan => new
|
||||||
|
{
|
||||||
|
plan_id = plan.Id,
|
||||||
|
plan.StartDate,
|
||||||
|
plan.EndDate,
|
||||||
|
phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active",
|
||||||
|
plan.ReminderTime,
|
||||||
|
total_items = plan.Items.Count,
|
||||||
|
completed_items = plan.Items.Count(item => item.IsCompleted),
|
||||||
|
returned_items = isDateScope
|
||||||
|
? plan.Items.Count(item => item.ScheduledDate == queryDate)
|
||||||
|
: Math.Min(plan.Items.Count, 14),
|
||||||
|
items_truncated = !isDateScope && plan.Items.Count > 14,
|
||||||
|
items = plan.Items
|
||||||
|
.Where(item => !isDateScope || item.ScheduledDate == queryDate)
|
||||||
|
.OrderBy(item => item.ScheduledDate)
|
||||||
|
.Take(isDateScope ? int.MaxValue : 14)
|
||||||
|
.Select(item => new
|
||||||
|
{
|
||||||
|
item.Id,
|
||||||
|
item.ScheduledDate,
|
||||||
|
item.ExerciseType,
|
||||||
|
item.DurationMinutes,
|
||||||
|
item.IsCompleted,
|
||||||
|
item.IsRestDay,
|
||||||
|
completed_at_beijing = item.CompletedAt.HasValue ? AiDateTime.ToBeijingText(item.CompletedAt.Value) : null,
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
});
|
||||||
|
var filtered = scope switch
|
||||||
|
{
|
||||||
|
"all_plans" => overview,
|
||||||
|
"current_plans" => overview.Where(plan => plan.phase == "active"),
|
||||||
|
"upcoming_plans" => overview
|
||||||
|
.Where(plan => plan.phase == "upcoming")
|
||||||
|
.OrderBy(plan => plan.StartDate),
|
||||||
|
"ended_plans" => overview
|
||||||
|
.Where(plan => plan.phase == "ended")
|
||||||
|
.OrderByDescending(plan => plan.EndDate),
|
||||||
|
_ => overview.Where(plan => plan.phase == "active" && plan.items.Count > 0),
|
||||||
|
};
|
||||||
|
var result = filtered.ToList();
|
||||||
|
var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList();
|
||||||
|
var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault();
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ public static class HealthDataAgentHandler
|
|||||||
{
|
{
|
||||||
Function = new()
|
Function = new()
|
||||||
{
|
{
|
||||||
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
|
Name = "record_health_data",
|
||||||
Parameters = new { type = "object", properties = new { type = new { type = "string" }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" } }, required = new[] { "type" } }
|
Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
|
||||||
|
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" } }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,8 +35,9 @@ public static class HealthDataAgentHandler
|
|||||||
if (metricType == null)
|
if (metricType == null)
|
||||||
return new { success = false, message = $"未知指标类型: {type}" };
|
return new { success = false, message = $"未知指标类型: {type}" };
|
||||||
|
|
||||||
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt)
|
var recordedAt = args.TryGetProperty("recorded_at", out var ra) &&
|
||||||
? dt
|
AiDateTime.TryParseUserDateTime(ra.GetString(), out var parsedRecordedAt)
|
||||||
|
? parsedRecordedAt
|
||||||
: DateTime.UtcNow;
|
: DateTime.UtcNow;
|
||||||
|
|
||||||
var request = new HealthRecordUpsertRequest(
|
var request = new HealthRecordUpsertRequest(
|
||||||
|
|||||||
@@ -12,19 +12,23 @@ public static class MedicationAgentHandler
|
|||||||
Function = new()
|
Function = new()
|
||||||
{
|
{
|
||||||
Name = "manage_medication",
|
Name = "manage_medication",
|
||||||
Description = "用药管理(创建/查询/确认服药)",
|
Description = "用药管理(创建计划、按日期查询计划与逐顿服药状态、确认某一顿服药)",
|
||||||
Parameters = new
|
Parameters = new
|
||||||
{
|
{
|
||||||
type = "object",
|
type = "object",
|
||||||
properties = new
|
properties = new
|
||||||
{
|
{
|
||||||
action = new { type = "string", description = "create/query/confirm" },
|
action = new { type = "string", @enum = new[] { "create", "query", "confirm" }, description = "create/query/confirm" },
|
||||||
|
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "inactive_plans", "all_plans" }, description = "查询时必填:今日安排/指定日期安排/当前计划/即将开始/自然结束/已停用/全部计划" },
|
||||||
|
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
|
||||||
medication_id = new { type = "string", description = "药品 ID,确认服药时使用" },
|
medication_id = new { type = "string", description = "药品 ID,确认服药时使用" },
|
||||||
|
scheduled_time = new { type = "string", description = "要确认的计划服药时间 HH:mm" },
|
||||||
name = new { type = "string", description = "药品名称" },
|
name = new { type = "string", description = "药品名称" },
|
||||||
dosage = new { type = "string", description = "剂量,如 100mg" },
|
dosage = new { type = "string", description = "剂量,如 100mg" },
|
||||||
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
|
frequency = new { type = "string", @enum = new[] { "Daily", "TwiceDaily", "ThreeTimesDaily", "EveryOtherDay", "Weekly", "AsNeeded" }, description = "服药频率" },
|
||||||
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
|
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
|
||||||
duration_days = new { type = "integer", description = "服用天数" },
|
duration_days = new { type = "integer", description = "服用天数" },
|
||||||
|
long_term = new { type = "boolean", description = "是否明确为长期服用;与 duration_days 二选一" },
|
||||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
|
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
|
||||||
},
|
},
|
||||||
required = new[] { "action" }
|
required = new[] { "action" }
|
||||||
@@ -43,17 +47,17 @@ public static class MedicationAgentHandler
|
|||||||
return toolName switch
|
return toolName switch
|
||||||
{
|
{
|
||||||
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
||||||
_ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
|
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
var action = args.TryGetProperty("action", out var a) ? a.GetString()?.ToLowerInvariant() : null;
|
||||||
return action switch
|
return action switch
|
||||||
{
|
{
|
||||||
"create" => await CreateMedication(medications, userId, args, ct),
|
"create" => await CreateMedication(medications, userId, args, ct),
|
||||||
"query" => await QueryMedications(medications, userId, ct),
|
"query" => await QueryMedications(medications, userId, args, ct),
|
||||||
"confirm" => await ConfirmMedication(medications, userId, args, ct),
|
"confirm" => await ConfirmMedication(medications, userId, args, ct),
|
||||||
_ => new { success = false, message = $"未知操作: {action}" }
|
_ => new { success = false, message = $"未知操作: {action}" }
|
||||||
};
|
};
|
||||||
@@ -61,14 +65,19 @@ public static class MedicationAgentHandler
|
|||||||
|
|
||||||
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var validationError = ValidateWriteArguments(args);
|
||||||
|
if (validationError != null)
|
||||||
|
return new { success = false, message = validationError };
|
||||||
|
|
||||||
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
|
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
|
||||||
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
|
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
|
||||||
var frequency = ReadFrequency(args);
|
var frequency = ReadFrequency(args);
|
||||||
var times = ReadTimes(args);
|
var times = ReadTimes(args);
|
||||||
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
||||||
|
var longTerm = args.TryGetProperty("long_term", out var longTermValue) && longTermValue.ValueKind == JsonValueKind.True;
|
||||||
var startDate = ReadStartDate(args);
|
var startDate = ReadStartDate(args);
|
||||||
// 结束日为包含式(IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
|
// 结束日为包含式(IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
|
||||||
var endDate = durationDays > 0 ? startDate.AddDays(durationDays - 1) : (DateOnly?)null;
|
DateOnly? endDate = longTerm ? null : startDate.AddDays(durationDays - 1);
|
||||||
|
|
||||||
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
|
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
|
||||||
name,
|
name,
|
||||||
@@ -91,27 +100,146 @@ public static class MedicationAgentHandler
|
|||||||
time = timeLabels,
|
time = timeLabels,
|
||||||
start_date = startDate.ToString("yyyy-MM-dd"),
|
start_date = startDate.ToString("yyyy-MM-dd"),
|
||||||
duration_days = durationDays,
|
duration_days = durationDays,
|
||||||
|
long_term = longTerm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
|
private static async Task<object> QueryMedications(
|
||||||
|
IMedicationService medications,
|
||||||
|
Guid userId,
|
||||||
|
JsonElement args,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var meds = await medications.ListActiveAsync(userId, ct);
|
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||||
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) };
|
? scopeValue.GetString()?.ToLowerInvariant()
|
||||||
|
: null;
|
||||||
|
if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "inactive_plans" or "all_plans"))
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
success = false,
|
||||||
|
message = "查询用药时必须明确 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)
|
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
var validationError = ValidateWriteArguments(args);
|
||||||
var success = await medications.ConfirmNowAsync(userId, medId, ct);
|
if (validationError != null)
|
||||||
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
return new { success = false, message = validationError };
|
||||||
|
|
||||||
|
var medId = args.TryGetProperty("medication_id", out var mid) && mid.TryGetGuid(out var parsedMedicationId)
|
||||||
|
? parsedMedicationId
|
||||||
|
: Guid.Empty;
|
||||||
|
if (!args.TryGetProperty("scheduled_time", out var timeValue) ||
|
||||||
|
!TimeOnly.TryParse(timeValue.GetString(), out var scheduledTime))
|
||||||
|
return new { success = false, message = "缺少要确认的计划服药时间" };
|
||||||
|
|
||||||
|
var success = await medications.ConfirmDoseAsync(
|
||||||
|
userId,
|
||||||
|
medId,
|
||||||
|
scheduledTime,
|
||||||
|
MedicationLogStatus.Taken,
|
||||||
|
ct);
|
||||||
|
return success == true
|
||||||
|
? new { success = true, medication_id = medId, scheduled_time = scheduledTime.ToString("HH:mm") }
|
||||||
|
: new { success = false, message = success == null ? "药品不存在" : "该时间不属于今天的服药安排,或这一顿已经记录" };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ValidateWriteArguments(JsonElement args)
|
||||||
|
{
|
||||||
|
var action = args.TryGetProperty("action", out var actionValue)
|
||||||
|
? actionValue.GetString()?.ToLowerInvariant()
|
||||||
|
: null;
|
||||||
|
if (action == "confirm")
|
||||||
|
{
|
||||||
|
if (!args.TryGetProperty("medication_id", out var medicationId) || !medicationId.TryGetGuid(out _))
|
||||||
|
return "确认服药时缺少有效的药品 ID";
|
||||||
|
if (!args.TryGetProperty("scheduled_time", out var scheduledTime) || !TimeOnly.TryParse(scheduledTime.GetString(), out _))
|
||||||
|
return "确认服药时缺少具体的计划服药时间";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action != "create") return null;
|
||||||
|
if (!args.TryGetProperty("name", out var name) || string.IsNullOrWhiteSpace(name.GetString()))
|
||||||
|
return "创建用药计划前需要确认药品名称";
|
||||||
|
if (!args.TryGetProperty("dosage", out var dosage) || string.IsNullOrWhiteSpace(dosage.GetString()))
|
||||||
|
return "创建用药计划前需要确认每次剂量";
|
||||||
|
if (!args.TryGetProperty("frequency", out var frequencyValue) ||
|
||||||
|
!Enum.TryParse<MedicationFrequency>(frequencyValue.GetString(), ignoreCase: true, out _))
|
||||||
|
return "创建用药计划前需要确认服药频率";
|
||||||
|
if (!args.TryGetProperty("time_of_day", out var times) || times.ValueKind != JsonValueKind.Array)
|
||||||
|
return "创建用药计划前需要确认具体服药时间";
|
||||||
|
var timeValues = times.EnumerateArray().ToList();
|
||||||
|
if (timeValues.Count == 0 || timeValues.Any(value => !TimeOnly.TryParse(value.GetString(), out _)))
|
||||||
|
return "创建用药计划前需要提供有效的具体服药时间";
|
||||||
|
if (!args.TryGetProperty("start_date", out var startDate) || !DateOnly.TryParse(startDate.GetString(), out _))
|
||||||
|
return "创建用药计划前需要确认开始日期";
|
||||||
|
|
||||||
|
var durationDays = 0;
|
||||||
|
var hasDuration = args.TryGetProperty("duration_days", out var duration) &&
|
||||||
|
duration.TryGetInt32(out durationDays) && durationDays > 0;
|
||||||
|
var isLongTerm = args.TryGetProperty("long_term", out var longTerm) && longTerm.ValueKind == JsonValueKind.True;
|
||||||
|
if (hasDuration && durationDays > 3650)
|
||||||
|
return "服用天数不能超过3650天;长期服用请明确设置 long_term=true";
|
||||||
|
if (hasDuration && isLongTerm)
|
||||||
|
return "服用天数和长期服用不能同时设置";
|
||||||
|
if (!hasDuration && !isLongTerm)
|
||||||
|
return "创建用药计划前需要确认服用天数,或明确说明长期服用";
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static MedicationFrequency ReadFrequency(JsonElement args)
|
private static MedicationFrequency ReadFrequency(JsonElement args)
|
||||||
{
|
{
|
||||||
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
|
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
|
||||||
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
|
return Enum.TryParse<MedicationFrequency>(frequencyStr, ignoreCase: true, out var frequency) ? frequency : MedicationFrequency.Daily;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateOnly ReadStartDate(JsonElement args)
|
private static DateOnly ReadStartDate(JsonElement args)
|
||||||
|
|||||||
@@ -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 System.Text.Json;
|
||||||
using Health.Application.AI;
|
using Health.Application.AI;
|
||||||
using Health.Application.Exercises;
|
using Health.Application.Exercises;
|
||||||
|
using Health.Application.Diets;
|
||||||
using Health.Application.HealthArchives;
|
using Health.Application.HealthArchives;
|
||||||
using Health.Application.HealthRecords;
|
using Health.Application.HealthRecords;
|
||||||
using Health.Application.Medications;
|
using Health.Application.Medications;
|
||||||
|
using Health.Application.Notifications;
|
||||||
|
using Health.Application.Reports;
|
||||||
using Health.Infrastructure.AI.AgentHandlers;
|
using Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
namespace Health.Infrastructure.AI;
|
namespace Health.Infrastructure.AI;
|
||||||
@@ -15,6 +18,9 @@ public sealed class AiToolExecutionService(
|
|||||||
IHealthRecordService healthRecords,
|
IHealthRecordService healthRecords,
|
||||||
IExerciseService exercises,
|
IExerciseService exercises,
|
||||||
IMedicationService medications,
|
IMedicationService medications,
|
||||||
|
IDietService diets,
|
||||||
|
IReportService reports,
|
||||||
|
IInAppNotificationService notifications,
|
||||||
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
|
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db = db;
|
private readonly AppDbContext _db = db;
|
||||||
@@ -23,6 +29,9 @@ public sealed class AiToolExecutionService(
|
|||||||
private readonly IHealthRecordService _healthRecords = healthRecords;
|
private readonly IHealthRecordService _healthRecords = healthRecords;
|
||||||
private readonly IExerciseService _exercises = exercises;
|
private readonly IExerciseService _exercises = exercises;
|
||||||
private readonly IMedicationService _medications = medications;
|
private readonly IMedicationService _medications = medications;
|
||||||
|
private readonly IDietService _diets = diets;
|
||||||
|
private readonly IReportService _reports = reports;
|
||||||
|
private readonly IInAppNotificationService _notifications = notifications;
|
||||||
private readonly IAiWriteConfirmationStore _confirmations = confirmations;
|
private readonly IAiWriteConfirmationStore _confirmations = confirmations;
|
||||||
|
|
||||||
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
|
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
|
||||||
@@ -36,8 +45,11 @@ public sealed class AiToolExecutionService(
|
|||||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
|
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
|
||||||
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
|
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
|
||||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
|
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, userId, _exercises, ct),
|
||||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
|
"query_diet_records" => PatientReadAgentHandler.QueryDietAsync(_diets, userId, root, ct),
|
||||||
|
"query_followups" => PatientReadAgentHandler.QueryFollowUpsAsync(_db, userId, root, ct),
|
||||||
|
"query_reports" => PatientReadAgentHandler.QueryReportsAsync(_reports, userId, root, ct),
|
||||||
|
"query_notifications" => PatientReadAgentHandler.QueryNotificationsAsync(_notifications, userId, root, ct),
|
||||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
|
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Health.Application.AI;
|
using Health.Application.AI;
|
||||||
|
using Health.Infrastructure.Files;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using UglyToad.PdfPig;
|
using UglyToad.PdfPig;
|
||||||
|
|
||||||
@@ -19,19 +20,19 @@ public sealed class AttachmentContextBuilder(
|
|||||||
private readonly VisionClient _vision = vision;
|
private readonly VisionClient _vision = vision;
|
||||||
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
|
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
|
||||||
|
|
||||||
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct)
|
public async Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(imageUrl))
|
if (!string.IsNullOrWhiteSpace(imageUrl))
|
||||||
return await BuildImageAsync(imageUrl!, ct);
|
return await BuildImageAsync(userId, imageUrl!, ct);
|
||||||
if (!string.IsNullOrWhiteSpace(pdfUrl))
|
if (!string.IsNullOrWhiteSpace(pdfUrl))
|
||||||
return await BuildPdfAsync(pdfUrl!, ct);
|
return await BuildPdfAsync(userId, pdfUrl!, ct);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 图片:调 VLM 输出结构化 JSON ──
|
// ── 图片:调 VLM 输出结构化 JSON ──
|
||||||
private async Task<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct)
|
private async Task<AttachmentContext?> BuildImageAsync(Guid userId, string imageUrl, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var filePath = ResolveLocalPath(imageUrl);
|
var filePath = UserUploadPathResolver.Resolve(userId, imageUrl);
|
||||||
if (filePath == null || !File.Exists(filePath))
|
if (filePath == null || !File.Exists(filePath))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Image file not found for {Url}", imageUrl);
|
_logger.LogWarning("Image file not found for {Url}", imageUrl);
|
||||||
@@ -105,9 +106,9 @@ public sealed class AttachmentContextBuilder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── PDF:PdfPig 抽取文本 ──
|
// ── PDF:PdfPig 抽取文本 ──
|
||||||
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct)
|
private Task<AttachmentContext?> BuildPdfAsync(Guid userId, string pdfUrl, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var filePath = ResolveLocalPath(pdfUrl);
|
var filePath = UserUploadPathResolver.Resolve(userId, pdfUrl);
|
||||||
var fileName = Path.GetFileName(pdfUrl);
|
var fileName = Path.GetFileName(pdfUrl);
|
||||||
if (filePath == null || !File.Exists(filePath))
|
if (filePath == null || !File.Exists(filePath))
|
||||||
{
|
{
|
||||||
@@ -150,18 +151,6 @@ public sealed class AttachmentContextBuilder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ResolveLocalPath(string url)
|
|
||||||
{
|
|
||||||
// url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。
|
|
||||||
var idx = url.IndexOf("/uploads/", StringComparison.Ordinal);
|
|
||||||
if (idx < 0) return null;
|
|
||||||
var relative = url[(idx + "/uploads/".Length)..];
|
|
||||||
// 去掉可能的 query string
|
|
||||||
var q = relative.IndexOf('?');
|
|
||||||
if (q >= 0) relative = relative[..q];
|
|
||||||
return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string StripCodeFence(string raw)
|
private static string StripCodeFence(string raw)
|
||||||
{
|
{
|
||||||
var t = raw.Trim();
|
var t = raw.Trim();
|
||||||
|
|||||||
@@ -37,6 +37,29 @@ public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversatio
|
|||||||
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
|
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
|
||||||
await _db.ConversationMessages.AddAsync(message, 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) =>
|
public void Delete(Conversation conversation) =>
|
||||||
_db.Conversations.Remove(conversation);
|
_db.Conversations.Remove(conversation);
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,10 @@ public sealed class PromptManager
|
|||||||
你是一个用药记录与提醒助手。
|
你是一个用药记录与提醒助手。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 用户询问当前用药时,必须先调用 manage_medication(action="query") 查询已有用药记录
|
1. 用户询问当前、某日、未来或过去的用药计划及服药完成状态时,必须先调用 manage_medication(action="query") 查询,不能根据患者背景或聊天历史猜测
|
||||||
|
- 某一天的服药任务:今天用 scope="today";其他相对日期或指定日期用 scope="scheduled_date" 并传北京时间 date
|
||||||
|
- 计划状态:当前有效用 scope="current_plans";尚未开始用 scope="upcoming_plans";自然结束用 scope="ended_plans";人为停用用 scope="inactive_plans";全部用 scope="all_plans"
|
||||||
|
- 查询时必须明确传 scope;时间意图无法判断时先追问,不能默认成今天
|
||||||
2. 解析用户口中的药品信息(药名/剂量/频次/时间)
|
2. 解析用户口中的药品信息(药名/剂量/频次/时间)
|
||||||
3. "早饭后"等模糊时间→追问具体几点
|
3. "早饭后"等模糊时间→追问具体几点
|
||||||
4. 解析完成后调用 manage_medication(action="create") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存
|
4. 解析完成后调用 manage_medication(action="create") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存
|
||||||
@@ -154,20 +157,38 @@ public sealed class PromptManager
|
|||||||
3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等
|
3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等
|
||||||
4. 运动强度要循序渐进
|
4. 运动强度要循序渐进
|
||||||
5. 避免剧烈运动
|
5. 避免剧烈运动
|
||||||
|
6. 查询运动计划时必须调用 manage_exercise(action="query"),不能根据聊天历史猜测
|
||||||
|
- 某一天的运动任务:今天用 scope="today";其他相对日期或指定日期用 scope="scheduled_date" 并传北京时间 date
|
||||||
|
- 计划状态:当前有效用 scope="current_plans";尚未开始用 scope="upcoming_plans";已经结束用 scope="ended_plans";全部用 scope="all_plans"
|
||||||
|
- 查询时必须明确传 scope;时间意图无法判断时先追问,不能默认成今天
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string UnifiedPrompt = """
|
private const string UnifiedPrompt = """
|
||||||
你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。
|
你是患者端统一 AI 健康助手“小脉”。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的健康解释、数据记录、用药管理、运动管理和预问诊辅助。
|
||||||
|
|
||||||
核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
|
核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称“已录入”“已保存”或“请点击确认”,实际却没有调用写入工具并生成确认卡片。
|
||||||
|
|
||||||
领域判断与可用工具:
|
领域判断与可用工具:
|
||||||
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
||||||
2. 饮食分析 → 引导用户使用「拍饮食」功能拍照识别
|
2. 新饮食图片分析 → 引导用户使用「拍饮食」功能;已保存饮食记录查询 → 调用 query_diet_records
|
||||||
3. 用药管理 → 调用 manage_medication
|
3. 用药管理 → 调用 manage_medication
|
||||||
4. 运动计划 → 调用 manage_exercise
|
4. 运动计划 → 调用 manage_exercise
|
||||||
5. 健康档案查询/修改 → 调用 check_archive / manage_archive
|
5. 健康档案查询 → 调用 check_archive;聊天中不修改健康档案
|
||||||
6. 历史数据查询 → 调用 query_health_records
|
6. 历史数据查询 → 调用 query_health_records
|
||||||
|
7. 复查随访 → 调用 query_followups
|
||||||
|
8. 已保存的检查报告 → 调用 query_reports
|
||||||
|
9. 站内通知 → 调用 query_notifications
|
||||||
|
|
||||||
|
计划类问题统一采用“时间意图 + 业务对象”判断,不依赖某一句固定问法或某个单独关键词:
|
||||||
|
- 先判断用户是在查询“某一天的任务”,还是查询“计划所处的生命周期”。两者不能混用
|
||||||
|
- 某一天的任务:北京时间今天使用 scope="today";昨天、明天、相对日期或明确日期使用 scope="scheduled_date" 并传换算后的 date
|
||||||
|
- 计划生命周期:当前有效使用 scope="current_plans";尚未开始使用 scope="upcoming_plans";已经自然结束使用 scope="ended_plans";查询全部历史和未来使用 scope="all_plans"
|
||||||
|
- 用药计划还要区分人为停用:明确询问已停药、已禁用或已取消的计划时使用 scope="inactive_plans",不能与自然到期混为一类
|
||||||
|
- 复查使用同一语义映射:下一次 scope="next"、全部未来 scope="upcoming"、已完成 scope="completed"、指定日期 scope="date"、全部 scope="all"
|
||||||
|
- “有没有、会不会有、新的、接下来”等表达要结合完整语义判断:询问是否存在计划属于 query;明确要求新增、制定或安排才属于 create
|
||||||
|
- “今天是否完成”必须先查询当天任务,再根据逐项状态回答;计划存在、当天有任务和任务已完成是三个不同事实
|
||||||
|
- 调用用药、运动或复查查询时 scope 必须明确传入;如果用户的时间意图确实无法判断,先只追问时间范围,禁止擅自按今天查询
|
||||||
|
- 工具返回 results_truncated=true 或 items_truncated=true 时,只能说明当前返回的是部分结果或部分日程,不能把它表述成完整清单
|
||||||
|
|
||||||
运动计划参数格式(manage_exercise):
|
运动计划参数格式(manage_exercise):
|
||||||
- action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
|
- action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
|
||||||
@@ -178,14 +199,33 @@ public sealed class PromptManager
|
|||||||
- 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目
|
- 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目
|
||||||
|
|
||||||
用药管理参数格式(manage_medication):
|
用药管理参数格式(manage_medication):
|
||||||
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
|
- action="create", name="药名", dosage="每次剂量", frequency="Daily", time_of_day=["08:00","20:00"], start_date="YYYY-MM-DD"
|
||||||
|
- 有明确疗程时传 duration_days;明确为长期服用时传 long_term=true。两者都不明确时必须追问
|
||||||
|
- 药名、每次剂量、频率、具体时间、开始日期以及疗程/长期属性任一缺失时,必须只追问缺失信息,禁止使用默认值生成确认卡
|
||||||
|
- 确认服药:必须使用查询结果中的 medication_id 和 scheduled_time,不能把当前时间当作计划时间
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 用户可能一句话涉及多个领域,全部处理
|
- 用户可能一句话涉及多个领域,全部处理
|
||||||
- 数值明确+指标明确→直接生成待确认命令,不要追问
|
- 询问“我的、今天、当前、下一次、是否完成”等用户个人事实时,必须先调用对应只读工具。不能只根据患者背景或聊天历史回答
|
||||||
|
- 当前工具结果是最高优先级;工具结果与历史聊天冲突时,以当前工具结果为准。历史里的计划、报告和完成状态不能当作当前事实
|
||||||
|
- 患者背景不提供动态计划状态;任何用药、运动和复查计划事实都必须调用对应工具实时查询
|
||||||
|
- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“指定任务已完成”,不能把它们当成同一件事
|
||||||
|
- 日期、今天、昨天、当前时间都按系统提供的北京时间解释
|
||||||
|
- 工具没有返回数据时应明确说未查到;没有相应读取能力时应说明暂时无法查询,禁止编造
|
||||||
|
- 查询健康记录时,根据用户问题选择最小且最相关的指标和时间范围:问某一指标就只查该指标;“今天/昨天”使用对应 scope;趋势问题按用户提到的7天、30天等范围查询;用户没有说明时间范围时默认 recent_days=7
|
||||||
|
- 健康记录工具最多返回最近100条,这是有意的上限;不得为了获得更多原始记录而反复扩大或拆分查询。需要更长周期但现有结果不足时,应说明当前只能基于最近结果判断
|
||||||
|
- 先判断用户是在咨询数值,还是希望记录数据,不能仅凭消息中出现指标和数值就自动录入
|
||||||
|
- 明确提问时先回答问题,不调用 record_health_data。例如“血氧98%是不是太高了”是在咨询,应解释正常范围,不生成录入确认卡片;除非用户同时明确要求记录
|
||||||
|
- 明确要求“记录、录入、保存、记一下”时调用 record_health_data。例如“帮我记录血压116/89”必须生成待确认命令
|
||||||
|
- 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录
|
||||||
|
- 指标或数值缺失时只追问缺失部分,不猜测数值
|
||||||
|
- 多个明确指标要逐项调用 record_health_data,不能遗漏
|
||||||
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
||||||
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||||
|
- 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片
|
||||||
|
- 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊
|
||||||
|
- 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||||
- 回复语气温暖、专业
|
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,25 +16,32 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
|
|
||||||
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
|
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空");
|
var phone = command.Phone.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空");
|
var name = command.Name.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(phone)) return Error(40001, "手机号不能为空");
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) return Error(40002, "姓名不能为空");
|
||||||
|
if (await _db.Users.AnyAsync(x => x.Phone == phone, ct) ||
|
||||||
|
await _db.Doctors.AnyAsync(x => x.Phone == phone, ct))
|
||||||
|
return Error(40003, "该手机号已被其他账号使用");
|
||||||
|
|
||||||
var doctor = new Doctor
|
var doctor = new Doctor
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department,
|
Id = Guid.NewGuid(), Name = name, Title = command.Title?.Trim(), Department = command.Department?.Trim(),
|
||||||
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow,
|
Phone = phone, ProfessionalDirection = command.ProfessionalDirection?.Trim(), IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Phone = phone, Role = "Doctor", Name = name,
|
||||||
|
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
_db.Doctors.Add(doctor);
|
_db.Doctors.Add(doctor);
|
||||||
if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct))
|
_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 };
|
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = name,
|
||||||
_db.Users.Add(user);
|
Title = doctor.Title, Department = doctor.Department, IsActive = true,
|
||||||
_db.DoctorProfiles.Add(new DoctorProfile
|
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||||
{
|
});
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return Ok(new { doctor.Id, doctor.Name });
|
return Ok(new { doctor.Id, doctor.Name });
|
||||||
}
|
}
|
||||||
@@ -43,11 +50,43 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
{
|
{
|
||||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||||
if (doctor == null) return Error(404, "医生不存在");
|
if (doctor == null) return Error(404, "医生不存在");
|
||||||
if (command.Name != null) doctor.Name = command.Name;
|
var profile = await _db.DoctorProfiles.Include(x => x.User)
|
||||||
if (command.Title != null) doctor.Title = command.Title;
|
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
|
||||||
if (command.Department != null) doctor.Department = command.Department;
|
if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
|
||||||
if (command.Phone != null) doctor.Phone = command.Phone;
|
|
||||||
if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection;
|
var user = profile.User;
|
||||||
|
if (command.Phone != null)
|
||||||
|
{
|
||||||
|
var phone = command.Phone.Trim();
|
||||||
|
if (phone.Length == 0) return Error(40001, "手机号不能为空");
|
||||||
|
if (await _db.Users.AnyAsync(x => x.Id != user.Id && x.Phone == phone, ct) ||
|
||||||
|
await _db.Doctors.AnyAsync(x => x.Id != id && x.Phone == phone, ct))
|
||||||
|
return Error(40003, "该手机号已被其他账号使用");
|
||||||
|
doctor.Phone = phone;
|
||||||
|
user.Phone = phone;
|
||||||
|
}
|
||||||
|
if (command.Name != null)
|
||||||
|
{
|
||||||
|
var name = command.Name.Trim();
|
||||||
|
if (name.Length == 0) return Error(40002, "姓名不能为空");
|
||||||
|
doctor.Name = name;
|
||||||
|
user.Name = name;
|
||||||
|
profile.Name = name;
|
||||||
|
}
|
||||||
|
if (command.Title != null)
|
||||||
|
{
|
||||||
|
doctor.Title = command.Title.Trim();
|
||||||
|
profile.Title = doctor.Title;
|
||||||
|
}
|
||||||
|
if (command.Department != null)
|
||||||
|
{
|
||||||
|
doctor.Department = command.Department.Trim();
|
||||||
|
profile.Department = doctor.Department;
|
||||||
|
}
|
||||||
|
if (command.ProfessionalDirection != null)
|
||||||
|
doctor.ProfessionalDirection = command.ProfessionalDirection.Trim();
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
profile.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return Ok(new { doctor.Id });
|
return Ok(new { doctor.Id });
|
||||||
}
|
}
|
||||||
@@ -56,7 +95,11 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
{
|
{
|
||||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||||
if (doctor == null) return Error(404, "医生不存在");
|
if (doctor == null) return Error(404, "医生不存在");
|
||||||
|
var profile = await _db.DoctorProfiles.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
|
||||||
|
if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
|
||||||
doctor.IsActive = !doctor.IsActive;
|
doctor.IsActive = !doctor.IsActive;
|
||||||
|
profile.IsActive = doctor.IsActive;
|
||||||
|
profile.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return Ok(new { doctor.Id, doctor.IsActive });
|
return Ok(new { doctor.Id, doctor.IsActive });
|
||||||
}
|
}
|
||||||
@@ -65,7 +108,22 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
{
|
{
|
||||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||||
if (doctor == null) return Error(404, "医生不存在");
|
if (doctor == null) return Error(404, "医生不存在");
|
||||||
await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct);
|
|
||||||
|
if (await _db.Users.AnyAsync(x => x.Role == "User" && x.DoctorId == id, ct))
|
||||||
|
return Error(40004, "该医生仍有绑定患者,请先停用,不能直接删除");
|
||||||
|
if (await _db.Consultations.AnyAsync(x => x.DoctorId == id, ct))
|
||||||
|
return Error(40005, "该医生已有问诊记录,只能停用,不能删除");
|
||||||
|
|
||||||
|
var profile = await _db.DoctorProfiles.Include(x => x.User)
|
||||||
|
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
|
||||||
|
if (profile != null)
|
||||||
|
{
|
||||||
|
var user = profile.User;
|
||||||
|
var refreshTokens = await _db.RefreshTokens.Where(x => x.UserId == user.Id).ToListAsync(ct);
|
||||||
|
_db.RefreshTokens.RemoveRange(refreshTokens);
|
||||||
|
_db.DoctorProfiles.Remove(profile);
|
||||||
|
_db.Users.Remove(user);
|
||||||
|
}
|
||||||
_db.Doctors.Remove(doctor);
|
_db.Doctors.Remove(doctor);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return Ok(new { success = true });
|
return Ok(new { success = true });
|
||||||
@@ -77,7 +135,9 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
|
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search));
|
query = query.Where(x =>
|
||||||
|
(x.Name != null && x.Name.Contains(search)) ||
|
||||||
|
(x.Phone != null && x.Phone.Contains(search)));
|
||||||
var total = await query.CountAsync(ct);
|
var total = await query.CountAsync(ct);
|
||||||
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
|
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
|
||||||
|
|||||||
@@ -88,14 +88,39 @@ public sealed class AuthService(
|
|||||||
{
|
{
|
||||||
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
|
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } });
|
return new AuthResult(0, new
|
||||||
|
{
|
||||||
|
tokens.accessToken,
|
||||||
|
tokens.refreshToken,
|
||||||
|
user = new
|
||||||
|
{
|
||||||
|
id = AdminId,
|
||||||
|
phone = AdminPhone,
|
||||||
|
role = "Admin",
|
||||||
|
name = "管理员"
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await _db.Users.FindAsync([oldToken.UserId], ct);
|
var user = await _db.Users.FindAsync([oldToken.UserId], ct);
|
||||||
if (user == null) return Error(40002, "用户不存在");
|
if (user == null) return Error(40002, "用户不存在");
|
||||||
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
|
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return new AuthResult(0, new { userTokens.accessToken, userTokens.refreshToken, user = new { user.Role } });
|
return new AuthResult(0, new
|
||||||
|
{
|
||||||
|
userTokens.accessToken,
|
||||||
|
userTokens.refreshToken,
|
||||||
|
user = new
|
||||||
|
{
|
||||||
|
user.Id,
|
||||||
|
user.Phone,
|
||||||
|
user.Role,
|
||||||
|
user.Name,
|
||||||
|
user.Gender,
|
||||||
|
user.AvatarUrl,
|
||||||
|
BirthDate = user.BirthDate?.ToString("yyyy-MM-dd")
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LogoutAsync(string refreshToken, CancellationToken ct)
|
public async Task LogoutAsync(string refreshToken, CancellationToken ct)
|
||||||
@@ -113,9 +138,13 @@ public sealed class AuthService(
|
|||||||
{
|
{
|
||||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return Error(40005, "Apple 登录验证失败,请重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 查找已有用户
|
// 2. 查找已有用户
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository
|
|||||||
|
|
||||||
public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct)
|
public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
// 日历的日期边界按北京时间计算,数据库仍统一使用 UTC。
|
||||||
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
|
||||||
|
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
|
||||||
|
|
||||||
var medications = await _db.Medications
|
var medications = await _db.Medications
|
||||||
.Where(m => m.UserId == userId && m.IsActive)
|
.Where(m => m.UserId == userId && m.IsActive)
|
||||||
|
|||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -486,6 +486,10 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<DateOnly?>("SurgeryDate")
|
b.Property<DateOnly?>("SurgeryDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("SurgeryHistoryStatus")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
b.Property<string>("SurgeryType")
|
b.Property<string>("SurgeryType")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -673,6 +677,15 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("AbnormalAlert")
|
b.Property<bool>("AbnormalAlert")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("DndEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("DndEndMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("DndStartMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<bool>("DoctorReply")
|
b.Property<bool>("DoctorReply")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -700,6 +713,9 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("MedicationReminder")
|
b.Property<bool>("MedicationReminder")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PushEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
builder.Entity<HealthArchive>(e =>
|
builder.Entity<HealthArchive>(e =>
|
||||||
{
|
{
|
||||||
e.HasIndex(a => a.UserId).IsUnique();
|
e.HasIndex(a => a.UserId).IsUnique();
|
||||||
|
e.Property(a => a.SurgeryHistoryStatus).HasMaxLength(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Entity<HealthArchiveSurgery>(e =>
|
builder.Entity<HealthArchiveSurgery>(e =>
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository
|
|||||||
.Take(limit)
|
.Take(limit)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) =>
|
||||||
|
await _db.ExercisePlans.Include(p => p.Items)
|
||||||
|
.Where(p => p.UserId == userId)
|
||||||
|
.OrderByDescending(p => p.StartDate)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) =>
|
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) =>
|
||||||
_db.ExercisePlans.Include(p => p.Items)
|
_db.ExercisePlans.Include(p => p.Items)
|
||||||
.FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct);
|
.FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct);
|
||||||
|
|
||||||
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) =>
|
|
||||||
_db.ExercisePlans.Include(p => p.Items)
|
|
||||||
.Where(p => p.UserId == userId)
|
|
||||||
.OrderByDescending(p => p.StartDate)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
|
|
||||||
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) =>
|
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) =>
|
||||||
_db.ExercisePlanItems.Include(i => i.Plan)
|
_db.ExercisePlanItems.Include(i => i.Plan)
|
||||||
.FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct);
|
.FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
namespace Health.Infrastructure.Files;
|
||||||
|
|
||||||
|
public static class UserUploadPathResolver
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string? Resolve(Guid userId, string value)
|
||||||
|
{
|
||||||
|
if (userId == Guid.Empty || string.IsNullOrWhiteSpace(value)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = Uri.TryCreate(value, UriKind.Absolute, out var absolute)
|
||||||
|
? absolute.AbsolutePath
|
||||||
|
: value.Split('?', 2)[0];
|
||||||
|
path = Uri.UnescapeDataString(path);
|
||||||
|
|
||||||
|
string? fileName;
|
||||||
|
if (path == Path.GetFileName(path))
|
||||||
|
{
|
||||||
|
fileName = path;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fileName = ExtractAfter(path, "/api/files/content/");
|
||||||
|
if (fileName == null)
|
||||||
|
{
|
||||||
|
var legacyPrefix = $"/uploads/users/{userId:N}/";
|
||||||
|
fileName = ExtractAfter(path, legacyPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
|
||||||
|
if (!AllowedExtensions.Contains(Path.GetExtension(fileName))) return null;
|
||||||
|
if (!Guid.TryParse(Path.GetFileNameWithoutExtension(fileName), out _)) return null;
|
||||||
|
|
||||||
|
var root = Path.GetFullPath(Path.Combine(
|
||||||
|
Directory.GetCurrentDirectory(),
|
||||||
|
"uploads",
|
||||||
|
"users",
|
||||||
|
userId.ToString("N")));
|
||||||
|
var candidate = Path.GetFullPath(Path.Combine(root, fileName));
|
||||||
|
return candidate.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? candidate
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (UriFormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractAfter(string path, string prefix)
|
||||||
|
{
|
||||||
|
var index = path.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
|
||||||
|
return index < 0 ? null : path[(index + prefix.Length)..];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,15 +30,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit
|
|||||||
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
|
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
|
||||||
_db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct);
|
_db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct);
|
||||||
|
|
||||||
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
|
|
||||||
_db.Medications.AnyAsync(m => m.Id == medicationId && m.UserId == userId, ct);
|
|
||||||
|
|
||||||
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
|
|
||||||
_db.MedicationLogs.FirstOrDefaultAsync(l =>
|
|
||||||
l.MedicationId == medicationId && l.UserId == userId
|
|
||||||
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
|
|
||||||
&& l.Status == MedicationLogStatus.Taken, ct);
|
|
||||||
|
|
||||||
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
|
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
|
||||||
_db.MedicationLogs.AnyAsync(l =>
|
_db.MedicationLogs.AnyAsync(l =>
|
||||||
l.MedicationId == medicationId && l.UserId == userId
|
l.MedicationId == medicationId && l.UserId == userId
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
using Health.Application.Notifications;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
public sealed class EfReminderCatchUpService(AppDbContext db) : IReminderCatchUpService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db = db;
|
||||||
|
|
||||||
|
public async Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pref = await GetPreferenceAsync(userId, ct);
|
||||||
|
if (!pref.PushEnabled) return new ReminderCatchUpResult(0);
|
||||||
|
|
||||||
|
var beijingNow = utcNow.AddHours(8);
|
||||||
|
var today = DateOnly.FromDateTime(beijingNow);
|
||||||
|
var todayStartUtc = beijingNow.Date.AddHours(-8);
|
||||||
|
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
created += await CreateHealthRecordReminderAsync(userId, pref, beijingNow, todayStartUtc, todayEndUtc, ct);
|
||||||
|
created += await CreateMedicationRemindersAsync(userId, pref, beijingNow, today, todayStartUtc, todayEndUtc, ct);
|
||||||
|
created += await CreateExerciseRemindersAsync(userId, pref, beijingNow, today, ct);
|
||||||
|
created += await CreateFollowUpRemindersAsync(userId, pref, beijingNow, ct);
|
||||||
|
|
||||||
|
if (created > 0)
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ReminderCatchUpResult(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<NotificationPreference> GetPreferenceAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pref = await _db.NotificationPreferences.FirstOrDefaultAsync(x => x.UserId == userId, ct);
|
||||||
|
if (pref != null) return pref;
|
||||||
|
|
||||||
|
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||||
|
await _db.NotificationPreferences.AddAsync(pref, ct);
|
||||||
|
return pref;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateHealthRecordReminderAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateTime todayStartUtc,
|
||||||
|
DateTime todayEndUtc,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.HealthRecordReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var window = HealthRecordWindow(beijingNow);
|
||||||
|
if (window == null) return 0;
|
||||||
|
|
||||||
|
var recordedTypes = await _db.HealthRecords.AsNoTracking()
|
||||||
|
.Where(r => r.UserId == userId && r.RecordedAt >= todayStartUtc && r.RecordedAt < todayEndUtc)
|
||||||
|
.Select(r => r.MetricType)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var missing = EnabledHealthMetrics(pref)
|
||||||
|
.Where(x => !recordedTypes.Contains(x.Type))
|
||||||
|
.ToList();
|
||||||
|
if (missing.Count == 0) return 0;
|
||||||
|
|
||||||
|
var date = DateOnly.FromDateTime(beijingNow);
|
||||||
|
var sourceId = StableGuid($"health_record|{userId:N}|{date:yyyyMMdd}|{window}");
|
||||||
|
return await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"health_record_reminder",
|
||||||
|
window == "evening" ? "今日健康指标还未完成" : "记得录入今日健康指标",
|
||||||
|
$"建议录入:{string.Join("、", missing.Select(x => x.Label))}",
|
||||||
|
"info",
|
||||||
|
"health",
|
||||||
|
null,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateMedicationRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateOnly today,
|
||||||
|
DateTime todayStartUtc,
|
||||||
|
DateTime todayEndUtc,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.MedicationReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var now = TimeOnly.FromDateTime(beijingNow);
|
||||||
|
var earliest = now.AddHours(-4);
|
||||||
|
var medications = await _db.Medications.AsNoTracking()
|
||||||
|
.Where(m => m.UserId == userId && m.IsActive)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var medication in medications.Where(m => IsMedicationActiveOn(m, today) && ShouldDoseToday(m, today)))
|
||||||
|
{
|
||||||
|
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInTimeWindow(t, earliest, now)))
|
||||||
|
{
|
||||||
|
var logged = await _db.MedicationLogs.AsNoTracking().AnyAsync(l =>
|
||||||
|
l.UserId == userId &&
|
||||||
|
l.MedicationId == medication.Id &&
|
||||||
|
l.ScheduledTime == scheduledTime &&
|
||||||
|
l.CreatedAt >= todayStartUtc &&
|
||||||
|
l.CreatedAt < todayEndUtc, ct);
|
||||||
|
if (logged) continue;
|
||||||
|
|
||||||
|
var sourceId = StableGuid($"medication|{userId:N}|{medication.Id:N}|{today:yyyyMMdd}|{scheduledTime:HHmm}");
|
||||||
|
var details = string.Join(" ", new[] { medication.Dosage, scheduledTime.ToString("HH:mm") }
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"MedicationReminder",
|
||||||
|
"用药时间到了",
|
||||||
|
$"请按计划服用{medication.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。",
|
||||||
|
"info",
|
||||||
|
"medication",
|
||||||
|
medication.Id.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateExerciseRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var now = TimeOnly.FromDateTime(beijingNow);
|
||||||
|
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
|
||||||
|
.Include(x => x.Plan)
|
||||||
|
.Where(x => x.Plan.UserId == userId &&
|
||||||
|
x.ScheduledDate == today &&
|
||||||
|
!x.IsCompleted &&
|
||||||
|
!x.IsRestDay &&
|
||||||
|
x.Plan.ReminderTime <= now)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var item in dueItems)
|
||||||
|
{
|
||||||
|
var sourceId = StableGuid($"exercise|{userId:N}|{item.Id:N}|{today:yyyyMMdd}");
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"ExerciseReminder",
|
||||||
|
"今日运动计划",
|
||||||
|
$"今天安排了{item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
|
||||||
|
"info",
|
||||||
|
"exercise",
|
||||||
|
item.PlanId.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateFollowUpRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.FollowUpReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var nowUtc = beijingNow.AddHours(-8);
|
||||||
|
var upcomingEndUtc = nowUtc.AddHours(24);
|
||||||
|
var followUps = await _db.FollowUps.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId && x.ScheduledAt >= nowUtc && x.ScheduledAt <= upcomingEndUtc)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var followUp in followUps)
|
||||||
|
{
|
||||||
|
var sourceId = StableGuid($"followup|{userId:N}|{followUp.Id:N}|24h");
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"follow_up_reminder",
|
||||||
|
"复查提醒",
|
||||||
|
$"{followUp.Title} 即将到期,请提前安排时间。",
|
||||||
|
"info",
|
||||||
|
"followup",
|
||||||
|
followUp.Id.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> AddNotificationIfMissingAsync(
|
||||||
|
Guid userId,
|
||||||
|
Guid sourceId,
|
||||||
|
string type,
|
||||||
|
string title,
|
||||||
|
string message,
|
||||||
|
string severity,
|
||||||
|
string? actionType,
|
||||||
|
string? actionTargetId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await _db.UserNotifications.AsNoTracking().AnyAsync(x => x.SourceId == sourceId, ct))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
await _db.UserNotifications.AddAsync(new UserNotification
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
SourceId = sourceId,
|
||||||
|
Type = type,
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
Severity = severity,
|
||||||
|
ActionType = actionType,
|
||||||
|
ActionTargetId = actionTargetId,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}, ct);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? HealthRecordWindow(DateTime beijingNow)
|
||||||
|
{
|
||||||
|
var minutes = beijingNow.Hour * 60 + beijingNow.Minute;
|
||||||
|
if (minutes is >= 7 * 60 and <= 10 * 60 + 30) return "morning";
|
||||||
|
if (minutes is >= 11 * 60 + 30 and <= 14 * 60) return "noon";
|
||||||
|
if (minutes is >= 18 * 60 and <= 21 * 60 + 30) return "evening";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<(HealthMetricType Type, string Label)> EnabledHealthMetrics(NotificationPreference pref)
|
||||||
|
{
|
||||||
|
var result = new List<(HealthMetricType, string)>();
|
||||||
|
if (pref.HealthRecordReminderBloodPressure) result.Add((HealthMetricType.BloodPressure, "血压"));
|
||||||
|
if (pref.HealthRecordReminderHeartRate) result.Add((HealthMetricType.HeartRate, "心率"));
|
||||||
|
if (pref.HealthRecordReminderGlucose) result.Add((HealthMetricType.Glucose, "血糖"));
|
||||||
|
if (pref.HealthRecordReminderSpO2) result.Add((HealthMetricType.SpO2, "血氧"));
|
||||||
|
if (pref.HealthRecordReminderWeight) result.Add((HealthMetricType.Weight, "体重"));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInDnd(NotificationPreference pref, DateTime beijingNow)
|
||||||
|
{
|
||||||
|
if (!pref.DndEnabled) return false;
|
||||||
|
var minuteOfDay = beijingNow.Hour * 60 + beijingNow.Minute;
|
||||||
|
return pref.DndStartMinutes <= pref.DndEndMinutes
|
||||||
|
? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes
|
||||||
|
: minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMedicationActiveOn(Medication medication, DateOnly date) =>
|
||||||
|
(medication.StartDate == null || medication.StartDate <= date) &&
|
||||||
|
(medication.EndDate == null || medication.EndDate >= date);
|
||||||
|
|
||||||
|
private static bool ShouldDoseToday(Medication medication, DateOnly today)
|
||||||
|
{
|
||||||
|
if (medication.Frequency is MedicationFrequency.Daily
|
||||||
|
or MedicationFrequency.TwiceDaily
|
||||||
|
or MedicationFrequency.ThreeTimesDaily
|
||||||
|
or MedicationFrequency.AsNeeded)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var startDate = medication.StartDate ?? today;
|
||||||
|
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
|
||||||
|
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
|
||||||
|
if (medication.Frequency == MedicationFrequency.Weekly)
|
||||||
|
return today.DayOfWeek == startDate.DayOfWeek;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInTimeWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
|
||||||
|
end >= start ? value >= start && value <= end : value >= start || value <= end;
|
||||||
|
|
||||||
|
private static Guid StableGuid(string key)
|
||||||
|
{
|
||||||
|
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
||||||
|
return new Guid(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificat
|
|||||||
NotificationMessage message,
|
NotificationMessage message,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var pushEnabled = await _db.NotificationPreferences.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId)
|
||||||
|
.Select(x => (bool?)x.PushEnabled)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (pushEnabled is false) return false;
|
||||||
|
|
||||||
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
|
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -67,7 +73,7 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
|||||||
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
||||||
.ExecuteUpdateAsync(setters => setters
|
.ExecuteUpdateAsync(setters => setters
|
||||||
.SetProperty(x => x.Status, "Failed")
|
.SetProperty(x => x.Status, "Failed")
|
||||||
.SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
|
.SetProperty(x => x.LastError, "Notification task timed out and reached max retry count")
|
||||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,59 +87,89 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
|||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
if (candidate == null) return false;
|
if (candidate == null) return false;
|
||||||
|
|
||||||
var claimed = await _db.NotificationOutbox
|
var task = await _db.NotificationOutbox
|
||||||
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
|
.FirstOrDefaultAsync(x => x.Id == candidate.Id && x.Status == "Pending", ct);
|
||||||
.ExecuteUpdateAsync(setters => setters
|
if (task == null) return true;
|
||||||
.SetProperty(x => x.Status, "Processing")
|
|
||||||
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
|
task.Status = "Processing";
|
||||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
task.Attempts += 1;
|
||||||
if (claimed == 0) return true;
|
task.UpdatedAt = now;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
|
var message = JsonSerializer.Deserialize<NotificationMessage>(task.Payload)
|
||||||
?? throw new InvalidOperationException("通知消息内容为空");
|
?? throw new InvalidOperationException("Notification payload is empty");
|
||||||
|
|
||||||
if (!await _db.UserNotifications.AsNoTracking()
|
var preference = await _db.NotificationPreferences.AsNoTracking()
|
||||||
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
|
.FirstOrDefaultAsync(x => x.UserId == task.UserId, ct);
|
||||||
|
var deliver = preference == null || ShouldDeliver(preference, message.Type, DateTime.UtcNow.AddHours(8));
|
||||||
|
|
||||||
|
if (deliver && !await _db.UserNotifications.AsNoTracking()
|
||||||
|
.AnyAsync(x => x.SourceId == task.SourceTaskId, ct))
|
||||||
{
|
{
|
||||||
await _db.UserNotifications.AddAsync(new UserNotification
|
await _db.UserNotifications.AddAsync(new UserNotification
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
UserId = candidate.UserId,
|
UserId = task.UserId,
|
||||||
SourceId = candidate.SourceTaskId,
|
SourceId = task.SourceTaskId,
|
||||||
Type = message.Type,
|
Type = message.Type,
|
||||||
Title = message.Title,
|
Title = message.Title,
|
||||||
Message = message.Message,
|
Message = message.Message,
|
||||||
Severity = message.Severity,
|
Severity = message.Severity,
|
||||||
ActionType = message.ActionType,
|
ActionType = message.ActionType,
|
||||||
ActionTargetId = message.ActionTargetId,
|
ActionTargetId = message.ActionTargetId,
|
||||||
CreatedAt = candidate.CreatedAt,
|
CreatedAt = task.CreatedAt,
|
||||||
}, ct);
|
}, ct);
|
||||||
// Persist the inbox item first. If completion fails, stale-task
|
|
||||||
// recovery can safely retry because SourceId is idempotent.
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
task.Status = "Completed";
|
||||||
.ExecuteUpdateAsync(setters => setters
|
task.LastError = null;
|
||||||
.SetProperty(x => x.Status, "Completed")
|
task.UpdatedAt = DateTime.UtcNow;
|
||||||
.SetProperty(x => x.LastError, (string?)null)
|
await _db.SaveChangesAsync(ct);
|
||||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var attempts = candidate.Attempts + 1;
|
task.Status = task.Attempts >= MaxAttempts ? "Failed" : "Pending";
|
||||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
|
||||||
.ExecuteUpdateAsync(setters => setters
|
task.LastError = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
|
||||||
.SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
|
task.UpdatedAt = DateTime.UtcNow;
|
||||||
.SetProperty(x => x.AvailableAt,
|
await _db.SaveChangesAsync(ct);
|
||||||
DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5))
|
|
||||||
.SetProperty(x => x.LastError,
|
|
||||||
ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message)
|
|
||||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool ShouldDeliver(NotificationPreference preference, string type, DateTime nowCst)
|
||||||
|
{
|
||||||
|
if (!preference.PushEnabled) return false;
|
||||||
|
|
||||||
|
var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute;
|
||||||
|
var inDnd = preference.DndEnabled && (preference.DndStartMinutes <= preference.DndEndMinutes
|
||||||
|
? minuteOfDay >= preference.DndStartMinutes && minuteOfDay < preference.DndEndMinutes
|
||||||
|
: minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes);
|
||||||
|
if (inDnd) return false;
|
||||||
|
|
||||||
|
return NormalizeType(type) switch
|
||||||
|
{
|
||||||
|
"medication_reminder" => preference.MedicationReminder,
|
||||||
|
"exercise_reminder" => true,
|
||||||
|
"follow_up_reminder" => preference.FollowUpReminder,
|
||||||
|
"doctor_reply" => preference.DoctorReply,
|
||||||
|
"abnormal_alert" => preference.AbnormalAlert,
|
||||||
|
"health_record_reminder" => preference.HealthRecordReminder,
|
||||||
|
"health_metric_alert" => preference.AbnormalAlert,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeType(string type) => type switch
|
||||||
|
{
|
||||||
|
"MedicationReminder" => "medication_reminder",
|
||||||
|
"ExerciseReminder" => "exercise_reminder",
|
||||||
|
"HealthMetricAlert" => "health_metric_alert",
|
||||||
|
"FollowUpReminder" => "follow_up_reminder",
|
||||||
|
_ => type.ToLowerInvariant(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,24 @@ public sealed class LocalReportFileStorage : IReportFileStorage
|
|||||||
return new StoredReportFile($"/uploads/reports/{fileName}", filePath);
|
return new StoredReportFile($"/uploads/reports/{fileName}", filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetLocalFilePath(string fileUrl) =>
|
public string GetLocalFilePath(string fileUrl)
|
||||||
Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/'));
|
{
|
||||||
|
var reportsRoot = Path.GetFullPath(Path.Combine(
|
||||||
|
Directory.GetCurrentDirectory(),
|
||||||
|
"uploads",
|
||||||
|
"reports"));
|
||||||
|
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||||
|
? absolute.AbsolutePath
|
||||||
|
: fileUrl.Split('?', 2)[0];
|
||||||
|
var fileName = Path.GetFileName(Uri.UnescapeDataString(path));
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName))
|
||||||
|
return Path.Combine(reportsRoot, "__invalid__");
|
||||||
|
|
||||||
|
var candidate = Path.GetFullPath(Path.Combine(reportsRoot, fileName));
|
||||||
|
return candidate.StartsWith(reportsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? candidate
|
||||||
|
: Path.Combine(reportsRoot, "__invalid__");
|
||||||
|
}
|
||||||
|
|
||||||
public bool Exists(string filePath) =>
|
public bool Exists(string filePath) =>
|
||||||
File.Exists(filePath);
|
File.Exists(filePath);
|
||||||
|
|||||||
49
backend/src/Health.Infrastructure/Services/bce_signer.cs
Normal file
49
backend/src/Health.Infrastructure/Services/bce_signer.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 百度云 BCE v1 签名算法(与 baidubce-sdk 算法一致)
|
||||||
|
/// </summary>
|
||||||
|
public static class BceSigner
|
||||||
|
{
|
||||||
|
private const int ExpirySeconds = 1800;
|
||||||
|
private const string SignedHeaders = "content-length;content-type;host;x-bce-date";
|
||||||
|
|
||||||
|
public static string BuildAuthorization(
|
||||||
|
string accessKeyId,
|
||||||
|
string secretAccessKey,
|
||||||
|
string method,
|
||||||
|
string path,
|
||||||
|
string host,
|
||||||
|
DateTime utcNow,
|
||||||
|
int contentLength,
|
||||||
|
string contentType)
|
||||||
|
{
|
||||||
|
var timestamp = utcNow.ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var canonicalHeaders = string.Join("\n",
|
||||||
|
$"content-length:{contentLength}",
|
||||||
|
$"content-type:{Normalize(contentType)}",
|
||||||
|
$"host:{Normalize(host)}",
|
||||||
|
$"x-bce-date:{Normalize(timestamp)}");
|
||||||
|
|
||||||
|
var canonicalRequest = $"{method}\n{path}\n\n{canonicalHeaders}";
|
||||||
|
var authStringPrefix = $"bce-auth-v1/{accessKeyId}/{timestamp}/{ExpirySeconds}";
|
||||||
|
|
||||||
|
var signingKeyDigest = HMACSHA256.HashData(
|
||||||
|
Encoding.UTF8.GetBytes(secretAccessKey),
|
||||||
|
Encoding.UTF8.GetBytes(authStringPrefix));
|
||||||
|
var signingKeyHex = Convert.ToHexString(signingKeyDigest).ToLowerInvariant();
|
||||||
|
|
||||||
|
var signature = HMACSHA256.HashData(
|
||||||
|
Encoding.UTF8.GetBytes(signingKeyHex),
|
||||||
|
Encoding.UTF8.GetBytes(canonicalRequest));
|
||||||
|
var signatureHex = Convert.ToHexString(signature).ToLowerInvariant();
|
||||||
|
|
||||||
|
return $"{authStringPrefix}/{SignedHeaders}/{signatureHex}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string s) => Uri.EscapeDataString(s);
|
||||||
|
}
|
||||||
64
backend/src/Health.Infrastructure/Services/bce_sms_client.cs
Normal file
64
backend/src/Health.Infrastructure/Services/bce_sms_client.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 百度云 SMS v3 短信发送客户端(HttpClient 直连 + BCE v1 签名)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BceSmsClient(HttpClient http, IConfiguration cfg, ILogger<BceSmsClient> log)
|
||||||
|
{
|
||||||
|
private const string Path = "/api/v3/sendSms";
|
||||||
|
private const string ContentType = "application/json; charset=utf-8";
|
||||||
|
|
||||||
|
private readonly HttpClient _http = http;
|
||||||
|
private readonly ILogger<BceSmsClient> _log = log;
|
||||||
|
private readonly string _ak = cfg["BAIDU_SMS_AK"] ?? "";
|
||||||
|
private readonly string _sk = cfg["BAIDU_SMS_SK"] ?? "";
|
||||||
|
private readonly string _host = cfg["BAIDU_SMS_HOST"] ?? "smsv3.bj.baidubce.com";
|
||||||
|
private readonly string _signatureId = cfg["BAIDU_SMS_SIGNATURE_ID"] ?? "";
|
||||||
|
private readonly string _templateId = cfg["BAIDU_SMS_TEMPLATE_ID"] ?? "";
|
||||||
|
|
||||||
|
public async Task<bool> SendAsync(string phone, string code, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
mobile = $"+86{phone}",
|
||||||
|
template = _templateId,
|
||||||
|
signatureId = _signatureId,
|
||||||
|
contentVar = new { SMSvCode = code }
|
||||||
|
};
|
||||||
|
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
|
||||||
|
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, Path)
|
||||||
|
{
|
||||||
|
Content = new ByteArrayContent(body)
|
||||||
|
};
|
||||||
|
req.Content.Headers.TryAddWithoutValidation("Content-Type", ContentType);
|
||||||
|
|
||||||
|
req.Headers.Host = _host;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var timestamp = now.ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture);
|
||||||
|
req.Headers.Add("x-bce-date", timestamp);
|
||||||
|
var auth = BceSigner.BuildAuthorization(_ak, _sk, "POST", Path, _host, now, body.Length, ContentType);
|
||||||
|
req.Headers.TryAddWithoutValidation("Authorization", auth);
|
||||||
|
|
||||||
|
using var resp = await _http.SendAsync(req, ct);
|
||||||
|
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_log.LogError("百度短信发送失败 HTTP {Status}: {Body}", resp.StatusCode, respBody);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(respBody);
|
||||||
|
var respCode = doc.RootElement.GetProperty("code").GetString();
|
||||||
|
if (respCode != "1000")
|
||||||
|
{
|
||||||
|
_log.LogError("百度短信发送失败 code={Code}: {Body}", respCode, respBody);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Health.Application.Speech;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Speech;
|
||||||
|
|
||||||
|
public sealed class DashScopeSpeechRecognitionProxy : IRealtimeSpeechRecognitionProxy
|
||||||
|
{
|
||||||
|
private const int SampleRate = 16_000;
|
||||||
|
private const int BytesPerSecond = SampleRate * 2;
|
||||||
|
private readonly string _apiKey;
|
||||||
|
private readonly string _model;
|
||||||
|
private readonly string _workspaceId;
|
||||||
|
private readonly Uri? _endpoint;
|
||||||
|
private readonly int _maxDurationSeconds;
|
||||||
|
private readonly ILogger<DashScopeSpeechRecognitionProxy> _logger;
|
||||||
|
|
||||||
|
public DashScopeSpeechRecognitionProxy(
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<DashScopeSpeechRecognitionProxy> logger)
|
||||||
|
{
|
||||||
|
_apiKey = FirstConfigured(
|
||||||
|
configuration["DASHSCOPE_ASR_API_KEY"],
|
||||||
|
configuration["VLM_API_KEY"],
|
||||||
|
configuration["QWEN_API_KEY"]);
|
||||||
|
_model = configuration["DASHSCOPE_ASR_MODEL"]?.Trim() is { Length: > 0 } model
|
||||||
|
? model
|
||||||
|
: "fun-asr-realtime";
|
||||||
|
_workspaceId = configuration["DASHSCOPE_ASR_WORKSPACE_ID"]?.Trim() ?? "";
|
||||||
|
var configuredMaxDuration = int.TryParse(
|
||||||
|
configuration["DASHSCOPE_ASR_MAX_DURATION_SECONDS"],
|
||||||
|
out var parsedMaxDuration)
|
||||||
|
? parsedMaxDuration
|
||||||
|
: 60;
|
||||||
|
_maxDurationSeconds = Math.Clamp(configuredMaxDuration, 10, 180);
|
||||||
|
|
||||||
|
var configuredEndpoint = configuration["DASHSCOPE_ASR_WEBSOCKET_URL"]?.Trim();
|
||||||
|
var endpoint = !string.IsNullOrWhiteSpace(configuredEndpoint)
|
||||||
|
? configuredEndpoint
|
||||||
|
: !string.IsNullOrWhiteSpace(_workspaceId)
|
||||||
|
? $"wss://{_workspaceId}.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference"
|
||||||
|
: "wss://dashscope.aliyuncs.com/api-ws/v1/inference";
|
||||||
|
if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && uri.Scheme == "wss")
|
||||||
|
_endpoint = uri;
|
||||||
|
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConfigured =>
|
||||||
|
!string.IsNullOrWhiteSpace(_apiKey) && _endpoint is not null;
|
||||||
|
|
||||||
|
private static string FirstConfigured(params string?[] values) =>
|
||||||
|
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? "";
|
||||||
|
|
||||||
|
public async Task ProxyAsync(
|
||||||
|
WebSocket clientSocket,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsConfigured)
|
||||||
|
throw new InvalidOperationException("实时语音识别服务尚未配置");
|
||||||
|
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(_maxDurationSeconds + 15));
|
||||||
|
var ct = timeoutCts.Token;
|
||||||
|
|
||||||
|
using var cloudSocket = new ClientWebSocket();
|
||||||
|
cloudSocket.Options.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
|
||||||
|
cloudSocket.Options.SetRequestHeader("User-Agent", "XiaomaiHealth/1.0");
|
||||||
|
if (!string.IsNullOrWhiteSpace(_workspaceId))
|
||||||
|
cloudSocket.Options.SetRequestHeader("X-DashScope-WorkSpace", _workspaceId);
|
||||||
|
|
||||||
|
await cloudSocket.ConnectAsync(_endpoint!, ct);
|
||||||
|
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
await SendJsonAsync(cloudSocket, new
|
||||||
|
{
|
||||||
|
header = new
|
||||||
|
{
|
||||||
|
action = "run-task",
|
||||||
|
task_id = taskId,
|
||||||
|
streaming = "duplex",
|
||||||
|
},
|
||||||
|
payload = new
|
||||||
|
{
|
||||||
|
task_group = "audio",
|
||||||
|
task = "asr",
|
||||||
|
function = "recognition",
|
||||||
|
model = _model,
|
||||||
|
parameters = new
|
||||||
|
{
|
||||||
|
format = "pcm",
|
||||||
|
sample_rate = SampleRate,
|
||||||
|
language_hints = new[] { "zh" },
|
||||||
|
semantic_punctuation_enabled = false,
|
||||||
|
max_sentence_silence = 800,
|
||||||
|
heartbeat = true,
|
||||||
|
},
|
||||||
|
input = new { },
|
||||||
|
},
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
await WaitForTaskStartedAsync(cloudSocket, taskId, ct);
|
||||||
|
await SendClientEventAsync(clientSocket, new { type = "ready" }, ct);
|
||||||
|
|
||||||
|
var clientPump = PumpClientAudioAsync(
|
||||||
|
clientSocket,
|
||||||
|
cloudSocket,
|
||||||
|
taskId,
|
||||||
|
_maxDurationSeconds,
|
||||||
|
ct);
|
||||||
|
var cloudPump = PumpCloudResultsAsync(cloudSocket, clientSocket, ct);
|
||||||
|
|
||||||
|
var completed = await Task.WhenAny(clientPump, cloudPump);
|
||||||
|
if (completed == cloudPump)
|
||||||
|
{
|
||||||
|
await cloudPump;
|
||||||
|
timeoutCts.Cancel();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var action = await clientPump;
|
||||||
|
if (action == ClientAction.Finish)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await cloudPump.WaitAsync(TimeSpan.FromSeconds(12), ct);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
await SendClientEventAsync(
|
||||||
|
clientSocket,
|
||||||
|
new { type = "error", message = "语音识别超时,请重试" },
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 客户端断开或硬超时,无需再给客户端发消息
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
timeoutCts.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await CloseSocketQuietlyAsync(cloudSocket);
|
||||||
|
await CloseSocketQuietlyAsync(clientSocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForTaskStartedAsync(
|
||||||
|
WebSocket cloudSocket,
|
||||||
|
string taskId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var message = await ReceiveMessageAsync(cloudSocket, ct);
|
||||||
|
if (message is null)
|
||||||
|
throw new InvalidOperationException("语音识别服务提前断开");
|
||||||
|
if (message.Value.Type != WebSocketMessageType.Text) continue;
|
||||||
|
|
||||||
|
using var json = JsonDocument.Parse(message.Value.Payload);
|
||||||
|
var header = json.RootElement.GetProperty("header");
|
||||||
|
var eventName = header.GetProperty("event").GetString();
|
||||||
|
var responseTaskId = header.TryGetProperty("task_id", out var id)
|
||||||
|
? id.GetString()
|
||||||
|
: null;
|
||||||
|
if (eventName == "task-started" && responseTaskId == taskId) return;
|
||||||
|
if (eventName == "task-failed")
|
||||||
|
{
|
||||||
|
var error = header.TryGetProperty("error_message", out var errorMessage)
|
||||||
|
? errorMessage.GetString()
|
||||||
|
: "语音识别启动失败";
|
||||||
|
throw new InvalidOperationException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ClientAction> PumpClientAudioAsync(
|
||||||
|
WebSocket clientSocket,
|
||||||
|
WebSocket cloudSocket,
|
||||||
|
string taskId,
|
||||||
|
int maxDurationSeconds,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var maxBytes = (long)BytesPerSecond * maxDurationSeconds;
|
||||||
|
long audioBytes = 0;
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested &&
|
||||||
|
clientSocket.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
var message = await ReceiveMessageAsync(clientSocket, ct);
|
||||||
|
if (message is null) return ClientAction.Disconnect;
|
||||||
|
|
||||||
|
if (message.Value.Type == WebSocketMessageType.Binary)
|
||||||
|
{
|
||||||
|
audioBytes += message.Value.Payload.Length;
|
||||||
|
if (audioBytes > maxBytes)
|
||||||
|
{
|
||||||
|
// 达到时长上限:主动结束并让阿里云返回已识别的结果,
|
||||||
|
// 而不是直接断开。客户端松手后会拿到这段文字。
|
||||||
|
await SendJsonAsync(cloudSocket, new
|
||||||
|
{
|
||||||
|
header = new
|
||||||
|
{
|
||||||
|
action = "finish-task",
|
||||||
|
task_id = taskId,
|
||||||
|
streaming = "duplex",
|
||||||
|
},
|
||||||
|
payload = new { input = new { } },
|
||||||
|
}, ct);
|
||||||
|
return ClientAction.Finish;
|
||||||
|
}
|
||||||
|
await cloudSocket.SendAsync(
|
||||||
|
message.Value.Payload,
|
||||||
|
WebSocketMessageType.Binary,
|
||||||
|
true,
|
||||||
|
ct);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Value.Type != WebSocketMessageType.Text) continue;
|
||||||
|
using var json = JsonDocument.Parse(message.Value.Payload);
|
||||||
|
var action = json.RootElement.TryGetProperty("action", out var actionElement)
|
||||||
|
? actionElement.GetString()
|
||||||
|
: null;
|
||||||
|
if (action == "cancel") return ClientAction.Cancel;
|
||||||
|
if (action != "finish") continue;
|
||||||
|
|
||||||
|
await SendJsonAsync(cloudSocket, new
|
||||||
|
{
|
||||||
|
header = new
|
||||||
|
{
|
||||||
|
action = "finish-task",
|
||||||
|
task_id = taskId,
|
||||||
|
streaming = "duplex",
|
||||||
|
},
|
||||||
|
payload = new { input = new { } },
|
||||||
|
}, ct);
|
||||||
|
return ClientAction.Finish;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClientAction.Disconnect;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PumpCloudResultsAsync(
|
||||||
|
WebSocket cloudSocket,
|
||||||
|
WebSocket clientSocket,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var committed = new StringBuilder();
|
||||||
|
var latest = "";
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested &&
|
||||||
|
cloudSocket.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
var message = await ReceiveMessageAsync(cloudSocket, ct);
|
||||||
|
if (message is null) return;
|
||||||
|
if (message.Value.Type != WebSocketMessageType.Text) continue;
|
||||||
|
|
||||||
|
using var json = JsonDocument.Parse(message.Value.Payload);
|
||||||
|
var root = json.RootElement;
|
||||||
|
if (!root.TryGetProperty("header", out var header)) continue;
|
||||||
|
var eventName = header.TryGetProperty("event", out var eventElement)
|
||||||
|
? eventElement.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (eventName == "result-generated")
|
||||||
|
{
|
||||||
|
if (!TryReadSentence(root, out var sentence, out var sentenceEnd))
|
||||||
|
continue;
|
||||||
|
if (sentenceEnd && !string.IsNullOrWhiteSpace(sentence))
|
||||||
|
committed.Append(sentence);
|
||||||
|
latest = sentenceEnd
|
||||||
|
? committed.ToString()
|
||||||
|
: committed + sentence;
|
||||||
|
await SendClientEventAsync(clientSocket, new
|
||||||
|
{
|
||||||
|
type = "partial",
|
||||||
|
text = latest,
|
||||||
|
sentenceEnd,
|
||||||
|
}, ct);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName == "task-finished")
|
||||||
|
{
|
||||||
|
await SendClientEventAsync(
|
||||||
|
clientSocket,
|
||||||
|
new { type = "done", text = latest.Trim() },
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName == "task-failed")
|
||||||
|
{
|
||||||
|
var error = header.TryGetProperty("error_message", out var errorElement)
|
||||||
|
? errorElement.GetString()
|
||||||
|
: "语音识别失败";
|
||||||
|
_logger.LogWarning("DashScope ASR task failed: {Error}", error);
|
||||||
|
await SendClientEventAsync(
|
||||||
|
clientSocket,
|
||||||
|
new { type = "error", message = "语音识别失败,请重试" },
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadSentence(
|
||||||
|
JsonElement root,
|
||||||
|
out string text,
|
||||||
|
out bool sentenceEnd)
|
||||||
|
{
|
||||||
|
text = "";
|
||||||
|
sentenceEnd = false;
|
||||||
|
if (!root.TryGetProperty("payload", out var payload) ||
|
||||||
|
!payload.TryGetProperty("output", out var output) ||
|
||||||
|
!output.TryGetProperty("sentence", out var sentence))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
text = sentence.TryGetProperty("text", out var textElement)
|
||||||
|
? textElement.GetString() ?? ""
|
||||||
|
: "";
|
||||||
|
sentenceEnd = sentence.TryGetProperty("sentence_end", out var endElement) &&
|
||||||
|
endElement.ValueKind == JsonValueKind.True;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task SendClientEventAsync(
|
||||||
|
WebSocket socket,
|
||||||
|
object value,
|
||||||
|
CancellationToken ct) => SendJsonAsync(socket, value, ct);
|
||||||
|
|
||||||
|
private static async Task SendJsonAsync(
|
||||||
|
WebSocket socket,
|
||||||
|
object value,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (socket.State != WebSocketState.Open) return;
|
||||||
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
|
||||||
|
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(WebSocketMessageType Type, byte[] Payload)?>
|
||||||
|
ReceiveMessageAsync(WebSocket socket, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var buffer = new byte[16 * 1024];
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var result = await socket.ReceiveAsync(buffer, ct);
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close) return null;
|
||||||
|
stream.Write(buffer, 0, result.Count);
|
||||||
|
if (stream.Length > 2 * 1024 * 1024)
|
||||||
|
throw new InvalidOperationException("WebSocket 消息过大");
|
||||||
|
if (result.EndOfMessage)
|
||||||
|
return (result.MessageType, stream.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CloseSocketQuietlyAsync(WebSocket socket)
|
||||||
|
{
|
||||||
|
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await socket.CloseAsync(
|
||||||
|
WebSocketCloseStatus.NormalClosure,
|
||||||
|
"completed",
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (WebSocketException)
|
||||||
|
{
|
||||||
|
// 对端可能已经主动断开。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ClientAction
|
||||||
|
{
|
||||||
|
Finish,
|
||||||
|
Cancel,
|
||||||
|
Disconnect,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,44 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
|||||||
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
|
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
|
||||||
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||||
|
|
||||||
|
public async Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var fileUrls = new List<string>();
|
||||||
|
|
||||||
|
var userAvatar = await _db.Users
|
||||||
|
.Where(u => u.Id == userId)
|
||||||
|
.Select(u => u.AvatarUrl)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, userAvatar);
|
||||||
|
|
||||||
|
var doctorProfile = await _db.DoctorProfiles
|
||||||
|
.Where(p => p.UserId == userId)
|
||||||
|
.Select(p => new { p.DoctorId, p.AvatarUrl })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, doctorProfile?.AvatarUrl);
|
||||||
|
|
||||||
|
if (doctorProfile?.DoctorId != null)
|
||||||
|
{
|
||||||
|
var doctorAvatar = await _db.Doctors
|
||||||
|
.Where(d => d.Id == doctorProfile.DoctorId)
|
||||||
|
.Select(d => d.AvatarUrl)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, doctorAvatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileUrls.AddRange(await _db.Reports
|
||||||
|
.Where(r => r.UserId == userId && r.FileUrl != "")
|
||||||
|
.Select(r => r.FileUrl)
|
||||||
|
.ToListAsync(ct));
|
||||||
|
|
||||||
|
var metadata = await _db.ConversationMessages
|
||||||
|
.Where(m => m.Conversation!.UserId == userId && m.MetadataJson != null && m.MetadataJson != "")
|
||||||
|
.Select(m => m.MetadataJson!)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new AccountFileReferences(fileUrls.Distinct().ToList(), metadata);
|
||||||
|
}
|
||||||
|
|
||||||
public Task SaveChangesAsync(CancellationToken ct) =>
|
public Task SaveChangesAsync(CancellationToken ct) =>
|
||||||
_db.SaveChangesAsync(ct);
|
_db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
@@ -50,4 +88,9 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
|||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AddIfPresent(List<string> values, string? value)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) values.Add(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Health.Application.Users;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Users;
|
||||||
|
|
||||||
|
public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCleanup
|
||||||
|
{
|
||||||
|
private readonly string _uploadsRoot = Path.GetFullPath(uploadsRoot);
|
||||||
|
|
||||||
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 正式报告路径来自服务端生成的报告记录,只允许删除 reports 目录中的文件。
|
||||||
|
foreach (var fileUrl in references.FileUrls)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var localPath = ResolveReportPath(fileUrl);
|
||||||
|
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话元数据可能包含客户端传入的 URL,只允许解析当前账号自己的目录。
|
||||||
|
var attachmentUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||||
|
AddMetadataUrls(attachmentUrls, metadataJson);
|
||||||
|
|
||||||
|
foreach (var fileUrl in attachmentUrls)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var localPath = ResolveOwnedAttachmentPath(userId, fileUrl);
|
||||||
|
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var userDirectory = Path.Combine(_uploadsRoot, "users", userId.ToString("N"));
|
||||||
|
if (Directory.Exists(userDirectory)) Directory.Delete(userDirectory, recursive: true);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddMetadataUrls(HashSet<string> fileUrls, string metadataJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(metadataJson);
|
||||||
|
AddStringProperty(document.RootElement, "imageUrl", fileUrls);
|
||||||
|
AddStringProperty(document.RootElement, "pdfUrl", fileUrls);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// 旧消息元数据可能不是合法 JSON;不影响其余账号数据注销。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddStringProperty(JsonElement root, string propertyName, HashSet<string> fileUrls)
|
||||||
|
{
|
||||||
|
if (root.ValueKind != JsonValueKind.Object ||
|
||||||
|
!root.TryGetProperty(propertyName, out var property) ||
|
||||||
|
property.ValueKind != JsonValueKind.String)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var value = property.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveReportPath(string fileUrl)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||||
|
? absolute.AbsolutePath
|
||||||
|
: fileUrl.Split('?', 2)[0];
|
||||||
|
var prefixIndex = path.IndexOf("/uploads/reports/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (prefixIndex < 0) return null;
|
||||||
|
var fileName = Uri.UnescapeDataString(path[(prefixIndex + "/uploads/reports/".Length)..]);
|
||||||
|
return ResolveInside(Path.Combine(_uploadsRoot, "reports"), fileName);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (UriFormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveOwnedAttachmentPath(Guid userId, string fileUrl)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||||
|
? absolute.AbsolutePath
|
||||||
|
: fileUrl.Split('?', 2)[0];
|
||||||
|
path = Uri.UnescapeDataString(path);
|
||||||
|
var protectedPrefix = "/api/files/content/";
|
||||||
|
var protectedIndex = path.IndexOf(protectedPrefix, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var legacyPrefix = $"/uploads/users/{userId:N}/";
|
||||||
|
var legacyIndex = path.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var fileName = protectedIndex >= 0
|
||||||
|
? path[(protectedIndex + protectedPrefix.Length)..]
|
||||||
|
: legacyIndex >= 0
|
||||||
|
? path[(legacyIndex + legacyPrefix.Length)..]
|
||||||
|
: null;
|
||||||
|
return fileName == null
|
||||||
|
? null
|
||||||
|
: ResolveInside(Path.Combine(_uploadsRoot, "users", userId.ToString("N")), fileName);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (UriFormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveInside(string root, string fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
|
||||||
|
var fullRoot = Path.GetFullPath(root);
|
||||||
|
var fullPath = Path.GetFullPath(Path.Combine(fullRoot, fileName));
|
||||||
|
var rootPrefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||||
|
+ Path.DirectorySeparatorChar;
|
||||||
|
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,107 +1,66 @@
|
|||||||
using Health.Application.Notifications;
|
using Health.Application.Notifications;
|
||||||
using Health.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Health.WebApi.BackgroundServices;
|
namespace Health.WebApi.BackgroundServices;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
|
/// Produces in-app catch-up reminders for health records.
|
||||||
/// 对未录入指标且开关开启的用户推送提醒。
|
/// The actual reminder rules live in IReminderCatchUpService so foreground
|
||||||
/// 中国时区 UTC+8。
|
/// app checks and background scans stay consistent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HealthRecordReminderService(
|
public sealed class HealthRecordReminderService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
private static readonly int[] ReminderHoursCst = [9, 13, 19];
|
||||||
private readonly ILogger<HealthRecordReminderService> _logger = logger;
|
|
||||||
|
|
||||||
// 北京时间 9:00 和 12:00 触发
|
|
||||||
private static readonly int[] ReminderHoursCst = [9, 12];
|
|
||||||
|
|
||||||
private DateTime _lastFired = DateTime.MinValue;
|
private DateTime _lastFired = DateTime.MinValue;
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("健康录入提醒服务已启动");
|
logger.LogInformation("Health record in-app reminder service started");
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
// 当前小时是否在触发列表,且今天该小时还没触发过
|
if (ReminderHoursCst.Contains(nowCst.Hour) &&
|
||||||
if (ReminderHoursCst.Contains(nowCst.Hour)
|
(_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||||
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
|
||||||
{
|
{
|
||||||
await ScanAndPushAsync(nowCst, stoppingToken);
|
await CheckUsersAsync(DateTime.UtcNow, stoppingToken);
|
||||||
_lastFired = nowCst;
|
_lastFired = nowCst;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Health record in-app reminder scan failed");
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
|
private async Task CheckUsersAsync(DateTime utcNow, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
var catchUp = scope.ServiceProvider.GetRequiredService<IReminderCatchUpService>();
|
||||||
|
|
||||||
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
|
var userIds = await db.NotificationPreferences.AsNoTracking()
|
||||||
var todayEnd = todayStart.AddDays(1);
|
.Where(x => x.PushEnabled && x.HealthRecordReminder)
|
||||||
|
.Select(x => x.UserId)
|
||||||
// 取所有启用了健康录入提醒的用户偏好
|
|
||||||
var prefs = await db.NotificationPreferences
|
|
||||||
.Where(p => p.HealthRecordReminder)
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
foreach (var pref in prefs)
|
var created = 0;
|
||||||
|
foreach (var userId in userIds)
|
||||||
{
|
{
|
||||||
// 该用户当天已录入的指标
|
var result = await catchUp.CheckDueAsync(userId, utcNow, ct);
|
||||||
var recordedTypes = await db.HealthRecords
|
created += result.CreatedCount;
|
||||||
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
|
|
||||||
.Select(r => r.MetricType)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
// 哪些指标启用开关但当天没录入
|
|
||||||
var missing = new List<(HealthMetricType type, bool enabled, string label)>
|
|
||||||
{
|
|
||||||
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
|
|
||||||
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
|
|
||||||
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
|
|
||||||
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
|
|
||||||
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
|
|
||||||
};
|
|
||||||
|
|
||||||
var missingLabels = missing
|
|
||||||
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
|
|
||||||
.Select(m => m.label)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (missingLabels.Count == 0) continue;
|
|
||||||
|
|
||||||
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
|
|
||||||
var sourceId = MakeSourceId(pref.UserId, nowCst);
|
|
||||||
var summary = string.Join("、", missingLabels);
|
|
||||||
|
|
||||||
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
|
|
||||||
Type: "health_record_reminder",
|
|
||||||
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
|
|
||||||
Message: $"建议录入:{summary}",
|
|
||||||
Severity: "info",
|
|
||||||
ActionType: "health",
|
|
||||||
ActionTargetId: null), ct);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
|
if (created > 0)
|
||||||
{
|
logger.LogInformation("Created {Count} in-app catch-up reminders", created);
|
||||||
// 通过哈希构造稳定的 Guid,保证同一用户同一时段不重复
|
|
||||||
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
|
|
||||||
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
|
||||||
return new Guid(bytes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,12 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
public static void MapAiChatEndpoints(this WebApplication app)
|
public static void MapAiChatEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
// SSE 流式对话(GET 方式,token 通过 query string 传递)
|
// SSE 流式对话。认证统一走 ASP.NET Core JWT 中间件。
|
||||||
app.MapGet("/api/ai/{agentType}/chat", async (
|
app.MapGet("/api/ai/{agentType}/chat", async (
|
||||||
string message,
|
string message,
|
||||||
string? conversationId,
|
string? conversationId,
|
||||||
string? imageUrl,
|
string? imageUrl,
|
||||||
string? pdfUrl,
|
string? pdfUrl,
|
||||||
string token,
|
|
||||||
string agentType,
|
string agentType,
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
DeepSeekClient llmClient,
|
DeepSeekClient llmClient,
|
||||||
@@ -38,13 +37,13 @@ public static class AiChatEndpoints
|
|||||||
FastGptKnowledgeClient knowledgeClient,
|
FastGptKnowledgeClient knowledgeClient,
|
||||||
IAiToolExecutionService toolExecution,
|
IAiToolExecutionService toolExecution,
|
||||||
IAiWriteConfirmationStore confirmations,
|
IAiWriteConfirmationStore confirmations,
|
||||||
|
IMedicationService medications,
|
||||||
IAiConversationService conversations,
|
IAiConversationService conversations,
|
||||||
IAttachmentContextBuilder attachments,
|
IAttachmentContextBuilder attachments,
|
||||||
IPatientContextService patientContexts,
|
IPatientContextService patientContexts,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
// 支持 token 通过 query string(浏览器 EventSource)或 header 传递
|
var userId = GetUserId(http);
|
||||||
var userId = GetUserId(http) ?? GetUserIdFromToken(token);
|
|
||||||
if (userId == null)
|
if (userId == null)
|
||||||
{
|
{
|
||||||
http.Response.StatusCode = 401;
|
http.Response.StatusCode = 401;
|
||||||
@@ -89,7 +88,7 @@ public static class AiChatEndpoints
|
|||||||
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
|
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
|
||||||
|
|
||||||
// 附件解析(图片走 VLM、PDF 走 PdfPig),结果同时拼 LLM 上下文 + 持久化到 user message metadata
|
// 附件解析(图片走 VLM、PDF 走 PdfPig),结果同时拼 LLM 上下文 + 持久化到 user message metadata
|
||||||
var attachment = await attachments.BuildAsync(imageUrl, pdfUrl, ct);
|
var attachment = await attachments.BuildAsync(userId.Value, imageUrl, pdfUrl, ct);
|
||||||
string? userMessageMetadataJson = null;
|
string? userMessageMetadataJson = null;
|
||||||
if (attachment != null)
|
if (attachment != null)
|
||||||
{
|
{
|
||||||
@@ -141,7 +140,11 @@ public static class AiChatEndpoints
|
|||||||
knowledgeSnippet = await knowledgeClient.SearchAsync(message, ct);
|
knowledgeSnippet = await knowledgeClient.SearchAsync(message, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
var enhancedSystem = systemPrompt + "\n\n当前患者信息:\n" + patientContext;
|
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||||
|
var enhancedSystem = systemPrompt
|
||||||
|
+ $"\n\n系统当前北京时间:{beijingNow:yyyy-MM-dd HH:mm:ss}(UTC+8)。所有‘今天/昨天/当前/下一次’均以此时间为准。"
|
||||||
|
+ "\n\n当前患者背景信息(仅作医学背景;个人计划和完成状态必须调用对应工具获取最新数据):\n"
|
||||||
|
+ patientContext;
|
||||||
if (!string.IsNullOrWhiteSpace(knowledgeSnippet))
|
if (!string.IsNullOrWhiteSpace(knowledgeSnippet))
|
||||||
{
|
{
|
||||||
enhancedSystem += "\n\n知识库参考资料(如与用户问题相关请优先采用):\n" + knowledgeSnippet;
|
enhancedSystem += "\n\n知识库参考资料(如与用户问题相关请优先采用):\n" + knowledgeSnippet;
|
||||||
@@ -245,7 +248,7 @@ public static class AiChatEndpoints
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
toolResult = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments)
|
toolResult = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments)
|
||||||
? await PreparePendingWriteAsync(confirmations, userId.Value, tc.Function.Name, tc.Function.Arguments, ct)
|
? await PreparePendingWriteAsync(confirmations, medications, userId.Value, tc.Function.Name, tc.Function.Arguments, ct)
|
||||||
: await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct);
|
: await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -277,7 +280,7 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
||||||
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||||
});
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapPost("/api/ai/confirm-write/{commandId:guid}", async (
|
app.MapPost("/api/ai/confirm-write/{commandId:guid}", async (
|
||||||
Guid commandId,
|
Guid commandId,
|
||||||
@@ -321,9 +324,13 @@ public static class AiChatEndpoints
|
|||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||||
|
|
||||||
await conversations.DeleteAsync(userId.Value, id, ct);
|
var deleted = await conversations.DeleteAsync(userId.Value, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
});
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Json(
|
||||||
|
new { code = 40401, data = (object?)null, message = "对话不存在" },
|
||||||
|
statusCode: StatusCodes.Status404NotFound);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// 一键清空当前用户的全部对话
|
// 一键清空当前用户的全部对话
|
||||||
app.MapDelete("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
|
app.MapDelete("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
|
||||||
@@ -335,6 +342,49 @@ public static class AiChatEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 饮食分析页专用建议:不创建会话,不保存提示词或回复到历史记录。
|
||||||
|
app.MapPost("/api/ai/diet-commentary", async (
|
||||||
|
DietCommentaryRequest request,
|
||||||
|
HttpContext http,
|
||||||
|
DeepSeekClient llmClient,
|
||||||
|
IPatientContextService patientContexts,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null)
|
||||||
|
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
|
||||||
|
|
||||||
|
var foods = request.Foods?
|
||||||
|
.Where(food => !string.IsNullOrWhiteSpace(food.Name) &&
|
||||||
|
!string.IsNullOrWhiteSpace(food.Portion) &&
|
||||||
|
food.Calories > 0)
|
||||||
|
.Take(20)
|
||||||
|
.Select(food => new DietCommentaryFood(food.Name, food.Portion, food.Calories))
|
||||||
|
.ToList() ?? [];
|
||||||
|
if (foods.Count == 0)
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "请先完善食物信息" });
|
||||||
|
|
||||||
|
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
||||||
|
var foodDescription = DietCommentaryPolicy.BuildFoodDescription(foods);
|
||||||
|
var response = await llmClient.ChatAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage
|
||||||
|
{
|
||||||
|
Role = "system",
|
||||||
|
Content = DietCommentaryPolicy.SystemPrompt + "\n\n当前用户健康档案:\n" + patientContext,
|
||||||
|
},
|
||||||
|
new ChatMessage { Role = "user", Content = "本餐食物:" + foodDescription },
|
||||||
|
],
|
||||||
|
maxTokens: 300,
|
||||||
|
temperature: 0.3f,
|
||||||
|
ct: ct);
|
||||||
|
var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(commentary))
|
||||||
|
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" });
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapPost("/api/ai/analyze-food-image", async (
|
app.MapPost("/api/ai/analyze-food-image", async (
|
||||||
HttpRequest httpRequest,
|
HttpRequest httpRequest,
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
@@ -374,19 +424,6 @@ public static class AiChatEndpoints
|
|||||||
private static Guid? GetUserId(HttpContext http) =>
|
private static Guid? GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null;
|
||||||
|
|
||||||
private static Guid? GetUserIdFromToken(string? token)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(token)) return null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
|
|
||||||
var jwt = handler.ReadJwtToken(token);
|
|
||||||
var sub = jwt.Claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
return sub != null && Guid.TryParse(sub, out var id) ? id : null;
|
|
||||||
}
|
|
||||||
catch (Exception) { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task TryUpdateConversationSummaryAsync(
|
private static async Task TryUpdateConversationSummaryAsync(
|
||||||
IAiConversationService conversations,
|
IAiConversationService conversations,
|
||||||
DeepSeekClient llmClient,
|
DeepSeekClient llmClient,
|
||||||
@@ -527,6 +564,10 @@ public static class AiChatEndpoints
|
|||||||
MedicationAgentHandler.ManageMedicationTool,
|
MedicationAgentHandler.ManageMedicationTool,
|
||||||
ExerciseAgentHandler.ManageExerciseTool,
|
ExerciseAgentHandler.ManageExerciseTool,
|
||||||
CommonAgentHandler.CheckArchiveTool,
|
CommonAgentHandler.CheckArchiveTool,
|
||||||
|
PatientReadAgentHandler.QueryDietRecordsTool,
|
||||||
|
PatientReadAgentHandler.QueryFollowUpsTool,
|
||||||
|
PatientReadAgentHandler.QueryReportsTool,
|
||||||
|
PatientReadAgentHandler.QueryNotificationsTool,
|
||||||
],
|
],
|
||||||
_ => CommonAgentHandler.Tools,
|
_ => CommonAgentHandler.Tools,
|
||||||
};
|
};
|
||||||
@@ -534,7 +575,6 @@ public static class AiChatEndpoints
|
|||||||
private static bool IsWriteToolCall(string toolName, string arguments)
|
private static bool IsWriteToolCall(string toolName, string arguments)
|
||||||
{
|
{
|
||||||
if (toolName == "record_health_data") return true;
|
if (toolName == "record_health_data") return true;
|
||||||
if (toolName == "manage_archive") return GetToolAction(arguments) != "query";
|
|
||||||
if (toolName == "manage_medication") return GetToolAction(arguments) is "create" or "confirm";
|
if (toolName == "manage_medication") return GetToolAction(arguments) is "create" or "confirm";
|
||||||
if (toolName == "manage_exercise") return GetToolAction(arguments) is "create" or "checkin";
|
if (toolName == "manage_exercise") return GetToolAction(arguments) is "create" or "checkin";
|
||||||
return false;
|
return false;
|
||||||
@@ -557,14 +597,39 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
private static async Task<object> PreparePendingWriteAsync(
|
private static async Task<object> PreparePendingWriteAsync(
|
||||||
IAiWriteConfirmationStore confirmations,
|
IAiWriteConfirmationStore confirmations,
|
||||||
|
IMedicationService medications,
|
||||||
Guid userId,
|
Guid userId,
|
||||||
string toolName,
|
string toolName,
|
||||||
string arguments,
|
string arguments,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct);
|
|
||||||
using var json = JsonDocument.Parse(arguments);
|
using var json = JsonDocument.Parse(arguments);
|
||||||
var args = json.RootElement;
|
var args = json.RootElement;
|
||||||
|
AiMedicationPlanDto? medicationPlan = null;
|
||||||
|
if (toolName == "manage_medication")
|
||||||
|
{
|
||||||
|
var validationError = MedicationAgentHandler.ValidateWriteArguments(args);
|
||||||
|
if (validationError != null)
|
||||||
|
return new { success = false, message = validationError };
|
||||||
|
|
||||||
|
if (string.Equals(GetString(args, "action"), "confirm", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var medicationId = args.GetProperty("medication_id").GetGuid();
|
||||||
|
var scheduledTime = GetString(args, "scheduled_time")!;
|
||||||
|
var overview = await medications.GetAiOverviewAsync(
|
||||||
|
userId,
|
||||||
|
DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||||
|
ct);
|
||||||
|
medicationPlan = overview.FirstOrDefault(plan =>
|
||||||
|
plan.Id == medicationId &&
|
||||||
|
plan.ScheduledOnDate &&
|
||||||
|
plan.Doses.Any(dose => dose.ScheduledTime == scheduledTime && dose.Status is "scheduled" or "overdue"));
|
||||||
|
if (medicationPlan == null)
|
||||||
|
return new { success = false, message = "没有找到这顿尚未确认的今日服药安排,请重新查询后再确认" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct);
|
||||||
|
|
||||||
var preview = new Dictionary<string, object?>
|
var preview = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
@@ -581,12 +646,15 @@ public static class AiChatEndpoints
|
|||||||
break;
|
break;
|
||||||
case "manage_medication":
|
case "manage_medication":
|
||||||
preview["type"] = "medication";
|
preview["type"] = "medication";
|
||||||
preview["name"] = GetString(args, "name") ?? "服药确认";
|
var medicationAction = GetString(args, "action");
|
||||||
preview["dosage"] = GetString(args, "dosage") ?? "";
|
preview["name"] = medicationPlan?.Name ?? GetString(args, "name") ?? "用药计划";
|
||||||
preview["frequency"] = GetString(args, "frequency") ?? "Daily";
|
preview["operation"] = medicationAction ?? "create";
|
||||||
preview["time"] = GetStringArray(args, "time_of_day");
|
preview["dosage"] = medicationPlan?.Dosage ?? GetString(args, "dosage") ?? "";
|
||||||
|
preview["frequency"] = medicationPlan?.Frequency ?? GetString(args, "frequency") ?? "";
|
||||||
|
preview["time"] = GetString(args, "scheduled_time") ?? GetStringArray(args, "time_of_day");
|
||||||
preview["duration_days"] = GetInt(args, "duration_days") ?? 0;
|
preview["duration_days"] = GetInt(args, "duration_days") ?? 0;
|
||||||
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
|
preview["long_term"] = args.TryGetProperty("long_term", out var longTerm) && longTerm.ValueKind == JsonValueKind.True;
|
||||||
|
preview["start_date"] = medicationPlan?.StartDate?.ToString("yyyy-MM-dd") ?? GetString(args, "start_date") ?? "";
|
||||||
break;
|
break;
|
||||||
case "manage_exercise":
|
case "manage_exercise":
|
||||||
preview["type"] = "exercise";
|
preview["type"] = "exercise";
|
||||||
@@ -596,11 +664,6 @@ public static class AiChatEndpoints
|
|||||||
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
|
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
|
||||||
preview["reminder_time"] = GetString(args, "reminder_time") ?? "19:00";
|
preview["reminder_time"] = GetString(args, "reminder_time") ?? "19:00";
|
||||||
break;
|
break;
|
||||||
case "manage_archive":
|
|
||||||
preview["type"] = "archive";
|
|
||||||
preview["value"] = "健康档案更新";
|
|
||||||
preview["unit"] = "";
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return preview;
|
return preview;
|
||||||
@@ -623,7 +686,7 @@ public static class AiChatEndpoints
|
|||||||
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
||||||
break;
|
break;
|
||||||
case "glucose":
|
case "glucose":
|
||||||
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m);
|
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v > 6.1m || v < 3.9m);
|
||||||
break;
|
break;
|
||||||
case "spo2":
|
case "spo2":
|
||||||
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
||||||
@@ -773,6 +836,8 @@ public static class AiChatEndpoints
|
|||||||
{
|
{
|
||||||
if (resultDict.TryGetValue("name", out var name))
|
if (resultDict.TryGetValue("name", out var name))
|
||||||
metadata["name"] = name?.ToString() ?? "";
|
metadata["name"] = name?.ToString() ?? "";
|
||||||
|
if (resultDict.TryGetValue("operation", out var operation))
|
||||||
|
metadata["operation"] = operation?.ToString() ?? "create";
|
||||||
if (resultDict.TryGetValue("dosage", out var dosage))
|
if (resultDict.TryGetValue("dosage", out var dosage))
|
||||||
metadata["dosage"] = dosage?.ToString() ?? "";
|
metadata["dosage"] = dosage?.ToString() ?? "";
|
||||||
if (resultDict.TryGetValue("time", out var time))
|
if (resultDict.TryGetValue("time", out var time))
|
||||||
@@ -802,15 +867,6 @@ public static class AiChatEndpoints
|
|||||||
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "manage_archive":
|
|
||||||
if (!isPendingConfirmation) break;
|
|
||||||
messageType = "data_confirm";
|
|
||||||
metadata["type"] = "archive";
|
|
||||||
metadata["value"] = "健康档案更新";
|
|
||||||
metadata["unit"] = "";
|
|
||||||
metadata["success"] = true;
|
|
||||||
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
|
||||||
break;
|
|
||||||
case "analyze_report":
|
case "analyze_report":
|
||||||
messageType = "report_analysis";
|
messageType = "report_analysis";
|
||||||
break;
|
break;
|
||||||
@@ -818,3 +874,6 @@ public static class AiChatEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record DietCommentaryFoodRequest(string Name, string Portion, int Calories);
|
||||||
|
public sealed record DietCommentaryRequest(IReadOnlyList<DietCommentaryFoodRequest>? Foods);
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ public static class DietEndpoints
|
|||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await diets.DeleteAsync(userId, id, ct);
|
var deleted = await diets.DeleteAsync(userId, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "饮食记录不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -29,6 +29,34 @@ public static class DoctorEndpoints
|
|||||||
return profile?.DoctorId;
|
return profile?.DoctorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static (DateTime StartUtc, DateTime EndUtc) GetBeijingDayRange(DateTime utcNow)
|
||||||
|
{
|
||||||
|
var beijingNow = utcNow.ToUniversalTime().AddHours(8);
|
||||||
|
var startUtc = new DateTime(
|
||||||
|
beijingNow.Year,
|
||||||
|
beijingNow.Month,
|
||||||
|
beijingNow.Day,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
DateTimeKind.Utc).AddHours(-8);
|
||||||
|
return (startUtc, startUtc.AddDays(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime ParseBeijingDateTimeAsUtc(string value)
|
||||||
|
{
|
||||||
|
if (DateTimeOffset.TryParse(value, out var offset) &&
|
||||||
|
(value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
value.LastIndexOf('+') > 9 ||
|
||||||
|
value.LastIndexOf('-') > 9))
|
||||||
|
return offset.UtcDateTime;
|
||||||
|
|
||||||
|
var beijing = DateTime.Parse(value);
|
||||||
|
return DateTime.SpecifyKind(
|
||||||
|
DateTime.SpecifyKind(beijing, DateTimeKind.Unspecified).AddHours(-8),
|
||||||
|
DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
public static void MapDoctorEndpoints(this WebApplication app)
|
public static void MapDoctorEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
||||||
@@ -56,8 +84,7 @@ public static class DoctorEndpoints
|
|||||||
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
|
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
|
||||||
var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
|
var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
|
||||||
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
|
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
|
||||||
var todayStart = DateTime.UtcNow.Date;
|
var (todayStart, todayEnd) = GetBeijingDayRange(DateTime.UtcNow);
|
||||||
var todayEnd = todayStart.AddDays(1);
|
|
||||||
var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
||||||
|
|
||||||
// 待办列表
|
// 待办列表
|
||||||
@@ -92,6 +119,7 @@ public static class DoctorEndpoints
|
|||||||
doctorHospital = profile.Hospital,
|
doctorHospital = profile.Hospital,
|
||||||
doctorAvatar = profile.AvatarUrl,
|
doctorAvatar = profile.AvatarUrl,
|
||||||
doctorOnline = profile.IsOnline,
|
doctorOnline = profile.IsOnline,
|
||||||
|
doctorActive = profile.IsActive,
|
||||||
stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps },
|
stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps },
|
||||||
pendingConsultations,
|
pendingConsultations,
|
||||||
pendingReports = pendingReportList,
|
pendingReports = pendingReportList,
|
||||||
@@ -112,7 +140,7 @@ public static class DoctorEndpoints
|
|||||||
{
|
{
|
||||||
query = query.Where(u =>
|
query = query.Where(u =>
|
||||||
(u.Name != null && u.Name.Contains(search)) ||
|
(u.Name != null && u.Name.Contains(search)) ||
|
||||||
u.Phone.Contains(search));
|
(u.Phone != null && u.Phone.Contains(search)));
|
||||||
}
|
}
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
var patients = await query.OrderByDescending(u => u.CreatedAt)
|
var patients = await query.OrderByDescending(u => u.CreatedAt)
|
||||||
@@ -190,7 +218,8 @@ public static class DoctorEndpoints
|
|||||||
user.HealthArchive.Allergies,
|
user.HealthArchive.Allergies,
|
||||||
user.HealthArchive.DietRestrictions,
|
user.HealthArchive.DietRestrictions,
|
||||||
user.HealthArchive.ChronicDiseases,
|
user.HealthArchive.ChronicDiseases,
|
||||||
user.HealthArchive.FamilyHistory
|
user.HealthArchive.FamilyHistory,
|
||||||
|
user.HealthArchive.SurgeryHistoryStatus
|
||||||
},
|
},
|
||||||
latestRecords,
|
latestRecords,
|
||||||
trendRecords,
|
trendRecords,
|
||||||
@@ -289,7 +318,7 @@ public static class DoctorEndpoints
|
|||||||
query = query.Where(r => r.Status == s);
|
query = query.Where(r => r.Status == s);
|
||||||
|
|
||||||
var reports = await query.OrderByDescending(r => r.CreatedAt)
|
var reports = await query.OrderByDescending(r => r.CreatedAt)
|
||||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
||||||
});
|
});
|
||||||
@@ -302,7 +331,7 @@ public static class DoctorEndpoints
|
|||||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId)
|
var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId)
|
||||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
||||||
return Results.Ok(new { code = 0, data = report, message = (string?)null });
|
return Results.Ok(new { code = 0, data = report, message = (string?)null });
|
||||||
@@ -376,7 +405,7 @@ public static class DoctorEndpoints
|
|||||||
Title = json.RootElement.GetProperty("title").GetString() ?? "",
|
Title = json.RootElement.GetProperty("title").GetString() ?? "",
|
||||||
DoctorName = profile.Name,
|
DoctorName = profile.Name,
|
||||||
Department = profile.Department,
|
Department = profile.Department,
|
||||||
ScheduledAt = DateTime.Parse(json.RootElement.GetProperty("scheduledAt").GetString()!),
|
ScheduledAt = ParseBeijingDateTimeAsUtc(json.RootElement.GetProperty("scheduledAt").GetString()!),
|
||||||
Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null,
|
Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null,
|
||||||
Status = FollowUpStatus.Upcoming,
|
Status = FollowUpStatus.Upcoming,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
@@ -400,7 +429,7 @@ public static class DoctorEndpoints
|
|||||||
var body = await reader.ReadToEndAsync(ct);
|
var body = await reader.ReadToEndAsync(ct);
|
||||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||||
if (json.RootElement.TryGetProperty("title", out var t)) followUp.Title = t.GetString() ?? followUp.Title;
|
if (json.RootElement.TryGetProperty("title", out var t)) followUp.Title = t.GetString() ?? followUp.Title;
|
||||||
if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = DateTime.Parse(sa.GetString()!);
|
if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = ParseBeijingDateTimeAsUtc(sa.GetString()!);
|
||||||
if (json.RootElement.TryGetProperty("notes", out var no)) followUp.Notes = no.GetString();
|
if (json.RootElement.TryGetProperty("notes", out var no)) followUp.Notes = no.GetString();
|
||||||
if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse<FollowUpStatus>(st.GetString(), out var fs)) followUp.Status = fs;
|
if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse<FollowUpStatus>(st.GetString(), out var fs)) followUp.Status = fs;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ public static class ExerciseEndpoints
|
|||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await exercises.DeleteAsync(userId, id, ct);
|
var deleted = await exercises.DeleteAsync(userId, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "运动计划不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Health.Infrastructure.Files;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
public static class FileEndpoints
|
public static class FileEndpoints
|
||||||
@@ -12,8 +14,9 @@ public static class FileEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/files").RequireAuthorization();
|
var group = app.MapGroup("/api/files").RequireAuthorization();
|
||||||
|
|
||||||
group.MapPost("/upload", async (HttpRequest request, CancellationToken ct) =>
|
group.MapPost("/upload", async (HttpContext http, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
var request = http.Request;
|
||||||
if (!request.HasFormContentType)
|
if (!request.HasFormContentType)
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
|
||||||
|
|
||||||
@@ -23,7 +26,12 @@ public static class FileEndpoints
|
|||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
|
||||||
|
|
||||||
var results = new List<object>();
|
var results = new List<object>();
|
||||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
var userDirectoryName = GetUserId(http).ToString("N");
|
||||||
|
var uploadsDir = Path.Combine(
|
||||||
|
Directory.GetCurrentDirectory(),
|
||||||
|
"uploads",
|
||||||
|
"users",
|
||||||
|
userDirectoryName);
|
||||||
Directory.CreateDirectory(uploadsDir);
|
Directory.CreateDirectory(uploadsDir);
|
||||||
|
|
||||||
foreach (var file in files)
|
foreach (var file in files)
|
||||||
@@ -41,19 +49,48 @@ public static class FileEndpoints
|
|||||||
|
|
||||||
var storedName = $"{fileId}{ext}";
|
var storedName = $"{fileId}{ext}";
|
||||||
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
|
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
|
||||||
await using var stream = new FileStream(filePath, FileMode.Create);
|
await using var stream = new FileStream(filePath, FileMode.CreateNew);
|
||||||
await file.CopyToAsync(stream, ct);
|
await file.CopyToAsync(stream, ct);
|
||||||
results.Add(new
|
results.Add(new
|
||||||
{
|
{
|
||||||
id = fileId,
|
id = fileId,
|
||||||
name = file.FileName,
|
name = file.FileName,
|
||||||
size = file.Length,
|
size = file.Length,
|
||||||
url = $"/uploads/{storedName}",
|
url = $"/api/files/content/{storedName}",
|
||||||
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapGet("/content/{fileName}", (string fileName, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var filePath = UserUploadPathResolver.Resolve(userId, fileName);
|
||||||
|
if (filePath == null || !File.Exists(filePath))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
return Results.File(
|
||||||
|
filePath,
|
||||||
|
ContentTypeFor(filePath),
|
||||||
|
enableRangeProcessing: true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
|
||||||
|
? id
|
||||||
|
: Guid.Empty;
|
||||||
|
|
||||||
|
private static string ContentTypeFor(string path) =>
|
||||||
|
Path.GetExtension(path).ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ public static class HealthEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("/batch", async (
|
||||||
|
IReadOnlyList<CreateHealthRecordRequest> requests,
|
||||||
|
HttpContext http,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var ids = await healthRecords.CreateManyAsync(
|
||||||
|
userId,
|
||||||
|
requests.Select(request => request.ToServiceRequest()).ToList(),
|
||||||
|
ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { ids }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (
|
group.MapPut("/{id:guid}", async (
|
||||||
Guid id,
|
Guid id,
|
||||||
CreateHealthRecordRequest req,
|
CreateHealthRecordRequest req,
|
||||||
|
|||||||
@@ -36,17 +36,10 @@ public static class MedicationEndpoints
|
|||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await medications.DeleteAsync(userId, id, ct);
|
var deleted = await medications.DeleteAsync(userId, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
});
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" });
|
||||||
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var taken = await medications.ToggleTodayTakenAsync(userId, id, ct);
|
|
||||||
if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" });
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { taken }, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
@@ -75,8 +68,10 @@ public static class MedicationEndpoints
|
|||||||
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
var cancelled = await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return cancelled
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "未找到对应的服药打卡记录" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,14 +105,19 @@ public static class MedicationEndpoints
|
|||||||
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
||||||
frequency = freq;
|
frequency = freq;
|
||||||
|
|
||||||
|
var hasEndDate = root.TryGetProperty("endDate", out var ed);
|
||||||
|
var clearEndDate = hasEndDate &&
|
||||||
|
(ed.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(ed.GetString()));
|
||||||
|
|
||||||
return new MedicationPatchRequest(
|
return new MedicationPatchRequest(
|
||||||
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
||||||
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||||
frequency,
|
frequency,
|
||||||
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
||||||
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
||||||
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
hasEndDate && !clearEndDate && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||||
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
|
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null,
|
||||||
|
clearEndDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ public static class NotificationEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("/check-due", async (HttpContext http, IReminderCatchUpService reminders, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||||
|
var result = await reminders.CheckDueAsync(userId, DateTime.UtcNow, ct);
|
||||||
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
@@ -90,6 +98,10 @@ public static class NotificationEndpoints
|
|||||||
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
||||||
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
||||||
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
||||||
|
if (body.PushEnabled.HasValue) pref.PushEnabled = body.PushEnabled.Value;
|
||||||
|
if (body.DndEnabled.HasValue) pref.DndEnabled = body.DndEnabled.Value;
|
||||||
|
if (body.DndStartMinutes is >= 0 and < 1440) pref.DndStartMinutes = body.DndStartMinutes.Value;
|
||||||
|
if (body.DndEndMinutes is >= 0 and < 1440) pref.DndEndMinutes = body.DndEndMinutes.Value;
|
||||||
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
||||||
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
||||||
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
||||||
@@ -100,7 +112,7 @@ public static class NotificationEndpoints
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
|
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
|
||||||
if (body.HealthRecordReminder is true && pref.HealthRecordReminder)
|
if (body.HealthRecordReminder is true && pref.PushEnabled && pref.HealthRecordReminder)
|
||||||
{
|
{
|
||||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
var todayStart = nowCst.Date.AddHours(-8);
|
var todayStart = nowCst.Date.AddHours(-8);
|
||||||
@@ -153,6 +165,10 @@ public static class NotificationEndpoints
|
|||||||
followUpReminder = pref.FollowUpReminder,
|
followUpReminder = pref.FollowUpReminder,
|
||||||
doctorReply = pref.DoctorReply,
|
doctorReply = pref.DoctorReply,
|
||||||
abnormalAlert = pref.AbnormalAlert,
|
abnormalAlert = pref.AbnormalAlert,
|
||||||
|
pushEnabled = pref.PushEnabled,
|
||||||
|
dndEnabled = pref.DndEnabled,
|
||||||
|
dndStartMinutes = pref.DndStartMinutes,
|
||||||
|
dndEndMinutes = pref.DndEndMinutes,
|
||||||
healthRecordReminder = pref.HealthRecordReminder,
|
healthRecordReminder = pref.HealthRecordReminder,
|
||||||
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
||||||
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
||||||
@@ -167,6 +183,10 @@ public sealed record NotificationPrefsUpdateDto(
|
|||||||
bool? FollowUpReminder,
|
bool? FollowUpReminder,
|
||||||
bool? DoctorReply,
|
bool? DoctorReply,
|
||||||
bool? AbnormalAlert,
|
bool? AbnormalAlert,
|
||||||
|
bool? PushEnabled,
|
||||||
|
bool? DndEnabled,
|
||||||
|
int? DndStartMinutes,
|
||||||
|
int? DndEndMinutes,
|
||||||
bool? HealthRecordReminder,
|
bool? HealthRecordReminder,
|
||||||
bool? HealthRecordReminderBloodPressure,
|
bool? HealthRecordReminderBloodPressure,
|
||||||
bool? HealthRecordReminderHeartRate,
|
bool? HealthRecordReminderHeartRate,
|
||||||
|
|||||||
@@ -24,6 +24,47 @@ public static class ReportEndpoints
|
|||||||
: Results.Ok(new { code = 0, data = report, message = (string?)null });
|
: Results.Ok(new { code = 0, data = report, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapGet("/{id:guid}/file", async (
|
||||||
|
Guid id,
|
||||||
|
HttpContext http,
|
||||||
|
AppDbContext db,
|
||||||
|
IReportFileStorage fileStorage,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var currentUserId = GetUserId(http);
|
||||||
|
var report = await db.Reports
|
||||||
|
.Include(r => r.User)
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||||
|
if (report == null)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var allowed = report.UserId == currentUserId;
|
||||||
|
if (!allowed && GetRole(http) == "Doctor")
|
||||||
|
{
|
||||||
|
var doctorId = await db.DoctorProfiles
|
||||||
|
.Where(profile => profile.UserId == currentUserId)
|
||||||
|
.Select(profile => profile.DoctorId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
allowed = doctorId != null && report.User.DoctorId == doctorId;
|
||||||
|
}
|
||||||
|
if (!allowed)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var filePath = fileStorage.GetLocalFilePath(report.FileUrl);
|
||||||
|
if (!fileStorage.Exists(filePath))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
return Results.File(filePath, contentType, enableRangeProcessing: true);
|
||||||
|
});
|
||||||
|
|
||||||
group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
|
group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
@@ -66,4 +107,9 @@ public static class ReportEndpoints
|
|||||||
|
|
||||||
private static Guid GetUserId(HttpContext http) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
|
||||||
|
private static string GetRole(HttpContext http) =>
|
||||||
|
http.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ??
|
||||||
|
http.User.FindFirst("Role")?.Value ??
|
||||||
|
"User";
|
||||||
}
|
}
|
||||||
|
|||||||
69
backend/src/Health.WebApi/Endpoints/speech_endpoints.cs
Normal file
69
backend/src/Health.WebApi/Endpoints/speech_endpoints.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Health.Application.Speech;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
public static class SpeechEndpoints
|
||||||
|
{
|
||||||
|
public static void MapSpeechEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.Map("/api/speech/realtime", async (
|
||||||
|
HttpContext http,
|
||||||
|
IRealtimeSpeechRecognitionProxy proxy,
|
||||||
|
ILoggerFactory loggerFactory) =>
|
||||||
|
{
|
||||||
|
if (!http.WebSockets.IsWebSocketRequest)
|
||||||
|
{
|
||||||
|
http.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||||
|
await http.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
code = 40001,
|
||||||
|
data = (object?)null,
|
||||||
|
message = "该接口仅支持 WebSocket 连接",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proxy.IsConfigured)
|
||||||
|
{
|
||||||
|
http.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||||
|
await http.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
code = 50301,
|
||||||
|
data = (object?)null,
|
||||||
|
message = "实时语音识别服务尚未配置",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var socket = await http.WebSockets.AcceptWebSocketAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await proxy.ProxyAsync(socket, http.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (http.RequestAborted.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// 客户端离开页面或断开网络。
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
loggerFactory.CreateLogger("SpeechEndpoints")
|
||||||
|
.LogError(ex, "Realtime speech proxy failed");
|
||||||
|
if (socket.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
type = "error",
|
||||||
|
message = "语音服务暂时不可用,请稍后重试",
|
||||||
|
});
|
||||||
|
await socket.SendAsync(
|
||||||
|
payload,
|
||||||
|
WebSocketMessageType.Text,
|
||||||
|
true,
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).RequireAuthorization(policy => policy.RequireRole("User"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,8 @@ public static class UserEndpoints
|
|||||||
req.DietRestrictions,
|
req.DietRestrictions,
|
||||||
req.ChronicDiseases,
|
req.ChronicDiseases,
|
||||||
req.FamilyHistory,
|
req.FamilyHistory,
|
||||||
surgeries),
|
surgeries,
|
||||||
|
req.SurgeryHistoryStatus),
|
||||||
ct);
|
ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
@@ -70,6 +71,7 @@ public sealed record UpdateArchiveRequest(
|
|||||||
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
||||||
List<string>? Allergies, List<string>? DietRestrictions,
|
List<string>? Allergies, List<string>? DietRestrictions,
|
||||||
List<string>? ChronicDiseases, string? FamilyHistory,
|
List<string>? ChronicDiseases, string? FamilyHistory,
|
||||||
List<UpdateArchiveSurgeryRequest>? Surgeries);
|
List<UpdateArchiveSurgeryRequest>? Surgeries,
|
||||||
|
string? SurgeryHistoryStatus);
|
||||||
|
|
||||||
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);
|
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using Health.Application.Medications;
|
|||||||
using Health.Application.Maintenance;
|
using Health.Application.Maintenance;
|
||||||
using Health.Application.Notifications;
|
using Health.Application.Notifications;
|
||||||
using Health.Application.Reports;
|
using Health.Application.Reports;
|
||||||
|
using Health.Application.Speech;
|
||||||
using Health.Application.Users;
|
using Health.Application.Users;
|
||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
using Health.Infrastructure.Admin;
|
using Health.Infrastructure.Admin;
|
||||||
@@ -25,6 +26,7 @@ using Health.Infrastructure.Medications;
|
|||||||
using Health.Infrastructure.Maintenance;
|
using Health.Infrastructure.Maintenance;
|
||||||
using Health.Infrastructure.Notifications;
|
using Health.Infrastructure.Notifications;
|
||||||
using Health.Infrastructure.Reports;
|
using Health.Infrastructure.Reports;
|
||||||
|
using Health.Infrastructure.Speech;
|
||||||
using Health.Infrastructure.Services;
|
using Health.Infrastructure.Services;
|
||||||
using Health.Infrastructure.Users;
|
using Health.Infrastructure.Users;
|
||||||
using Health.WebApi.BackgroundServices;
|
using Health.WebApi.BackgroundServices;
|
||||||
@@ -34,7 +36,6 @@ using Health.WebApi.Middleware;
|
|||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.FileProviders;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
// 加载 .env 文件(开发环境)
|
// 加载 .env 文件(开发环境)
|
||||||
@@ -143,6 +144,7 @@ builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>(
|
|||||||
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
||||||
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
|
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
|
||||||
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
|
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
|
||||||
|
builder.Services.AddScoped<IReminderCatchUpService, EfReminderCatchUpService>();
|
||||||
builder.Services.AddScoped<IReportService, ReportService>();
|
builder.Services.AddScoped<IReportService, ReportService>();
|
||||||
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
||||||
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
||||||
@@ -150,6 +152,9 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
|
|||||||
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||||
builder.Services.AddScoped<IUserService, UserService>();
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||||
|
builder.Services.AddSingleton<IRealtimeSpeechRecognitionProxy, DashScopeSpeechRecognitionProxy>();
|
||||||
|
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
|
||||||
|
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
|
||||||
|
|
||||||
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
||||||
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||||
@@ -203,18 +208,17 @@ app.UseMiddleware<ExceptionMiddleware>();
|
|||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseWebSockets(new WebSocketOptions
|
||||||
|
{
|
||||||
|
KeepAliveInterval = TimeSpan.FromSeconds(20),
|
||||||
|
});
|
||||||
|
|
||||||
// 默认 wwwroot 静态文件(法律文档 H5 页面等)
|
// 默认 wwwroot 静态文件(法律文档 H5 页面等)
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
|
||||||
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
// 用户上传文件不能作为静态目录公开;统一通过带鉴权和归属校验的 API 读取。
|
||||||
Directory.CreateDirectory(uploadsPath);
|
Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), "uploads"));
|
||||||
app.UseStaticFiles(new StaticFileOptions
|
|
||||||
{
|
|
||||||
FileProvider = new PhysicalFileProvider(uploadsPath),
|
|
||||||
RequestPath = "/uploads"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
@@ -243,6 +247,7 @@ app.MapNotificationEndpoints();
|
|||||||
app.MapDoctorEndpoints();
|
app.MapDoctorEndpoints();
|
||||||
app.MapAdminEndpoints();
|
app.MapAdminEndpoints();
|
||||||
app.MapFollowUpEndpoints();
|
app.MapFollowUpEndpoints();
|
||||||
|
app.MapSpeechEndpoints();
|
||||||
|
|
||||||
// SignalR Hub
|
// SignalR Hub
|
||||||
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");
|
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Warning",
|
"Default": "Warning",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
backend/src/Health.WebApi/wwwroot/app-icon.png
Normal file
BIN
backend/src/Health.WebApi/wwwroot/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -28,7 +28,7 @@
|
|||||||
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
|
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
|
||||||
|
|
||||||
<h2>一、我们收集的信息</h2>
|
<h2>一、我们收集的信息</h2>
|
||||||
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
||||||
|
|
||||||
<h3>1. 账号与身份信息</h3>
|
<h3>1. 账号与身份信息</h3>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
<h3>4. 蓝牙设备信息</h3>
|
<h3>4. 蓝牙设备信息</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主。</li>
|
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前实际支持以采用标准蓝牙血压服务的血压计为主;其他设备类型将在功能正式开放后另行说明。</li>
|
||||||
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。</li>
|
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。</li>
|
||||||
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
|
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<p>如您不同意本协议,请停止注册或使用本应用。</p>
|
<p>如您不同意本协议,请停止注册或使用本应用。</p>
|
||||||
|
|
||||||
<h2>一、服务内容</h2>
|
<h2>一、服务内容</h2>
|
||||||
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
||||||
<p>本应用当前可能包含以下服务:</p>
|
<p>本应用当前可能包含以下服务:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
|
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
|
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
|
||||||
<li>AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
|
<li>AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
|
||||||
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
|
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
|
||||||
<li>在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>二、账号注册与使用</h2>
|
<h2>二、账号注册与使用</h2>
|
||||||
@@ -56,7 +55,6 @@
|
|||||||
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
|
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
|
||||||
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
|
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
|
||||||
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
|
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
|
||||||
<li>医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>
|
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>
|
||||||
|
|
||||||
|
|||||||
122
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
122
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using Health.Application.Users;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Infrastructure.Users;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class AccountDeletionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task LocalCleanup_DeletesOwnedUploadsWithoutTouchingOtherFiles()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"health-account-delete-{Guid.NewGuid():N}");
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var userDirectory = Path.Combine(root, "users", userId.ToString("N"));
|
||||||
|
var reportPath = Path.Combine(root, "reports", "owned-report.pdf");
|
||||||
|
var chatFileName = $"{Guid.NewGuid()}.jpg";
|
||||||
|
var chatImagePath = Path.Combine(userDirectory, chatFileName);
|
||||||
|
var otherUserId = Guid.NewGuid();
|
||||||
|
var otherUserPath = Path.Combine(root, "users", otherUserId.ToString("N"), "keep.jpg");
|
||||||
|
var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(userDirectory);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(otherUserPath)!);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(userDirectory, "unlinked-upload.jpg"), "owned");
|
||||||
|
await File.WriteAllTextAsync(reportPath, "owned");
|
||||||
|
await File.WriteAllTextAsync(chatImagePath, "owned");
|
||||||
|
await File.WriteAllTextAsync(otherUserPath, "other");
|
||||||
|
await File.WriteAllTextAsync(outsidePath, "outside");
|
||||||
|
|
||||||
|
var cleanup = new LocalAccountFileCleanup(root);
|
||||||
|
var references = new AccountFileReferences(
|
||||||
|
["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"],
|
||||||
|
[$"{{\"imageUrl\":\"/uploads/users/{userId:N}/{chatFileName}\"}}", $"{{\"imageUrl\":\"/uploads/users/{otherUserId:N}/keep.jpg\"}}"]);
|
||||||
|
|
||||||
|
await cleanup.DeleteAsync(userId, references, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(userDirectory));
|
||||||
|
Assert.False(File.Exists(reportPath));
|
||||||
|
Assert.False(File.Exists(chatImagePath));
|
||||||
|
Assert.True(File.Exists(otherUserPath));
|
||||||
|
Assert.True(File.Exists(outsidePath));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||||
|
if (File.Exists(outsidePath)) File.Delete(outsidePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UserService_CleansFilesBeforeDeletingDatabaseData()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var references = new AccountFileReferences(["/uploads/reports/report.pdf"], []);
|
||||||
|
var repository = new FakeUserRepository(references, calls);
|
||||||
|
var cleanup = new FakeAccountFileCleanup(calls);
|
||||||
|
var service = new UserService(repository, cleanup);
|
||||||
|
|
||||||
|
await service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(["references", "files", "database"], calls);
|
||||||
|
Assert.Same(references, cleanup.ReceivedReferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UserService_DoesNotDeleteDatabaseWhenFileCleanupFails()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var repository = new FakeUserRepository(new AccountFileReferences([], []), calls);
|
||||||
|
var service = new UserService(repository, new FailingAccountFileCleanup(calls));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<IOException>(() =>
|
||||||
|
service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(["references", "files"], calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeUserRepository(
|
||||||
|
AccountFileReferences references,
|
||||||
|
List<string> calls) : IUserRepository
|
||||||
|
{
|
||||||
|
public Task<User?> GetAsync(Guid userId, CancellationToken ct) => Task.FromResult<User?>(null);
|
||||||
|
|
||||||
|
public Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("references");
|
||||||
|
return Task.FromResult(references);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task DeleteAccountDataAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("database");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeAccountFileCleanup(List<string> calls) : IAccountFileCleanup
|
||||||
|
{
|
||||||
|
public AccountFileReferences? ReceivedReferences { get; private set; }
|
||||||
|
|
||||||
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("files");
|
||||||
|
ReceivedReferences = references;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingAccountFileCleanup(List<string> calls) : IAccountFileCleanup
|
||||||
|
{
|
||||||
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("files");
|
||||||
|
throw new IOException("file is locked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
backend/tests/Health.Tests/admin_service_tests.cs
Normal file
191
backend/tests/Health.Tests/admin_service_tests.cs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
using Health.Application.Admin;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Infrastructure.Admin;
|
||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class AdminServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new AppDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddDoctor_CreatesDoctorLoginAndProfileTogether()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.AddDoctorAsync(
|
||||||
|
new AddDoctorCommand("13800138001", "王医生", "主任医师", "心内科", "高血压"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Code);
|
||||||
|
var doctor = await db.Doctors.SingleAsync();
|
||||||
|
var user = await db.Users.SingleAsync();
|
||||||
|
var profile = await db.DoctorProfiles.SingleAsync();
|
||||||
|
Assert.Equal("Doctor", user.Role);
|
||||||
|
Assert.Equal(doctor.Id, profile.DoctorId);
|
||||||
|
Assert.Equal(user.Id, profile.UserId);
|
||||||
|
Assert.Equal(doctor.Name, user.Name);
|
||||||
|
Assert.Equal(doctor.Name, profile.Name);
|
||||||
|
Assert.Equal(doctor.Title, profile.Title);
|
||||||
|
Assert.Equal(doctor.Department, profile.Department);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddDoctor_RejectsAPhoneAlreadyUsedByAnyAccount()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = "13800138002",
|
||||||
|
Role = "User",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.AddDoctorAsync(
|
||||||
|
new AddDoctorCommand("13800138002", "李医生", null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotEqual(0, result.Code);
|
||||||
|
Assert.Empty(db.Doctors);
|
||||||
|
Assert.Empty(db.DoctorProfiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateDoctor_SynchronizesDoctorLoginAndProfile()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var (doctor, user, profile) = await SeedDoctorAsync(db);
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.UpdateDoctorAsync(
|
||||||
|
doctor.Id,
|
||||||
|
new UpdateDoctorCommand("13800138999", "新姓名", "副主任医师", "神经内科", "脑血管"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Code);
|
||||||
|
Assert.Equal("13800138999", doctor.Phone);
|
||||||
|
Assert.Equal("13800138999", user.Phone);
|
||||||
|
Assert.Equal("新姓名", doctor.Name);
|
||||||
|
Assert.Equal("新姓名", user.Name);
|
||||||
|
Assert.Equal("新姓名", profile.Name);
|
||||||
|
Assert.Equal("副主任医师", profile.Title);
|
||||||
|
Assert.Equal("神经内科", profile.Department);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToggleDoctor_SynchronizesAvailabilityButKeepsLoginEnabled()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var (doctor, user, profile) = await SeedDoctorAsync(db);
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.ToggleDoctorAsync(doctor.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Code);
|
||||||
|
Assert.False(doctor.IsActive);
|
||||||
|
Assert.False(profile.IsActive);
|
||||||
|
Assert.Equal("Doctor", user.Role);
|
||||||
|
Assert.NotNull(await db.Users.FindAsync(user.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteDoctor_IsRejectedWhilePatientsAreBound()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var (doctor, user, profile) = await SeedDoctorAsync(db);
|
||||||
|
db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = "13800138088",
|
||||||
|
Name = "患者",
|
||||||
|
Role = "User",
|
||||||
|
DoctorId = doctor.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.DeleteDoctorAsync(doctor.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotEqual(0, result.Code);
|
||||||
|
Assert.NotNull(await db.Doctors.FindAsync(doctor.Id));
|
||||||
|
Assert.NotNull(await db.Users.FindAsync(user.Id));
|
||||||
|
Assert.NotNull(await db.DoctorProfiles.FindAsync(profile.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteUnusedDoctor_RemovesLoginProfileAndRefreshTokens()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var (doctor, user, _) = await SeedDoctorAsync(db);
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
Token = "refresh-token",
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(1),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.DeleteDoctorAsync(doctor.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Code);
|
||||||
|
Assert.Empty(db.Doctors);
|
||||||
|
Assert.Empty(db.DoctorProfiles);
|
||||||
|
Assert.Empty(db.Users);
|
||||||
|
Assert.Empty(db.RefreshTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(Doctor Doctor, User User, DoctorProfile Profile)> SeedDoctorAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
var doctor = new Doctor
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = "13800138003",
|
||||||
|
Name = "原姓名",
|
||||||
|
Title = "医师",
|
||||||
|
Department = "内科",
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = doctor.Phone,
|
||||||
|
Name = doctor.Name,
|
||||||
|
Role = "Doctor",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var profile = new DoctorProfile
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
DoctorId = doctor.Id,
|
||||||
|
UserId = user.Id,
|
||||||
|
Name = doctor.Name,
|
||||||
|
Title = doctor.Title,
|
||||||
|
Department = doctor.Department,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.AddRange(doctor, user, profile);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return (doctor, user, profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ using Health.Application.Exercises;
|
|||||||
using Health.Domain;
|
using Health.Domain;
|
||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
using Health.Domain.Enums;
|
using Health.Domain.Enums;
|
||||||
|
using Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Health.Tests;
|
namespace Health.Tests;
|
||||||
|
|
||||||
@@ -42,7 +44,8 @@ public sealed class ApplicationServiceTests
|
|||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = userId,
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
});
|
});
|
||||||
var service = new HealthArchiveService(repository);
|
var service = new HealthArchiveService(repository);
|
||||||
|
|
||||||
@@ -75,14 +78,35 @@ public sealed class ApplicationServiceTests
|
|||||||
Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01);
|
Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HealthRecords_BatchPersistsAllReadingsWithOneSave()
|
||||||
|
{
|
||||||
|
var repository = new CapturingHealthRecordRepository();
|
||||||
|
var service = new HealthRecordService(repository);
|
||||||
|
|
||||||
|
var ids = await service.CreateManyAsync(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
[
|
||||||
|
new HealthRecordUpsertRequest(HealthMetricType.BloodPressure, 116, 89, null, "mmHg", HealthRecordSource.DeviceSync, DateTime.UtcNow),
|
||||||
|
new HealthRecordUpsertRequest(HealthMetricType.HeartRate, null, null, 72, "bpm", HealthRecordSource.DeviceSync, DateTime.UtcNow),
|
||||||
|
],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, ids.Count);
|
||||||
|
Assert.Equal(2, repository.Added.Count);
|
||||||
|
Assert.Equal(1, repository.SaveCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
|
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = userId,
|
Id = Guid.NewGuid(),
|
||||||
SurgeryType = "Legacy surgery", SurgeryDate = new DateOnly(2010, 1, 2),
|
UserId = userId,
|
||||||
|
SurgeryType = "Legacy surgery",
|
||||||
|
SurgeryDate = new DateOnly(2010, 1, 2),
|
||||||
});
|
});
|
||||||
var service = new HealthArchiveService(repository);
|
var service = new HealthArchiveService(repository);
|
||||||
|
|
||||||
@@ -110,13 +134,71 @@ public sealed class ApplicationServiceTests
|
|||||||
Assert.False(result.Created);
|
Assert.False(result.Created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Conversation_List_ReturnsAtMostThirtyRecentItems()
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var conversations = Enumerable.Range(0, 35)
|
||||||
|
.Select(index => new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AgentType = AgentType.Unified,
|
||||||
|
UpdatedAt = DateTime.UtcNow.AddMinutes(-index),
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var service = new AiConversationService(new ListConversationRepository(conversations));
|
||||||
|
|
||||||
|
var result = await service.ListAsync(userId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(30, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Conversation_Delete_ReportsWhetherARecordWasActuallyDeleted()
|
||||||
|
{
|
||||||
|
var method = typeof(IAiConversationService).GetMethod(nameof(IAiConversationService.DeleteAsync));
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
Assert.Equal(typeof(Task<bool>), method!.ReturnType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Conversation_Open_ReactivatesTheSameOwnedConversation()
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var conversation = new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AgentType = AgentType.Unified,
|
||||||
|
UpdatedAt = DateTime.UtcNow.AddDays(-1),
|
||||||
|
};
|
||||||
|
var service = new AiConversationService(new FakeConversationRepository(conversation));
|
||||||
|
|
||||||
|
var result = await service.OpenAsync(
|
||||||
|
userId,
|
||||||
|
conversation.Id,
|
||||||
|
AgentType.Unified,
|
||||||
|
"新的追问",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Found);
|
||||||
|
Assert.False(result.Created);
|
||||||
|
Assert.Equal(conversation.Id, result.ConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp()
|
public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp()
|
||||||
{
|
{
|
||||||
var date = new DateOnly(2026, 6, 19);
|
var date = new DateOnly(2026, 6, 19);
|
||||||
var medication = new Medication
|
var medication = new Medication
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), IsActive = true, Name = "阿司匹林", StartDate = date.AddDays(-1), TimeOfDay = [new TimeOnly(8, 0)]
|
Id = Guid.NewGuid(),
|
||||||
|
IsActive = true,
|
||||||
|
Name = "阿司匹林",
|
||||||
|
StartDate = date.AddDays(-1),
|
||||||
|
TimeOfDay = [new TimeOnly(8, 0)]
|
||||||
};
|
};
|
||||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) };
|
var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) };
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 });
|
||||||
@@ -131,6 +213,24 @@ public sealed class ApplicationServiceTests
|
|||||||
Assert.Equal(["medication", "exercise", "followup"], events);
|
Assert.Equal(["medication", "exercise", "followup"], events);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Calendar_FollowUpUsesBeijingDateForUtcTimestamp()
|
||||||
|
{
|
||||||
|
var followUp = new FollowUp
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ScheduledAt = new DateTime(2026, 6, 18, 17, 0, 0, DateTimeKind.Utc),
|
||||||
|
Title = "北京时间复查"
|
||||||
|
};
|
||||||
|
var service = new CalendarService(new FakeCalendarRepository(
|
||||||
|
new CalendarDataSnapshot([], [], [followUp])));
|
||||||
|
|
||||||
|
var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None);
|
||||||
|
|
||||||
|
var target = Assert.Single(result);
|
||||||
|
Assert.Equal("2026-06-19", target.GetType().GetProperty("date")!.GetValue(target));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates()
|
public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates()
|
||||||
{
|
{
|
||||||
@@ -154,7 +254,11 @@ public sealed class ApplicationServiceTests
|
|||||||
var repository = new FakeExerciseRepository();
|
var repository = new FakeExerciseRepository();
|
||||||
var plan = new ExercisePlan
|
var plan = new ExercisePlan
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today, EndDate = today.AddDays(9), ReminderTime = new TimeOnly(19, 0),
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
StartDate = today,
|
||||||
|
EndDate = today.AddDays(9),
|
||||||
|
ReminderTime = new TimeOnly(19, 0),
|
||||||
};
|
};
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 });
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 });
|
||||||
@@ -168,6 +272,73 @@ public sealed class ApplicationServiceTests
|
|||||||
Assert.Equal(2, current.Items.Count);
|
Assert.Equal(2, current.Items.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AiExerciseQuery_UpcomingScopeReturnsFuturePlanButTodayScopeDoesNot()
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
var repository = new FakeExerciseRepository();
|
||||||
|
var plan = new ExercisePlan
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
StartDate = today.AddDays(2),
|
||||||
|
EndDate = today.AddDays(4),
|
||||||
|
ReminderTime = new TimeOnly(19, 0),
|
||||||
|
};
|
||||||
|
plan.Items.Add(new ExercisePlanItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ScheduledDate = today.AddDays(2),
|
||||||
|
ExerciseType = "散步",
|
||||||
|
DurationMinutes = 30,
|
||||||
|
});
|
||||||
|
repository.SetPlan(plan);
|
||||||
|
var service = new ExerciseService(repository);
|
||||||
|
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
|
||||||
|
using var todayArgs = JsonDocument.Parse("""{"action":"query","scope":"today"}""");
|
||||||
|
|
||||||
|
var upcoming = await ExerciseAgentHandler.Execute(
|
||||||
|
"manage_exercise", upcomingArgs.RootElement, userId, service, CancellationToken.None);
|
||||||
|
var todayResult = await ExerciseAgentHandler.Execute(
|
||||||
|
"manage_exercise", todayArgs.RootElement, userId, service, CancellationToken.None);
|
||||||
|
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||||
|
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
|
||||||
|
using var todayJson = JsonDocument.Parse(JsonSerializer.Serialize(todayResult, jsonOptions));
|
||||||
|
|
||||||
|
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
|
||||||
|
Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
|
||||||
|
Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday()
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var service = new ExerciseService(new FakeExerciseRepository());
|
||||||
|
using var args = JsonDocument.Parse("""{"action":"query"}""");
|
||||||
|
|
||||||
|
var result = await ExerciseAgentHandler.Execute(
|
||||||
|
"manage_exercise", args.RootElement, userId, service, CancellationToken.None);
|
||||||
|
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||||
|
|
||||||
|
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
|
||||||
|
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AiFollowUpQuery_RequiresExplicitScopeInsteadOfAssumingNext()
|
||||||
|
{
|
||||||
|
using var args = JsonDocument.Parse("""{}""");
|
||||||
|
|
||||||
|
var result = await PatientReadAgentHandler.QueryFollowUpsAsync(
|
||||||
|
null!, Guid.NewGuid(), args.RootElement, CancellationToken.None);
|
||||||
|
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||||
|
|
||||||
|
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
|
||||||
|
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ExercisePlan_CheckInRejectsNonTodayItem()
|
public async Task ExercisePlan_CheckInRejectsNonTodayItem()
|
||||||
{
|
{
|
||||||
@@ -175,11 +346,19 @@ public sealed class ApplicationServiceTests
|
|||||||
var repository = new FakeExerciseRepository();
|
var repository = new FakeExerciseRepository();
|
||||||
var plan = new ExercisePlan
|
var plan = new ExercisePlan
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today.AddDays(-1), EndDate = today, ReminderTime = new TimeOnly(19, 0),
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
StartDate = today.AddDays(-1),
|
||||||
|
EndDate = today,
|
||||||
|
ReminderTime = new TimeOnly(19, 0),
|
||||||
};
|
};
|
||||||
var item = new ExercisePlanItem
|
var item = new ExercisePlanItem
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), Plan = plan, ScheduledDate = today.AddDays(-1), ExerciseType = "散步", DurationMinutes = 30,
|
Id = Guid.NewGuid(),
|
||||||
|
Plan = plan,
|
||||||
|
ScheduledDate = today.AddDays(-1),
|
||||||
|
ExerciseType = "散步",
|
||||||
|
DurationMinutes = 30,
|
||||||
};
|
};
|
||||||
plan.Items.Add(item);
|
plan.Items.Add(item);
|
||||||
repository.SetPlan(plan);
|
repository.SetPlan(plan);
|
||||||
@@ -199,6 +378,8 @@ public sealed class ApplicationServiceTests
|
|||||||
private sealed class CapturingHealthRecordRepository : IHealthRecordRepository
|
private sealed class CapturingHealthRecordRepository : IHealthRecordRepository
|
||||||
{
|
{
|
||||||
public DateTime? RecordedAfter { get; private set; }
|
public DateTime? RecordedAfter { get; private set; }
|
||||||
|
public List<HealthRecord> Added { get; } = [];
|
||||||
|
public int SaveCount { get; private set; }
|
||||||
public Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) =>
|
public Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) =>
|
||||||
Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
||||||
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
|
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
|
||||||
@@ -208,9 +389,9 @@ public sealed class ApplicationServiceTests
|
|||||||
RecordedAfter = recordedAfter;
|
RecordedAfter = recordedAfter;
|
||||||
return Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
return Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
||||||
}
|
}
|
||||||
public Task AddAsync(HealthRecord record, CancellationToken ct) => Task.CompletedTask;
|
public Task AddAsync(HealthRecord record, CancellationToken ct) { Added.Add(record); return Task.CompletedTask; }
|
||||||
public void Delete(HealthRecord record) { }
|
public void Delete(HealthRecord record) { }
|
||||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task SaveChangesAsync(CancellationToken ct) { SaveCount++; return Task.CompletedTask; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
|
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
|
||||||
@@ -223,10 +404,38 @@ public sealed class ApplicationServiceTests
|
|||||||
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||||
public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask;
|
public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask;
|
||||||
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0);
|
||||||
public void Delete(Conversation value) { }
|
public void Delete(Conversation value) { }
|
||||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ListConversationRepository(List<Conversation> conversations) : IAiConversationRepository
|
||||||
|
{
|
||||||
|
private readonly List<Conversation> _conversations = conversations;
|
||||||
|
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) =>
|
||||||
|
Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId && item.UserId == userId));
|
||||||
|
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) =>
|
||||||
|
Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId));
|
||||||
|
public Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) =>
|
||||||
|
Task.FromResult<IReadOnlyList<Conversation>>(_conversations
|
||||||
|
.Where(item => item.UserId == userId)
|
||||||
|
.OrderByDescending(item => item.UpdatedAt)
|
||||||
|
.ToList());
|
||||||
|
public Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) =>
|
||||||
|
Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||||
|
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) =>
|
||||||
|
Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||||
|
public Task AddConversationAsync(Conversation value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_conversations.Add(value);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0);
|
||||||
|
public void Delete(Conversation value) => _conversations.Remove(value);
|
||||||
|
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository
|
private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository
|
||||||
{
|
{
|
||||||
public Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot);
|
public Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot);
|
||||||
@@ -238,9 +447,11 @@ public sealed class ApplicationServiceTests
|
|||||||
public void SetPlan(ExercisePlan plan) => Plan = plan;
|
public void SetPlan(ExercisePlan plan) => Plan = plan;
|
||||||
public Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
public Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||||
Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []);
|
Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []);
|
||||||
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>([]);
|
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||||
|
Plan != null && Plan.UserId == userId ? [Plan] : []);
|
||||||
|
public Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||||
|
Plan != null && Plan.UserId == userId ? [Plan] : []);
|
||||||
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan);
|
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan);
|
||||||
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) => Task.FromResult(Plan);
|
|
||||||
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId));
|
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId));
|
||||||
public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; }
|
public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; }
|
||||||
public void Delete(ExercisePlan plan) { Plan = null; }
|
public void Delete(ExercisePlan plan) { Plan = null; }
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
|
using Health.Infrastructure.Auth;
|
||||||
using Health.Infrastructure.Data;
|
using Health.Infrastructure.Data;
|
||||||
using Health.Infrastructure.Services;
|
using Health.Infrastructure.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -118,6 +120,47 @@ public class AuthTests
|
|||||||
Assert.NotNull(active);
|
Assert.NotNull(active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_Should_Return_Stable_User_Identity()
|
||||||
|
{
|
||||||
|
using var db = CreateDbContext();
|
||||||
|
var config = CreateConfig();
|
||||||
|
var jwt = new JwtProvider(config);
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = "13800138000",
|
||||||
|
Role = "User",
|
||||||
|
Name = "测试用户",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
var oldRefresh = jwt.GenerateRefreshToken();
|
||||||
|
db.Users.Add(user);
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
Token = oldRefresh,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(30)
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new AuthService(
|
||||||
|
db,
|
||||||
|
jwt,
|
||||||
|
new SmsService(),
|
||||||
|
new AppleTokenValidator(config));
|
||||||
|
|
||||||
|
var result = await service.RefreshAsync(oldRefresh, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Code);
|
||||||
|
var data = JsonSerializer.SerializeToElement(result.Data);
|
||||||
|
var refreshedUser = data.GetProperty("user");
|
||||||
|
Assert.Equal(user.Id, refreshedUser.GetProperty("Id").GetGuid());
|
||||||
|
Assert.Equal(user.Phone, refreshedUser.GetProperty("Phone").GetString());
|
||||||
|
Assert.Equal(user.Role, refreshedUser.GetProperty("Role").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task VerificationCode_Expired_Should_Fail_Login()
|
public async Task VerificationCode_Expired_Should_Fail_Login()
|
||||||
{
|
{
|
||||||
|
|||||||
111
backend/tests/Health.Tests/bce_signer_tests.cs
Normal file
111
backend/tests/Health.Tests/bce_signer_tests.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using Health.Infrastructure.Services;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// BCE v1 签名算法测试
|
||||||
|
/// Golden value 由 Python 标准库 hmac + hashlib 按 baidubce-sdk 算法手算,
|
||||||
|
/// 并与 baidubce-sdk 0.9.72 的实际 debug 输出对比验证一致
|
||||||
|
/// </summary>
|
||||||
|
public class BceSignerTests
|
||||||
|
{
|
||||||
|
private const string Ak = "test-ak";
|
||||||
|
private const string Sk = "test-sk";
|
||||||
|
private const string Host = "smsv3.bj.baidubce.com";
|
||||||
|
private const string Method = "POST";
|
||||||
|
private const string ApiPath = "/api/v3/sendSms";
|
||||||
|
private const string ContentType = "application/json;charset=utf-8";
|
||||||
|
private const int ContentLength = 199;
|
||||||
|
|
||||||
|
private static readonly DateTime Ts1 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
private static readonly DateTime Ts2 = new(2026, 7, 23, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// Golden 1: 固定输入
|
||||||
|
private const string ExpectedSig1 = "7592321b147186724a0194344aa274b4875fbc3442b4a5a07db4fd00cf44e580";
|
||||||
|
private const string ExpectedAuth1 = "bce-auth-v1/test-ak/2026-01-01T00:00:00Z/1800/content-length;content-type;host;x-bce-date/" + ExpectedSig1;
|
||||||
|
|
||||||
|
// Golden 2: 不同 timestamp
|
||||||
|
private const string ExpectedSig2 = "5c058909c04e8a6964fc22f9ae00cd5aae9c9182cb21fca7a76eb834eaa47c0b";
|
||||||
|
|
||||||
|
// Golden 3: 不同 SK
|
||||||
|
private const string ExpectedSig3 = "b944339bad2635d0eb41344427c1328f9785335a64baaabced658fa487fb228a";
|
||||||
|
|
||||||
|
// Golden 4: 不同 content_length
|
||||||
|
private const string ExpectedSig4 = "7b2f8bfdda2255b2331992a4adb9fb7365aa83c62453b61380cc7be350e6105f";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_FixedInput_MatchesGoldenValue()
|
||||||
|
{
|
||||||
|
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
Assert.Equal(ExpectedAuth1, auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_DifferentTimestamp_ProducesDifferentSignature()
|
||||||
|
{
|
||||||
|
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
var auth2 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts2, ContentLength, ContentType);
|
||||||
|
Assert.NotEqual(auth1, auth2);
|
||||||
|
Assert.EndsWith(ExpectedSig2, auth2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_DifferentSecretKey_ProducesDifferentSignature()
|
||||||
|
{
|
||||||
|
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
var auth3 = BceSigner.BuildAuthorization(Ak, "test-sk-2", Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
Assert.NotEqual(auth1, auth3);
|
||||||
|
Assert.EndsWith(ExpectedSig3, auth3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_DifferentContentLength_ProducesDifferentSignature()
|
||||||
|
{
|
||||||
|
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
var auth4 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, 100, ContentType);
|
||||||
|
Assert.NotEqual(auth1, auth4);
|
||||||
|
Assert.EndsWith(ExpectedSig4, auth4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_Signature_Is64CharLowercaseHex()
|
||||||
|
{
|
||||||
|
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
var sig = auth.Split('/')[^1];
|
||||||
|
Assert.Equal(64, sig.Length);
|
||||||
|
Assert.Matches("^[0-9a-f]{64}$", sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_Format_HasCorrectStructure()
|
||||||
|
{
|
||||||
|
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
var parts = auth.Split('/');
|
||||||
|
Assert.Equal(6, parts.Length);
|
||||||
|
Assert.Equal("bce-auth-v1", parts[0]);
|
||||||
|
Assert.Equal(Ak, parts[1]);
|
||||||
|
Assert.Equal("2026-01-01T00:00:00Z", parts[2]);
|
||||||
|
Assert.Equal("1800", parts[3]);
|
||||||
|
Assert.Equal("content-length;content-type;host;x-bce-date", parts[4]);
|
||||||
|
Assert.Equal(ExpectedSig1, parts[5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_RealCredentials_MatchesSdkDebugOutput()
|
||||||
|
{
|
||||||
|
// 用百度 SDK 0.9.72 实际 debug 日志里的真实输入,验证 C# 输出和 SDK 完全一致
|
||||||
|
var auth = BceSigner.BuildAuthorization(
|
||||||
|
"ALTAKnJn8wtdLtBPgl7bD1x3N3",
|
||||||
|
"c5ca05a967d04a69bbc0e12f08062dc8",
|
||||||
|
"POST",
|
||||||
|
"/api/v3/sendSms",
|
||||||
|
"smsv3.bj.baidubce.com",
|
||||||
|
new DateTime(2026, 7, 23, 9, 3, 42, DateTimeKind.Utc),
|
||||||
|
199,
|
||||||
|
"application/json;charset=utf-8");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"bce-auth-v1/ALTAKnJn8wtdLtBPgl7bD1x3N3/2026-07-23T09:03:42Z/1800/content-length;content-type;host;x-bce-date/0fd388266f63e9036de09981d7312ce3b7e279c8789823cb80b6c1800005fe13",
|
||||||
|
auth);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/tests/Health.Tests/diet_commentary_tests.cs
Normal file
56
backend/tests/Health.Tests/diet_commentary_tests.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using Health.Application.AI;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class DietCommentaryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FoodDescription_ContainsOnlyStructuredFoodData()
|
||||||
|
{
|
||||||
|
var result = DietCommentaryPolicy.BuildFoodDescription(
|
||||||
|
[
|
||||||
|
new DietCommentaryFood("番茄炒蛋", "半盘", 220),
|
||||||
|
new DietCommentaryFood("米饭", "一碗", 260),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal("番茄炒蛋(半盘,220千卡)、米饭(一碗,260千卡)", result);
|
||||||
|
Assert.DoesNotContain("请结合", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyArtifact_RequiresEveryUserMessageToUseInternalPrefix()
|
||||||
|
{
|
||||||
|
var artifact = new Conversation
|
||||||
|
{
|
||||||
|
Messages =
|
||||||
|
[
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
|
||||||
|
},
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.Assistant,
|
||||||
|
Content = "注意搭配蔬菜",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var normal = new Conversation
|
||||||
|
{
|
||||||
|
Messages =
|
||||||
|
[
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = "我想了解今天的饮食记录",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(DietCommentaryPolicy.IsLegacyArtifact(artifact.Messages));
|
||||||
|
Assert.False(DietCommentaryPolicy.IsLegacyArtifact(normal.Messages));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/tests/Health.Tests/doctor_endpoint_tests.cs
Normal file
17
backend/tests/Health.Tests/doctor_endpoint_tests.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class DoctorEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BeijingDayRange_MapsEarlyUtcTimeToTheCorrectLocalDate()
|
||||||
|
{
|
||||||
|
var utcNow = new DateTime(2026, 7, 13, 17, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var (startUtc, endUtc) = DoctorEndpoints.GetBeijingDayRange(utcNow);
|
||||||
|
|
||||||
|
Assert.Equal(new DateTime(2026, 7, 13, 16, 0, 0, DateTimeKind.Utc), startUtc);
|
||||||
|
Assert.Equal(new DateTime(2026, 7, 14, 16, 0, 0, DateTimeKind.Utc), endUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,6 +184,7 @@ public class EntityTests
|
|||||||
DietRestrictions = ["低盐", "低脂"],
|
DietRestrictions = ["低盐", "低脂"],
|
||||||
ChronicDiseases = ["高血压", "高血脂"],
|
ChronicDiseases = ["高血压", "高血脂"],
|
||||||
FamilyHistory = "父亲冠心病",
|
FamilyHistory = "父亲冠心病",
|
||||||
|
SurgeryHistoryStatus = "有",
|
||||||
};
|
};
|
||||||
db.HealthArchives.Add(archive);
|
db.HealthArchives.Add(archive);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -193,6 +194,7 @@ public class EntityTests
|
|||||||
Assert.Equal("冠心病", saved!.Diagnosis);
|
Assert.Equal("冠心病", saved!.Diagnosis);
|
||||||
Assert.Equal(2, saved.DietRestrictions.Count);
|
Assert.Equal(2, saved.DietRestrictions.Count);
|
||||||
Assert.Contains("低盐", saved.DietRestrictions);
|
Assert.Contains("低盐", saved.DietRestrictions);
|
||||||
|
Assert.Equal("有", saved.SurgeryHistoryStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
40
backend/tests/Health.Tests/file_path_security_tests.cs
Normal file
40
backend/tests/Health.Tests/file_path_security_tests.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Health.Infrastructure.Files;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class FilePathSecurityTests
|
||||||
|
{
|
||||||
|
private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
|
private readonly string _fileName = "22222222-2222-2222-2222-222222222222.jpg";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProtectedUrl_ResolvesInsideCurrentUserDirectory()
|
||||||
|
{
|
||||||
|
var path = UserUploadPathResolver.Resolve(
|
||||||
|
_userId,
|
||||||
|
$"/api/files/content/{_fileName}");
|
||||||
|
|
||||||
|
Assert.NotNull(path);
|
||||||
|
Assert.Contains(Path.Combine("users", _userId.ToString("N")), path);
|
||||||
|
Assert.EndsWith(_fileName, path, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyUrl_OnlyResolvesForItsOwner()
|
||||||
|
{
|
||||||
|
var legacy = $"/uploads/users/{_userId:N}/{_fileName}";
|
||||||
|
|
||||||
|
Assert.NotNull(UserUploadPathResolver.Resolve(_userId, legacy));
|
||||||
|
Assert.Null(UserUploadPathResolver.Resolve(Guid.NewGuid(), legacy));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/api/files/content/../../secret.jpg")]
|
||||||
|
[InlineData("/api/files/content/not-a-guid.jpg")]
|
||||||
|
[InlineData("/api/files/content/22222222-2222-2222-2222-222222222222.exe")]
|
||||||
|
[InlineData("/uploads/reports/22222222-2222-2222-2222-222222222222.jpg")]
|
||||||
|
public void UnsafeOrUnownedPath_IsRejected(string value)
|
||||||
|
{
|
||||||
|
Assert.Null(UserUploadPathResolver.Resolve(_userId, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/tests/Health.Tests/medication_schedule_tests.cs
Normal file
29
backend/tests/Health.Tests/medication_schedule_tests.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Health.Application.Medications;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class MedicationScheduleTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FutureDoseOutsideReminderWindow_RemainsVisibleAsScheduled()
|
||||||
|
{
|
||||||
|
var status = MedicationScheduleStatus.Resolve(
|
||||||
|
new TimeOnly(18, 0),
|
||||||
|
new TimeOnly(8, 0),
|
||||||
|
logStatus: null);
|
||||||
|
|
||||||
|
Assert.Equal("scheduled", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoggedDose_UsesItsActualStatus()
|
||||||
|
{
|
||||||
|
var status = MedicationScheduleStatus.Resolve(
|
||||||
|
new TimeOnly(8, 0),
|
||||||
|
new TimeOnly(12, 0),
|
||||||
|
MedicationLogStatus.Taken);
|
||||||
|
|
||||||
|
Assert.Equal("taken", status);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
backend/tests/Health.Tests/medication_update_tests.cs
Normal file
263
backend/tests/Health.Tests/medication_update_tests.cs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
using Health.Application.Medications;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
using Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class MedicationUpdateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_CanClearAnExistingEndDate()
|
||||||
|
{
|
||||||
|
var medication = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
Name = "阿司匹林",
|
||||||
|
StartDate = new DateOnly(2026, 7, 1),
|
||||||
|
EndDate = new DateOnly(2026, 7, 31),
|
||||||
|
};
|
||||||
|
var repository = new FakeMedicationRepository(medication);
|
||||||
|
var service = new MedicationService(repository);
|
||||||
|
|
||||||
|
var updated = await service.UpdateAsync(
|
||||||
|
medication.UserId,
|
||||||
|
medication.Id,
|
||||||
|
new MedicationPatchRequest(
|
||||||
|
null, null, null, null, null, null, null,
|
||||||
|
ClearEndDate: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(updated);
|
||||||
|
Assert.Null(medication.EndDate);
|
||||||
|
Assert.True(repository.Saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AiOverview_DoesNotScheduleFutureMedicationForToday()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
var medication = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
Name = "未来用药",
|
||||||
|
IsActive = true,
|
||||||
|
StartDate = today.AddDays(2),
|
||||||
|
EndDate = today.AddDays(10),
|
||||||
|
Frequency = MedicationFrequency.Daily,
|
||||||
|
TimeOfDay = [new TimeOnly(8, 0)],
|
||||||
|
};
|
||||||
|
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||||
|
|
||||||
|
var overview = await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None);
|
||||||
|
|
||||||
|
var plan = Assert.Single(overview);
|
||||||
|
Assert.Equal("upcoming", plan.Phase);
|
||||||
|
Assert.False(plan.ScheduledOnDate);
|
||||||
|
Assert.Empty(plan.Doses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AiOverview_ReportsEachDoseInsteadOfAnyTakenAggregate()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
var medication = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
Name = "两顿药",
|
||||||
|
IsActive = true,
|
||||||
|
StartDate = today.AddDays(-1),
|
||||||
|
EndDate = today.AddDays(1),
|
||||||
|
Frequency = MedicationFrequency.TwiceDaily,
|
||||||
|
TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)],
|
||||||
|
};
|
||||||
|
var log = new MedicationLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = medication.UserId,
|
||||||
|
MedicationId = medication.Id,
|
||||||
|
ScheduledTime = new TimeOnly(8, 0),
|
||||||
|
Status = MedicationLogStatus.Taken,
|
||||||
|
ConfirmedAt = DateTime.UtcNow,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var service = new MedicationService(new FakeMedicationRepository(medication, [log]));
|
||||||
|
|
||||||
|
var plan = Assert.Single(await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(2, plan.Doses.Count);
|
||||||
|
Assert.Equal("taken", plan.Doses.Single(dose => dose.ScheduledTime == "08:00").Status);
|
||||||
|
Assert.NotEqual("taken", plan.Doses.Single(dose => dose.ScheduledTime == "20:00").Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConfirmDose_RejectsAClockTimeThatIsNotInTodaysPlan()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
var medication = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
Name = "阿司匹林",
|
||||||
|
IsActive = true,
|
||||||
|
StartDate = today,
|
||||||
|
Frequency = MedicationFrequency.Daily,
|
||||||
|
TimeOfDay = [new TimeOnly(8, 0)],
|
||||||
|
};
|
||||||
|
var repository = new FakeMedicationRepository(medication);
|
||||||
|
var service = new MedicationService(repository);
|
||||||
|
|
||||||
|
var result = await service.ConfirmDoseAsync(
|
||||||
|
medication.UserId,
|
||||||
|
medication.Id,
|
||||||
|
new TimeOnly(8, 15),
|
||||||
|
MedicationLogStatus.Taken,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
Assert.Empty(repository.AddedLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AiMedicationCreate_RejectsMissingFieldsInsteadOfUsingDefaults()
|
||||||
|
{
|
||||||
|
using var arguments = JsonDocument.Parse("""{"action":"create","name":"阿司匹林"}""");
|
||||||
|
|
||||||
|
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
|
||||||
|
|
||||||
|
Assert.Equal("创建用药计划前需要确认每次剂量", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AiMedicationCreate_AcceptsCompleteFinitePlan()
|
||||||
|
{
|
||||||
|
using var arguments = JsonDocument.Parse("""
|
||||||
|
{
|
||||||
|
"action":"create",
|
||||||
|
"name":"阿司匹林",
|
||||||
|
"dosage":"100mg",
|
||||||
|
"frequency":"Daily",
|
||||||
|
"time_of_day":["08:00"],
|
||||||
|
"start_date":"2026-07-20",
|
||||||
|
"duration_days":30
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
|
||||||
|
|
||||||
|
Assert.Null(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AiMedicationQuery_UpcomingScopeReturnsTomorrowPlanButCurrentScopeDoesNot()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
var medication = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
Name = "明天开始的药",
|
||||||
|
Dosage = "1片",
|
||||||
|
IsActive = true,
|
||||||
|
StartDate = today.AddDays(1),
|
||||||
|
Frequency = MedicationFrequency.Daily,
|
||||||
|
TimeOfDay = [new TimeOnly(8, 0)],
|
||||||
|
};
|
||||||
|
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||||
|
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
|
||||||
|
using var currentArgs = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
|
||||||
|
|
||||||
|
var upcoming = await MedicationAgentHandler.Execute(
|
||||||
|
"manage_medication", upcomingArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||||
|
var current = await MedicationAgentHandler.Execute(
|
||||||
|
"manage_medication", currentArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||||
|
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||||
|
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
|
||||||
|
using var currentJson = JsonDocument.Parse(JsonSerializer.Serialize(current, jsonOptions));
|
||||||
|
|
||||||
|
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
|
||||||
|
Assert.True(upcomingJson.RootElement.GetProperty("has_upcoming_plans").GetBoolean());
|
||||||
|
Assert.Equal(1, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
|
||||||
|
Assert.Equal("明天开始的药", upcomingJson.RootElement.GetProperty("plans")[0].GetProperty("name").GetString());
|
||||||
|
Assert.Equal(0, currentJson.RootElement.GetProperty("count").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AiMedicationQuery_RequiresExplicitScopeInsteadOfAssumingToday()
|
||||||
|
{
|
||||||
|
var medication = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
Name = "测试药物",
|
||||||
|
IsActive = true,
|
||||||
|
StartDate = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||||
|
Frequency = MedicationFrequency.Daily,
|
||||||
|
TimeOfDay = [new TimeOnly(8, 0)],
|
||||||
|
};
|
||||||
|
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||||
|
using var args = JsonDocument.Parse("""{"action":"query"}""");
|
||||||
|
|
||||||
|
var result = await MedicationAgentHandler.Execute(
|
||||||
|
"manage_medication", args.RootElement, medication.UserId, service, CancellationToken.None);
|
||||||
|
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||||
|
|
||||||
|
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
|
||||||
|
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AiMedicationQuery_SeparatesInactivePlansFromNaturallyEndedPlans()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
var medication = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
Name = "已停用药物",
|
||||||
|
IsActive = false,
|
||||||
|
StartDate = today.AddDays(-10),
|
||||||
|
EndDate = today.AddDays(10),
|
||||||
|
Frequency = MedicationFrequency.Daily,
|
||||||
|
TimeOfDay = [new TimeOnly(8, 0)],
|
||||||
|
};
|
||||||
|
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||||
|
using var inactiveArgs = JsonDocument.Parse("""{"action":"query","scope":"inactive_plans"}""");
|
||||||
|
using var endedArgs = JsonDocument.Parse("""{"action":"query","scope":"ended_plans"}""");
|
||||||
|
|
||||||
|
var inactive = await MedicationAgentHandler.Execute(
|
||||||
|
"manage_medication", inactiveArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||||
|
var ended = await MedicationAgentHandler.Execute(
|
||||||
|
"manage_medication", endedArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||||
|
using var inactiveJson = JsonDocument.Parse(JsonSerializer.Serialize(inactive));
|
||||||
|
using var endedJson = JsonDocument.Parse(JsonSerializer.Serialize(ended));
|
||||||
|
|
||||||
|
Assert.Equal(1, inactiveJson.RootElement.GetProperty("count").GetInt32());
|
||||||
|
Assert.Equal(0, endedJson.RootElement.GetProperty("count").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeMedicationRepository(
|
||||||
|
Medication medication,
|
||||||
|
IReadOnlyList<MedicationLog>? logs = null) : IMedicationRepository
|
||||||
|
{
|
||||||
|
public bool Saved { get; private set; }
|
||||||
|
public List<MedicationLog> AddedLogs { get; } = [];
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||||
|
public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||||
|
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(logs ?? (IReadOnlyList<MedicationLog>)[]);
|
||||||
|
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null);
|
||||||
|
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false);
|
||||||
|
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||||
|
public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||||
|
public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task AddLogAsync(MedicationLog log, CancellationToken ct) { AddedLogs.Add(log); return Task.CompletedTask; }
|
||||||
|
public void DeleteMedication(Medication value) { }
|
||||||
|
public void DeleteLog(MedicationLog log) { }
|
||||||
|
public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user