12 Commits

Author SHA1 Message Date
MingNian
32aae40b12 feat: 新增百度云 SMS v3 签名算法与 HttpClient 直连实现
- BceSigner: BCE v1 签名算法(HMAC-SHA256),与 baidubce-sdk 输出 byte-for-byte 一致
- BceSmsClient: HttpClient 直连百度云 SMS v3 API,自写签名,无第三方 SDK
- BceSignerTests: 7 个单测,包括真实 AK/SK 对比百度 SDK debug 输出

暂未启用:SmsService 仍为开发桩,等签名运营商实名报备后接入真实发送
2026-07-23 18:58:33 +08:00
MingNian
28f704c98e feat: 长辈模式(大字大按钮 + 偏好按账号隔离)+ 语音输入(按住说话 + 实时转写 + 后端代理 DashScope ASR + 患者端专用)+ 健康仪表盘背景渐变 2026-07-21 10:53:16 +08:00
MingNian
0cb5b8e85a feat: AI Agent 处理器扩展 + 服药打卡确认链路 + AI 气泡配色微调 + 数据刷新 providers 抽离 + 多端页面细节调整 2026-07-20 17:12:00 +08:00
MingNian
9cea41705e feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固
- 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越
- LocalReportFileStorage: 文件存储路径安全加固
- local_account_file_cleanup: 账号删除时文件清理逻辑增强
- AuthService: 认证逻辑增强
- file_endpoints / report_endpoints: 文件访问接口安全加固
- ai_chat_endpoints / doctor_endpoints: 接口安全调整
- Program.cs: 服务注册调整

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

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

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

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

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

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

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

## 其他
- api_client IP 适配
- app_future_view 增强
- data_providers 调整
- secondary_page_visuals_test 更新
2026-07-19 19:11:30 +08:00
MingNian
ae94ced2d5 feat: 后台管理页全量重构 + backoffice 共享模块 + 启动脚本优化
## 后台管理页重构
- admin 端: add_doctor / doctors / home / patients 四页重构
- doctor 端: consultations / dashboard / followup_edit / followups / home / patient_detail / patients / profile / report_detail / reports / settings 十一页重构
- 新增 backoffice 共享模块: backoffice_refresh_providers + backoffice_formatters + backoffice_ui

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

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

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

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

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

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

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

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

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

## 其他
- 删除已完成的计划/spec 文档(ui-system-first-pass / apple-sign-in / secondary-page-color-refresh / notification-preferences-design / ui-design-system)
- 新增 native_navigation_test
- 新增 HANDOFF 交接文档
- home_message_order_test 更新
- .gitignore 调整
2026-07-16 22:30:23 +08:00
MingNian
528859686f merge: integrate sccsbc release preparation safely 2026-07-16 00:41:52 +08:00
MingNian
fade61ac21 feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

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

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
2026-07-15 23:22:52 +08:00
MingNian
e654c1e0cc feat: 通知推送/免打扰偏好 + 饮食记录侧滑删除修复 + 聊天交互优化
- 通知: 新增推送开关/免打扰时段偏好持久化 + EF 迁移; 通知管线支持免打扰过滤
- 饮食: 侧滑删除 confirmDismiss 按方向放行(修复删不掉); 新增 diet_nutrition_widgets; 饮食录入页重构
- 聊天: 智能体胶囊点击锁防误触; 流式状态 select 优化; 卡片入场动画
- 主页: 消息列表提取 _HomeMessages + 侧滑手势打开抽屉
- 其他: api_client IP 适配; 通知/用药/饮食端点微调; 测试更新
2026-07-13 10:39:34 +08:00
MingNian
335a3e6440 feat: 饮食记录逻辑重构 + 通知管理分隔线修复 + 多页面 UI 调整
- 饮食: 新增 DietCommentaryPolicy + diet_record_logic, 饮食记录逻辑抽取复用
- 通知管理: 分隔线改为仿通知中心样式, 跳过图标区域只留文字下方
- 侧边栏: 对话记录操作栏浮层修复 + 常用功能间距收紧
- 智能体: 欢迎卡片去 400ms 延迟 + 胶囊去阴影
- 后端: AI 会话仓库新增方法 + 饮食评论策略 + 健康记录规则调整
- 其他: api_client IP 适配 + 多页面 UI 微调 + 新增测试
2026-07-12 22:49:38 +08:00
228 changed files with 23572 additions and 8293 deletions

1
.gitignore vendored
View File

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -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);
} }

View File

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

View File

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

View File

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

View File

@@ -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);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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} 不合理");
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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);
}
} }

View File

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

View File

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

View File

@@ -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);
} }

View File

@@ -15,7 +15,9 @@ public static class ExerciseAgentHandler
type = "object", type = "object",
properties = new properties = new
{ {
action = new { type = "string", description = "create/query/checkin" }, action = new { type = "string", @enum = new[] { "create", "query", "checkin" }, description = "create/query/checkin" },
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "all_plans" }, description = "查询时必填:今日任务/指定日期任务/当前计划/即将开始/已结束/全部计划" },
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd默认今天" }, start_date = new { type = "string", description = "开始日期 yyyy-MM-dd默认今天" },
duration_days = new { type = "integer", description = "连续运动天数如一周为7天" }, duration_days = new { type = "integer", description = "连续运动天数如一周为7天" },
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" }, exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
@@ -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 = "查询运动计划时必须明确 scopetoday、scheduled_date、current_plans、upcoming_plans、ended_plans 或 all_plans",
plan.StartDate,
plan.EndDate,
plan.ReminderTime,
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
}; };
if (scope == "scheduled_date" &&
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
return new { success = false, message = "查询指定日期的运动安排时必须提供有效的 date" };
var queryDate = scope == "scheduled_date"
? DateOnly.Parse(args.GetProperty("date").GetString()!)
: AiDateTime.BeijingToday;
var isDateScope = scope is "today" or "scheduled_date";
var plans = await exercises.ListAllAsync(userId, ct);
var overview = plans.Select(plan => new
{
plan_id = plan.Id,
plan.StartDate,
plan.EndDate,
phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active",
plan.ReminderTime,
total_items = plan.Items.Count,
completed_items = plan.Items.Count(item => item.IsCompleted),
returned_items = isDateScope
? plan.Items.Count(item => item.ScheduledDate == queryDate)
: Math.Min(plan.Items.Count, 14),
items_truncated = !isDateScope && plan.Items.Count > 14,
items = plan.Items
.Where(item => !isDateScope || item.ScheduledDate == queryDate)
.OrderBy(item => item.ScheduledDate)
.Take(isDateScope ? int.MaxValue : 14)
.Select(item => new
{
item.Id,
item.ScheduledDate,
item.ExerciseType,
item.DurationMinutes,
item.IsCompleted,
item.IsRestDay,
completed_at_beijing = item.CompletedAt.HasValue ? AiDateTime.ToBeijingText(item.CompletedAt.Value) : null,
})
.ToList(),
});
var filtered = scope switch
{
"all_plans" => overview,
"current_plans" => overview.Where(plan => plan.phase == "active"),
"upcoming_plans" => overview
.Where(plan => plan.phase == "upcoming")
.OrderBy(plan => plan.StartDate),
"ended_plans" => overview
.Where(plan => plan.phase == "ended")
.OrderByDescending(plan => plan.EndDate),
_ => overview.Where(plan => plan.phase == "active" && plan.items.Count > 0),
};
var result = filtered.ToList();
var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList();
var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault();
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,
};
} }
} }

View File

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

View File

@@ -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 = "查询用药时必须明确 scopetoday、scheduled_date、current_plans、upcoming_plans、ended_plans、inactive_plans 或 all_plans",
};
if (scope == "scheduled_date" &&
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
return new { success = false, message = "查询指定日期的用药安排时必须提供有效的 date" };
var date = scope == "scheduled_date"
? DateOnly.Parse(args.GetProperty("date").GetString()!)
: AiDateTime.BeijingToday;
var isDateScope = scope is "today" or "scheduled_date";
var overview = await medications.GetAiOverviewAsync(userId, date, ct);
var plans = scope switch
{
"all_plans" => overview.ToList(),
"current_plans" => overview.Where(plan => plan.Phase == "active").ToList(),
"upcoming_plans" => overview
.Where(plan => plan.Phase == "upcoming")
.OrderBy(plan => plan.StartDate)
.ToList(),
"ended_plans" => overview
.Where(plan => plan.Phase == "ended")
.OrderByDescending(plan => plan.EndDate)
.ToList(),
"inactive_plans" => overview
.Where(plan => plan.Phase == "inactive")
.OrderByDescending(plan => plan.EndDate)
.ToList(),
_ => overview.Where(plan => plan.ScheduledOnDate).ToList(),
};
var doseStatuses = plans.SelectMany(plan => plan.Doses).ToList();
var nextPlan = overview
.Where(plan => plan.Phase == "upcoming" && plan.StartDate.HasValue)
.OrderBy(plan => plan.StartDate)
.FirstOrDefault();
return new
{
query_date = date.ToString("yyyy-MM-dd"),
scope,
count = plans.Count,
all_doses_completed = isDateScope
? doseStatuses.Count > 0 && doseStatuses.All(dose => dose.Status == "taken")
: (bool?)null,
has_upcoming_plans = nextPlan != null,
next_start_date = nextPlan?.StartDate?.ToString("yyyy-MM-dd"),
days_until_next_start = nextPlan?.StartDate is DateOnly nextStart
? nextStart.DayNumber - date.DayNumber
: (int?)null,
plans,
};
} }
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct) private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{ {
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty; var validationError = ValidateWriteArguments(args);
var success = await medications.ConfirmNowAsync(userId, medId, ct); if (validationError != null)
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" }; return new { success = false, message = validationError };
var medId = args.TryGetProperty("medication_id", out var mid) && mid.TryGetGuid(out var parsedMedicationId)
? parsedMedicationId
: Guid.Empty;
if (!args.TryGetProperty("scheduled_time", out var timeValue) ||
!TimeOnly.TryParse(timeValue.GetString(), out var scheduledTime))
return new { success = false, message = "缺少要确认的计划服药时间" };
var success = await medications.ConfirmDoseAsync(
userId,
medId,
scheduledTime,
MedicationLogStatus.Taken,
ct);
return success == true
? new { success = true, medication_id = medId, scheduled_time = scheduledTime.ToString("HH:mm") }
: new { success = false, message = success == null ? "药品不存在" : "该时间不属于今天的服药安排,或这一顿已经记录" };
}
public static string? ValidateWriteArguments(JsonElement args)
{
var action = args.TryGetProperty("action", out var actionValue)
? actionValue.GetString()?.ToLowerInvariant()
: null;
if (action == "confirm")
{
if (!args.TryGetProperty("medication_id", out var medicationId) || !medicationId.TryGetGuid(out _))
return "确认服药时缺少有效的药品 ID";
if (!args.TryGetProperty("scheduled_time", out var scheduledTime) || !TimeOnly.TryParse(scheduledTime.GetString(), out _))
return "确认服药时缺少具体的计划服药时间";
return null;
}
if (action != "create") return null;
if (!args.TryGetProperty("name", out var name) || string.IsNullOrWhiteSpace(name.GetString()))
return "创建用药计划前需要确认药品名称";
if (!args.TryGetProperty("dosage", out var dosage) || string.IsNullOrWhiteSpace(dosage.GetString()))
return "创建用药计划前需要确认每次剂量";
if (!args.TryGetProperty("frequency", out var frequencyValue) ||
!Enum.TryParse<MedicationFrequency>(frequencyValue.GetString(), ignoreCase: true, out _))
return "创建用药计划前需要确认服药频率";
if (!args.TryGetProperty("time_of_day", out var times) || times.ValueKind != JsonValueKind.Array)
return "创建用药计划前需要确认具体服药时间";
var timeValues = times.EnumerateArray().ToList();
if (timeValues.Count == 0 || timeValues.Any(value => !TimeOnly.TryParse(value.GetString(), out _)))
return "创建用药计划前需要提供有效的具体服药时间";
if (!args.TryGetProperty("start_date", out var startDate) || !DateOnly.TryParse(startDate.GetString(), out _))
return "创建用药计划前需要确认开始日期";
var durationDays = 0;
var hasDuration = args.TryGetProperty("duration_days", out var duration) &&
duration.TryGetInt32(out durationDays) && durationDays > 0;
var isLongTerm = args.TryGetProperty("long_term", out var longTerm) && longTerm.ValueKind == JsonValueKind.True;
if (hasDuration && durationDays > 3650)
return "服用天数不能超过3650天长期服用请明确设置 long_term=true";
if (hasDuration && isLongTerm)
return "服用天数和长期服用不能同时设置";
if (!hasDuration && !isLongTerm)
return "创建用药计划前需要确认服用天数,或明确说明长期服用";
return null;
} }
private static MedicationFrequency ReadFrequency(JsonElement args) private static MedicationFrequency ReadFrequency(JsonElement args)
{ {
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily"; var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily; return Enum.TryParse<MedicationFrequency>(frequencyStr, ignoreCase: true, out var frequency) ? frequency : MedicationFrequency.Daily;
} }
private static DateOnly ReadStartDate(JsonElement args) private static DateOnly ReadStartDate(JsonElement args)

View File

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

View File

@@ -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}" }),
}); });
} }

View File

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

View File

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

View File

@@ -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
- -
- + -
-
-
-
-
-
- /使 scope730 recent_days=7
- 100
-
- record_health_data98%
- record_health_data116/89
-
-
- record_health_data
- -
- pendingConfirmation=true - pendingConfirmation=true
-
- ///
- 2-5
- -
- -
"""; """;
} }

View File

@@ -16,25 +16,32 @@ public sealed class AdminService(AppDbContext db) : IAdminService
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct) public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空"); var phone = command.Phone.Trim();
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空"); var name = command.Name.Trim();
if (string.IsNullOrWhiteSpace(phone)) return Error(40001, "手机号不能为空");
if (string.IsNullOrWhiteSpace(name)) return Error(40002, "姓名不能为空");
if (await _db.Users.AnyAsync(x => x.Phone == phone, ct) ||
await _db.Doctors.AnyAsync(x => x.Phone == phone, ct))
return Error(40003, "该手机号已被其他账号使用");
var doctor = new Doctor var doctor = new Doctor
{ {
Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department, Id = Guid.NewGuid(), Name = name, Title = command.Title?.Trim(), Department = command.Department?.Trim(),
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow, Phone = phone, ProfessionalDirection = command.ProfessionalDirection?.Trim(), IsActive = true, CreatedAt = DateTime.UtcNow,
};
var user = new User
{
Id = Guid.NewGuid(), Phone = phone, Role = "Doctor", Name = name,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow
}; };
_db.Doctors.Add(doctor); _db.Doctors.Add(doctor);
if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct)) _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 })

View File

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

View File

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

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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(),
};
} }

View File

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

View File

@@ -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);
}

View 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;
}
}

View File

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

View File

@@ -9,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);
}
} }

View File

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

View File

@@ -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);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
using Health.Infrastructure.Files;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
public static class FileEndpoints public static class FileEndpoints
@@ -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"
};
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ using Health.Application.Medications;
using Health.Application.Maintenance; using Health.Application.Maintenance;
using Health.Application.Notifications; using Health.Application.Notifications;
using Health.Application.Reports; using Health.Application.Reports;
using Health.Application.Speech;
using Health.Application.Users; using Health.Application.Users;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Health.Infrastructure.Admin; using Health.Infrastructure.Admin;
@@ -25,6 +26,7 @@ using Health.Infrastructure.Medications;
using Health.Infrastructure.Maintenance; using Health.Infrastructure.Maintenance;
using Health.Infrastructure.Notifications; using Health.Infrastructure.Notifications;
using Health.Infrastructure.Reports; using Health.Infrastructure.Reports;
using Health.Infrastructure.Speech;
using Health.Infrastructure.Services; using Health.Infrastructure.Services;
using Health.Infrastructure.Users; using Health.Infrastructure.Users;
using Health.WebApi.BackgroundServices; using Health.WebApi.BackgroundServices;
@@ -34,7 +36,6 @@ using Health.WebApi.Middleware;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
// 加载 .env 文件(开发环境) // 加载 .env 文件(开发环境)
@@ -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");

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

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

View File

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

View File

@@ -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");
}
}
}

View File

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

View File

@@ -6,6 +6,8 @@ using Health.Application.Exercises;
using Health.Domain; using Health.Domain;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums; using Health.Domain.Enums;
using Health.Infrastructure.AI.AgentHandlers;
using System.Text.Json;
namespace Health.Tests; namespace Health.Tests;
@@ -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; }

View File

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

View File

@@ -0,0 +1,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);
}
}

View 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));
}
}

View 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);
}
}

View File

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

View File

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

View File

@@ -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);
}
}

View 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