From fade61ac213287cd5f3a257de948d804c992db6a Mon Sep 17 00:00:00 2001
From: MingNian <1281442923@qq.com>
Date: Wed, 15 Jul 2026 23:22:52 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=8C=E7=BA=A7=E9=A1=B5=E9=9D=A2?=
=?UTF-8?q?=E8=89=B2=E5=BD=A9=E5=88=B7=E6=96=B0=20+=20=E7=94=A8=E8=8D=AF/?=
=?UTF-8?q?=E9=80=9A=E7=9F=A5/=E8=AE=BE=E5=A4=87=E9=87=8D=E6=9E=84=20+=20?=
=?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=81=A5=E5=BA=B7=E6=A1=A3=E6=A1=88/?=
=?UTF-8?q?=E9=80=9A=E7=9F=A5=E7=AE=A1=E7=BA=BF=E5=A2=9E=E5=BC=BA=20+=20?=
=?UTF-8?q?=E5=A4=A7=E9=87=8F=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 后端
- 健康档案: 新增手术状态字段 + 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 目录
---
audit/2026-07-13/window.xml | 1 +
audit/2026-07-14/01-current.xml | 1 +
audit/2026-07-14/02-drawer.xml | 1 +
audit/2026-07-14/03-health-archive.xml | 1 +
audit/2026-07-14/04-reports.xml | 1 +
audit/2026-07-14/05-medications.xml | 1 +
audit/2026-07-14/06-diet-records.xml | 1 +
audit/2026-07-14/07-calendar.xml | 1 +
audit/2026-07-14/08-followups.xml | 1 +
audit/2026-07-14/09-exercise.xml | 1 +
audit/2026-07-14/10-devices.xml | 1 +
audit/2026-07-14/11-notifications.xml | 1 +
audit/2026-07-14/12-settings.xml | 1 +
audit/2026-07-14/13-profile.xml | 1 +
audit/2026-07-14/project-audit-report.md | 261 +++
audit/ui-pages/window.xml | 1 +
.../AI/AiConversationContracts.cs | 2 +-
.../AI/AiConversationService.cs | 7 +-
.../AI/PatientContextService.cs | 4 +-
.../HealthArchives/HealthArchiveContracts.cs | 4 +-
.../HealthArchives/HealthArchiveService.cs | 13 +
.../HealthRecords/HealthRecordContracts.cs | 1 +
.../HealthRecords/HealthRecordService.cs | 38 +
.../Medications/MedicationContracts.cs | 3 +-
.../Medications/MedicationScheduleStatus.cs | 18 +
.../Medications/MedicationService.cs | 16 +-
.../InAppNotificationContracts.cs | 7 +
.../Health.Application/Users/UserContracts.cs | 10 +
.../Health.Application/Users/UserService.cs | 13 +-
.../Entities/support_entities.cs | 1 +
.../AgentHandlers/medication_agent_handler.cs | 2 +-
.../AI/prompt_manager.cs | 16 +-
..._AddHealthArchiveSurgeryStatus.Designer.cs | 1477 ++++++++++++++++
...713132830_AddHealthArchiveSurgeryStatus.cs | 29 +
.../Migrations/AppDbContextModelSnapshot.cs | 4 +
.../Data/app_db_context.cs | 1 +
.../Notifications/EfReminderCatchUpService.cs | 284 ++++
.../EfUserNotificationPipeline.cs | 75 +-
.../Users/EfUserRepository.cs | 43 +
.../Users/local_account_file_cleanup.cs | 83 +
.../health_record_reminder_service.cs | 102 +-
.../Endpoints/ai_chat_endpoints.cs | 8 +-
.../Endpoints/doctor_endpoints.cs | 20 +-
.../Endpoints/exercise_endpoints.cs | 6 +-
.../Health.WebApi/Endpoints/file_endpoints.cs | 17 +-
.../Endpoints/health_endpoints.cs | 14 +
.../Endpoints/medication_endpoints.cs | 15 +-
.../Endpoints/notification_endpoints.cs | 10 +-
.../Health.WebApi/Endpoints/user_endpoints.cs | 6 +-
backend/src/Health.WebApi/Program.cs | 3 +
.../Health.Tests/account_deletion_tests.cs | 120 ++
.../Health.Tests/application_service_tests.cs | 139 +-
.../Health.Tests/doctor_endpoint_tests.cs | 17 +
backend/tests/Health.Tests/entity_tests.cs | 2 +
.../Health.Tests/medication_schedule_tests.cs | 29 +
.../Health.Tests/medication_update_tests.cs | 54 +
.../persistence_pipeline_tests.cs | 172 ++
.../Health.Tests/prompt_manager_tests.cs | 18 +
...2026-07-14-secondary-page-color-refresh.md | 47 +
...6-07-14-notification-preferences-design.md | 47 +
docs/ui-design-system.md | 79 +
.../android/app/src/main/AndroidManifest.xml | 1 -
health_app/lib/app.dart | 6 +
health_app/lib/core/api_client.dart | 2 +-
health_app/lib/core/app_colors.dart | 11 +-
health_app/lib/core/app_design_tokens.dart | 1 -
health_app/lib/core/app_module_visuals.dart | 4 +-
health_app/lib/core/app_router.dart | 13 +-
health_app/lib/core/app_theme.dart | 404 +++--
health_app/lib/core/local_database.dart | 16 +-
.../pages/admin/admin_add_doctor_page.dart | 6 +-
.../lib/pages/admin/admin_home_page.dart | 17 +-
health_app/lib/pages/auth/login_page.dart | 454 +++--
health_app/lib/pages/care_plan_ui_logic.dart | 41 +
health_app/lib/pages/chart/trend_page.dart | 911 +++++++---
.../consultation/consultation_pages.dart | 2 +-
.../pages/device/device_management_page.dart | 794 +++++----
.../lib/pages/device/device_scan_page.dart | 171 +-
.../pages/device/device_sync_ui_logic.dart | 33 +
.../lib/pages/diet/diet_capture_page.dart | 93 +-
.../pages/diet/diet_nutrition_widgets.dart | 4 +-
.../lib/pages/diet/diet_record_logic.dart | 9 +
.../doctor/doctor_followup_edit_page.dart | 2 +-
.../lib/pages/doctor/doctor_home_page.dart | 2 +-
.../doctor/doctor_patient_detail_page.dart | 14 +-
.../lib/pages/doctor/doctor_profile_page.dart | 2 +-
.../doctor/doctor_report_detail_page.dart | 10 +-
.../pages/doctor/doctor_settings_page.dart | 18 +-
.../pages/exercise/exercise_plan_page.dart | 604 +++++++
.../history/conversation_history_page.dart | 78 +-
health_app/lib/pages/home/home_page.dart | 393 +++--
.../home/widgets/chat_messages_view.dart | 1406 ++++++++-------
.../medication/medication_checkin_page.dart | 755 +++------
.../medication/medication_edit_page.dart | 815 +++++----
.../medication/medication_list_page.dart | 550 ++++--
.../pages/medication/medication_ui_logic.dart | 33 +
.../notification_center_page.dart | 342 ++--
.../lib/pages/profile/profile_edit_page.dart | 278 +++
.../lib/pages/profile/profile_page.dart | 257 +--
health_app/lib/pages/remaining_pages.dart | 1508 ++++++++++++-----
.../lib/pages/report/ai_analysis_page.dart | 152 +-
health_app/lib/pages/report/report_pages.dart | 648 +++++--
.../settings/notification_prefs_logic.dart | 107 ++
.../settings/notification_prefs_page.dart | 1197 +++++++------
.../lib/pages/settings/settings_pages.dart | 199 ++-
health_app/lib/providers/auth_provider.dart | 6 +
health_app/lib/providers/chat_provider.dart | 68 +-
.../lib/providers/consultation_provider.dart | 108 +-
.../conversation_history_provider.dart | 18 +-
health_app/lib/providers/data_providers.dart | 62 +-
health_app/lib/services/admin_service.dart | 11 +-
.../services/in_app_notification_service.dart | 6 +
health_app/lib/utils/sse_handler.dart | 15 +-
health_app/lib/widgets/ai_content.dart | 14 +-
health_app/lib/widgets/app_empty_state.dart | 26 +-
health_app/lib/widgets/app_error_state.dart | 20 +-
health_app/lib/widgets/app_future_view.dart | 5 +-
health_app/lib/widgets/app_toast.dart | 25 +-
health_app/lib/widgets/common_widgets.dart | 214 ++-
health_app/lib/widgets/drawer_shell.dart | 2 +-
.../lib/widgets/enterprise_widgets.dart | 36 +-
health_app/lib/widgets/health_drawer.dart | 285 ++--
health_app/test/ai_content_test.dart | 20 +
health_app/test/app_router_test.dart | 23 +
.../test/audit_fix_guardrails_test.dart | 74 +
health_app/test/care_plan_ui_logic_test.dart | 86 +
health_app/test/chat_provider_test.dart | 27 +
health_app/test/diet_record_logic_test.dart | 12 +
health_app/test/home_message_order_test.dart | 162 ++
.../test/login_flow_guardrails_test.dart | 40 +
.../test/medication_checkin_logic_test.dart | 27 +
health_app/test/medication_ui_logic_test.dart | 29 +
.../test/notification_prefs_logic_test.dart | 67 +
.../test/prelaunch_guardrails_test.dart | 70 +
...profile_device_drawer_guardrails_test.dart | 71 +
health_app/test/report_polling_test.dart | 11 +
.../test/secondary_page_visuals_test.dart | 212 +++
health_app/test/swipe_delete_tile_test.dart | 82 +
138 files changed, 12636 insertions(+), 5013 deletions(-)
create mode 100644 audit/2026-07-13/window.xml
create mode 100644 audit/2026-07-14/01-current.xml
create mode 100644 audit/2026-07-14/02-drawer.xml
create mode 100644 audit/2026-07-14/03-health-archive.xml
create mode 100644 audit/2026-07-14/04-reports.xml
create mode 100644 audit/2026-07-14/05-medications.xml
create mode 100644 audit/2026-07-14/06-diet-records.xml
create mode 100644 audit/2026-07-14/07-calendar.xml
create mode 100644 audit/2026-07-14/08-followups.xml
create mode 100644 audit/2026-07-14/09-exercise.xml
create mode 100644 audit/2026-07-14/10-devices.xml
create mode 100644 audit/2026-07-14/11-notifications.xml
create mode 100644 audit/2026-07-14/12-settings.xml
create mode 100644 audit/2026-07-14/13-profile.xml
create mode 100644 audit/2026-07-14/project-audit-report.md
create mode 100644 audit/ui-pages/window.xml
create mode 100644 backend/src/Health.Application/Medications/MedicationScheduleStatus.cs
create mode 100644 backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
create mode 100644 backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.cs
create mode 100644 backend/src/Health.Infrastructure/Notifications/EfReminderCatchUpService.cs
create mode 100644 backend/src/Health.Infrastructure/Users/local_account_file_cleanup.cs
create mode 100644 backend/tests/Health.Tests/account_deletion_tests.cs
create mode 100644 backend/tests/Health.Tests/doctor_endpoint_tests.cs
create mode 100644 backend/tests/Health.Tests/medication_schedule_tests.cs
create mode 100644 backend/tests/Health.Tests/medication_update_tests.cs
create mode 100644 backend/tests/Health.Tests/prompt_manager_tests.cs
create mode 100644 docs/superpowers/plans/2026-07-14-secondary-page-color-refresh.md
create mode 100644 docs/superpowers/specs/2026-07-14-notification-preferences-design.md
create mode 100644 docs/ui-design-system.md
create mode 100644 health_app/lib/pages/care_plan_ui_logic.dart
create mode 100644 health_app/lib/pages/device/device_sync_ui_logic.dart
create mode 100644 health_app/lib/pages/exercise/exercise_plan_page.dart
create mode 100644 health_app/lib/pages/medication/medication_ui_logic.dart
create mode 100644 health_app/lib/pages/profile/profile_edit_page.dart
create mode 100644 health_app/lib/pages/settings/notification_prefs_logic.dart
create mode 100644 health_app/test/ai_content_test.dart
create mode 100644 health_app/test/audit_fix_guardrails_test.dart
create mode 100644 health_app/test/care_plan_ui_logic_test.dart
create mode 100644 health_app/test/home_message_order_test.dart
create mode 100644 health_app/test/login_flow_guardrails_test.dart
create mode 100644 health_app/test/medication_checkin_logic_test.dart
create mode 100644 health_app/test/medication_ui_logic_test.dart
create mode 100644 health_app/test/notification_prefs_logic_test.dart
create mode 100644 health_app/test/profile_device_drawer_guardrails_test.dart
create mode 100644 health_app/test/secondary_page_visuals_test.dart
create mode 100644 health_app/test/swipe_delete_tile_test.dart
diff --git a/audit/2026-07-13/window.xml b/audit/2026-07-13/window.xml
new file mode 100644
index 0000000..244b825
--- /dev/null
+++ b/audit/2026-07-13/window.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/01-current.xml b/audit/2026-07-14/01-current.xml
new file mode 100644
index 0000000..b93abe9
--- /dev/null
+++ b/audit/2026-07-14/01-current.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/02-drawer.xml b/audit/2026-07-14/02-drawer.xml
new file mode 100644
index 0000000..d5ea71d
--- /dev/null
+++ b/audit/2026-07-14/02-drawer.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/03-health-archive.xml b/audit/2026-07-14/03-health-archive.xml
new file mode 100644
index 0000000..7a9ed28
--- /dev/null
+++ b/audit/2026-07-14/03-health-archive.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/04-reports.xml b/audit/2026-07-14/04-reports.xml
new file mode 100644
index 0000000..eb982d2
--- /dev/null
+++ b/audit/2026-07-14/04-reports.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/05-medications.xml b/audit/2026-07-14/05-medications.xml
new file mode 100644
index 0000000..26ba4fb
--- /dev/null
+++ b/audit/2026-07-14/05-medications.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/06-diet-records.xml b/audit/2026-07-14/06-diet-records.xml
new file mode 100644
index 0000000..8d148dd
--- /dev/null
+++ b/audit/2026-07-14/06-diet-records.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/07-calendar.xml b/audit/2026-07-14/07-calendar.xml
new file mode 100644
index 0000000..527ac75
--- /dev/null
+++ b/audit/2026-07-14/07-calendar.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/08-followups.xml b/audit/2026-07-14/08-followups.xml
new file mode 100644
index 0000000..b149a14
--- /dev/null
+++ b/audit/2026-07-14/08-followups.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/09-exercise.xml b/audit/2026-07-14/09-exercise.xml
new file mode 100644
index 0000000..54cb5ac
--- /dev/null
+++ b/audit/2026-07-14/09-exercise.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/10-devices.xml b/audit/2026-07-14/10-devices.xml
new file mode 100644
index 0000000..c6b2085
--- /dev/null
+++ b/audit/2026-07-14/10-devices.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/11-notifications.xml b/audit/2026-07-14/11-notifications.xml
new file mode 100644
index 0000000..bad3035
--- /dev/null
+++ b/audit/2026-07-14/11-notifications.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/12-settings.xml b/audit/2026-07-14/12-settings.xml
new file mode 100644
index 0000000..2d59171
--- /dev/null
+++ b/audit/2026-07-14/12-settings.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/13-profile.xml b/audit/2026-07-14/13-profile.xml
new file mode 100644
index 0000000..3f4bb2f
--- /dev/null
+++ b/audit/2026-07-14/13-profile.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/audit/2026-07-14/project-audit-report.md b/audit/2026-07-14/project-audit-report.md
new file mode 100644
index 0000000..97982ec
--- /dev/null
+++ b/audit/2026-07-14/project-audit-report.md
@@ -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 签名和商店账号,因此只检查了当前准备缺口。
diff --git a/audit/ui-pages/window.xml b/audit/ui-pages/window.xml
new file mode 100644
index 0000000..4a5cbfa
--- /dev/null
+++ b/audit/ui-pages/window.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/backend/src/Health.Application/AI/AiConversationContracts.cs b/backend/src/Health.Application/AI/AiConversationContracts.cs
index 9b158cf..bcb8068 100644
--- a/backend/src/Health.Application/AI/AiConversationContracts.cs
+++ b/backend/src/Health.Application/AI/AiConversationContracts.cs
@@ -35,7 +35,7 @@ public interface IAiConversationService
Task> ListAsync(Guid userId, CancellationToken ct);
Task> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
- Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
+ Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task DeleteAllAsync(Guid userId, CancellationToken ct);
}
diff --git a/backend/src/Health.Application/AI/AiConversationService.cs b/backend/src/Health.Application/AI/AiConversationService.cs
index 5327f55..c20fa60 100644
--- a/backend/src/Health.Application/AI/AiConversationService.cs
+++ b/backend/src/Health.Application/AI/AiConversationService.cs
@@ -5,7 +5,7 @@ namespace Health.Application.AI;
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
{
- private const int HistoryConversationLimit = 7;
+ private const int HistoryConversationLimit = 30;
private readonly IAiConversationRepository _conversations = conversations;
public async Task OpenAsync(
@@ -83,13 +83,14 @@ public sealed class AiConversationService(IAiConversationRepository conversation
await _conversations.SaveChangesAsync(ct);
}
- public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
+ public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
- if (conversation == null) return;
+ if (conversation == null) return false;
_conversations.Delete(conversation);
await _conversations.SaveChangesAsync(ct);
+ return true;
}
public async Task DeleteAllAsync(Guid userId, CancellationToken ct)
diff --git a/backend/src/Health.Application/AI/PatientContextService.cs b/backend/src/Health.Application/AI/PatientContextService.cs
index ee7de85..1234725 100644
--- a/backend/src/Health.Application/AI/PatientContextService.cs
+++ b/backend/src/Health.Application/AI/PatientContextService.cs
@@ -24,7 +24,9 @@ public sealed class PatientContextService(
if (archive != null)
{
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})"))}");
else if (!string.IsNullOrEmpty(archive.SurgeryType))
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
diff --git a/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs b/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs
index f6d809a..784b277 100644
--- a/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs
+++ b/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs
@@ -22,6 +22,7 @@ public sealed record HealthArchiveDto(
IReadOnlyList DietRestrictions,
IReadOnlyList ChronicDiseases,
string? FamilyHistory,
+ string? SurgeryHistoryStatus,
DateTime UpdatedAt);
public sealed record HealthArchiveUpdateRequest(
@@ -32,7 +33,8 @@ public sealed record HealthArchiveUpdateRequest(
IReadOnlyList? DietRestrictions,
IReadOnlyList? ChronicDiseases,
string? FamilyHistory,
- IReadOnlyList? Surgeries = null);
+ IReadOnlyList? Surgeries = null,
+ string? SurgeryHistoryStatus = null);
public interface IHealthArchiveService
{
diff --git a/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs b/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs
index d5811dc..10a6c29 100644
--- a/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs
+++ b/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs
@@ -1,5 +1,7 @@
using Health.Domain.Entities;
+using System.ComponentModel.DataAnnotations;
+
namespace Health.Application.HealthArchives;
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
@@ -14,6 +16,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
public async Task UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
{
+ Validate(request);
var archive = await GetOrCreateEntityAsync(userId, ct);
Apply(archive, request);
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.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
+ if (request.SurgeryHistoryStatus != null)
+ archive.SurgeryHistoryStatus = request.SurgeryHistoryStatus;
+ }
+
+ private static void Validate(HealthArchiveUpdateRequest request)
+ {
+ if (request.SurgeryHistoryStatus is not null and not ("无" or "有"))
+ throw new ValidationException("手术史状态格式不正确");
}
private static object ToAiResult(HealthArchiveDto archive) => new
@@ -123,6 +134,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
+ archive.SurgeryHistoryStatus,
};
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
@@ -136,6 +148,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
+ archive.SurgeryHistoryStatus,
archive.UpdatedAt);
private static IReadOnlyList GetSurgeries(HealthArchive archive)
diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs b/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs
index aac71a9..0984858 100644
--- a/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs
+++ b/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs
@@ -27,6 +27,7 @@ public interface IHealthRecordService
{
Task> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
Task CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
+ Task> CreateManyAsync(Guid userId, IReadOnlyList requests, CancellationToken ct);
Task UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
Task DeleteAsync(Guid userId, Guid id, CancellationToken ct);
Task> GetLatestAsync(Guid userId, CancellationToken ct);
diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs
index 9a92ea1..2ea2d00 100644
--- a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs
+++ b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs
@@ -49,6 +49,44 @@ public sealed class HealthRecordService(
return record.Id;
}
+ public async Task> CreateManyAsync(
+ Guid userId,
+ IReadOnlyList 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 UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
{
var record = await _records.GetOwnedAsync(userId, id, ct);
diff --git a/backend/src/Health.Application/Medications/MedicationContracts.cs b/backend/src/Health.Application/Medications/MedicationContracts.cs
index b1944bf..ab4751b 100644
--- a/backend/src/Health.Application/Medications/MedicationContracts.cs
+++ b/backend/src/Health.Application/Medications/MedicationContracts.cs
@@ -20,7 +20,8 @@ public sealed record MedicationPatchRequest(
IReadOnlyList? TimeOfDay,
DateOnly? StartDate,
DateOnly? EndDate,
- string? Notes);
+ string? Notes,
+ bool ClearEndDate = false);
public sealed record MedicationDto(
Guid Id,
diff --git a/backend/src/Health.Application/Medications/MedicationScheduleStatus.cs b/backend/src/Health.Application/Medications/MedicationScheduleStatus.cs
new file mode 100644
index 0000000..913d812
--- /dev/null
+++ b/backend/src/Health.Application/Medications/MedicationScheduleStatus.cs
@@ -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";
+ }
+}
diff --git a/backend/src/Health.Application/Medications/MedicationService.cs b/backend/src/Health.Application/Medications/MedicationService.cs
index 4e94b07..1380fd5 100644
--- a/backend/src/Health.Application/Medications/MedicationService.cs
+++ b/backend/src/Health.Application/Medications/MedicationService.cs
@@ -54,7 +54,8 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
- if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
+ if (request.ClearEndDate) med.EndDate = null;
+ else if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
ValidateDateRange(med.StartDate, med.EndDate);
if (request.Notes != null) med.Notes = request.Notes;
med.UpdatedAt = DateTime.UtcNow;
@@ -130,15 +131,10 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
foreach (var scheduledTime in med.TimeOfDay)
{
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
- string status;
- if (log != null)
- status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
- else if (scheduledTime <= now.AddMinutes(-30))
- status = "overdue";
- else if (scheduledTime <= now.AddHours(3))
- status = "upcoming";
- else
- continue;
+ var status = MedicationScheduleStatus.Resolve(
+ scheduledTime,
+ now,
+ log?.Status);
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
}
diff --git a/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs b/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs
index 2cb4114..3e02db8 100644
--- a/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs
+++ b/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs
@@ -58,3 +58,10 @@ public interface INotificationOutboxProcessor
Task RecoverStaleAsync(CancellationToken ct);
Task ProcessNextAsync(CancellationToken ct);
}
+
+public sealed record ReminderCatchUpResult(int CreatedCount);
+
+public interface IReminderCatchUpService
+{
+ Task CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct);
+}
diff --git a/backend/src/Health.Application/Users/UserContracts.cs b/backend/src/Health.Application/Users/UserContracts.cs
index d5b357d..1c39510 100644
--- a/backend/src/Health.Application/Users/UserContracts.cs
+++ b/backend/src/Health.Application/Users/UserContracts.cs
@@ -16,6 +16,10 @@ public sealed record UserProfileUpdateRequest(
string? Gender,
DateOnly? BirthDate);
+public sealed record AccountFileReferences(
+ IReadOnlyList FileUrls,
+ IReadOnlyList ConversationMetadataJson);
+
public interface IUserService
{
Task GetProfileAsync(Guid userId, CancellationToken ct);
@@ -26,6 +30,12 @@ public interface IUserService
public interface IUserRepository
{
Task GetAsync(Guid userId, CancellationToken ct);
+ Task GetAccountFileReferencesAsync(Guid userId, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
}
+
+public interface IAccountFileCleanup
+{
+ Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct);
+}
diff --git a/backend/src/Health.Application/Users/UserService.cs b/backend/src/Health.Application/Users/UserService.cs
index 6b9da04..8a0968b 100644
--- a/backend/src/Health.Application/Users/UserService.cs
+++ b/backend/src/Health.Application/Users/UserService.cs
@@ -1,8 +1,11 @@
namespace Health.Application.Users;
-public sealed class UserService(IUserRepository users) : IUserService
+public sealed class UserService(
+ IUserRepository users,
+ IAccountFileCleanup fileCleanup) : IUserService
{
private readonly IUserRepository _users = users;
+ private readonly IAccountFileCleanup _fileCleanup = fileCleanup;
public async Task GetProfileAsync(Guid userId, CancellationToken ct)
{
@@ -30,6 +33,10 @@ public sealed class UserService(IUserRepository users) : IUserService
return true;
}
- public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
- _users.DeleteAccountDataAsync(userId, ct);
+ public async Task DeleteAccountAsync(Guid userId, CancellationToken ct)
+ {
+ var references = await _users.GetAccountFileReferencesAsync(userId, ct);
+ await _fileCleanup.DeleteAsync(userId, references, ct);
+ await _users.DeleteAccountDataAsync(userId, ct);
+ }
}
diff --git a/backend/src/Health.Domain/Entities/support_entities.cs b/backend/src/Health.Domain/Entities/support_entities.cs
index 45abfac..dfac21c 100644
--- a/backend/src/Health.Domain/Entities/support_entities.cs
+++ b/backend/src/Health.Domain/Entities/support_entities.cs
@@ -35,6 +35,7 @@ public sealed class HealthArchive
public List DietRestrictions { get; set; } = []; // 饮食限制
public List ChronicDiseases { get; set; } = []; // 慢病史
public string? FamilyHistory { get; set; } // 家族病史
+ public string? SurgeryHistoryStatus { get; set; } // 无/有,区分未填写与无手术史
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs
index 54f7d99..cc3c868 100644
--- a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs
+++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs
@@ -43,7 +43,7 @@ public static class MedicationAgentHandler
return toolName switch
{
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
- _ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
+ _ => new { success = false, message = $"未知工具: {toolName}" }
};
}
diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs
index b510c1c..5fef6f5 100644
--- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs
+++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs
@@ -157,9 +157,9 @@ public sealed class PromptManager
""";
private const string UnifiedPrompt = """
- 你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。
+ 你是患者端统一 AI 健康助手“小脉”。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的健康解释、数据记录、用药管理、运动管理和预问诊辅助。
- 核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
+ 核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称“已录入”“已保存”或“请点击确认”,实际却没有调用写入工具并生成确认卡片。
领域判断与可用工具:
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
@@ -182,10 +182,18 @@ public sealed class PromptManager
规则:
- 用户可能一句话涉及多个领域,全部处理
- - 数值明确+指标明确→直接生成待确认命令,不要追问
+ - 先判断用户是在咨询数值,还是希望记录数据,不能仅凭消息中出现指标和数值就自动录入
+ - 明确提问时先回答问题,不调用 record_health_data。例如“血氧98%是不是太高了”是在咨询,应解释正常范围,不生成录入确认卡片;除非用户同时明确要求记录
+ - 明确要求“记录、录入、保存、记一下”时调用 record_health_data。例如“帮我记录血压116/89”必须生成待确认命令
+ - 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录
+ - 指标或数值缺失时只追问缺失部分,不猜测数值
+ - 多个明确指标要逐项调用 record_health_data,不能遗漏
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
+ - 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片
+ - 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊
+ - 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
- - 回复语气温暖、专业
+ - 回复语气温和、清楚、专业,避免假装医生或作确定诊断
""";
}
diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
new file mode 100644
index 0000000..e3213dc
--- /dev/null
+++ b/backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
@@ -0,0 +1,1477 @@
+//
+using System;
+using System.Collections.Generic;
+using Health.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Health.Infrastructure.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260713132830_AddHealthArchiveSurgeryStatus")]
+ partial class AddHealthArchiveSurgeryStatus
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Health.Domain.Entities.Consultation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ClosedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DoctorId")
+ .HasColumnType("uuid");
+
+ b.Property("Month")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DoctorId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Consultations");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ConsultationId")
+ .HasColumnType("uuid");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SenderName")
+ .HasColumnType("text");
+
+ b.Property("SenderType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ConsultationId", "CreatedAt")
+ .IsDescending(false, true);
+
+ b.ToTable("ConsultationMessages");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.Conversation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AgentType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MessageCount")
+ .HasColumnType("integer");
+
+ b.Property("Summary")
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "UpdatedAt")
+ .IsDescending(false, true);
+
+ b.ToTable("Conversations");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ConversationId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Intent")
+ .HasColumnType("text");
+
+ b.Property("MetadataJson")
+ .HasColumnType("jsonb");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ConversationId", "CreatedAt");
+
+ b.ToTable("ConversationMessages");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Platform")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PushToken")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("DeviceTokens");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Calories")
+ .HasColumnType("integer");
+
+ b.Property("CarbsGrams")
+ .HasColumnType("numeric");
+
+ b.Property("DietRecordId")
+ .HasColumnType("uuid");
+
+ b.Property("FatGrams")
+ .HasColumnType("numeric");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Portion")
+ .HasColumnType("text");
+
+ b.Property("ProteinGrams")
+ .HasColumnType("numeric");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("Warning")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DietRecordId");
+
+ b.ToTable("DietFoodItems");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.DietRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("HealthScore")
+ .HasColumnType("integer");
+
+ b.Property("MealType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RecordedAt")
+ .HasColumnType("date");
+
+ b.Property("TotalCalories")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "RecordedAt")
+ .IsDescending(false, true);
+
+ b.ToTable("DietRecords");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.Doctor", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AvatarUrl")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Department")
+ .HasColumnType("text");
+
+ b.Property("Introduction")
+ .HasColumnType("text");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Phone")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("ProfessionalDirection")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Title")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Doctors");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AvatarUrl")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Department")
+ .HasColumnType("text");
+
+ b.Property("DoctorId")
+ .HasColumnType("uuid");
+
+ b.Property("Hospital")
+ .HasColumnType("text");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("IsOnline")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DoctorId");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("DoctorProfiles");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EndDate")
+ .HasColumnType("date");
+
+ b.Property("ReminderTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("StartDate")
+ .HasColumnType("date");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "StartDate", "EndDate");
+
+ b.ToTable("ExercisePlans");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DurationMinutes")
+ .HasColumnType("integer");
+
+ b.Property("ExerciseType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsCompleted")
+ .HasColumnType("boolean");
+
+ b.Property("IsRestDay")
+ .HasColumnType("boolean");
+
+ b.Property("PlanId")
+ .HasColumnType("uuid");
+
+ b.Property("ScheduledDate")
+ .HasColumnType("date");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlanId", "ScheduledDate")
+ .IsUnique();
+
+ b.ToTable("ExercisePlanItems");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.FollowUp", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Department")
+ .HasColumnType("text");
+
+ b.Property("DoctorName")
+ .HasColumnType("text");
+
+ b.Property("Notes")
+ .HasColumnType("text");
+
+ b.Property("ScheduledAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("FollowUps");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.PrimitiveCollection>("Allergies")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.PrimitiveCollection>("ChronicDiseases")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("Diagnosis")
+ .HasColumnType("text");
+
+ b.PrimitiveCollection>("DietRestrictions")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("FamilyHistory")
+ .HasColumnType("text");
+
+ b.Property("SurgeryDate")
+ .HasColumnType("date");
+
+ b.Property("SurgeryHistoryStatus")
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("SurgeryType")
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("HealthArchives");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("HealthArchiveId")
+ .HasColumnType("uuid");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("HealthArchiveId", "SortOrder");
+
+ b.ToTable("HealthArchiveSurgeries", (string)null);
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Diastolic")
+ .HasColumnType("integer");
+
+ b.Property("IsAbnormal")
+ .HasColumnType("boolean");
+
+ b.Property("MetricType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Systolic")
+ .HasColumnType("integer");
+
+ b.Property("Unit")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("Value")
+ .HasColumnType("numeric");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "MetricType");
+
+ b.HasIndex("UserId", "RecordedAt")
+ .IsDescending(false, true);
+
+ b.ToTable("HealthRecords");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.Medication", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Dosage")
+ .HasColumnType("text");
+
+ b.Property("EndDate")
+ .HasColumnType("date");
+
+ b.Property("Frequency")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Notes")
+ .HasColumnType("text");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("StartDate")
+ .HasColumnType("date");
+
+ b.PrimitiveCollection>("TimeOfDay")
+ .IsRequired()
+ .HasColumnType("time[]");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "IsActive");
+
+ b.ToTable("Medications");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ConfirmedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MedicationId")
+ .HasColumnType("uuid");
+
+ b.Property("ScheduledTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MedicationId", "CreatedAt")
+ .IsDescending(false, true);
+
+ b.ToTable("MedicationLogs");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AbnormalAlert")
+ .HasColumnType("boolean");
+
+ b.Property("DndEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("DndEndMinutes")
+ .HasColumnType("integer");
+
+ b.Property("DndStartMinutes")
+ .HasColumnType("integer");
+
+ b.Property("DoctorReply")
+ .HasColumnType("boolean");
+
+ b.Property("FollowUpReminder")
+ .HasColumnType("boolean");
+
+ b.Property("HealthRecordReminder")
+ .HasColumnType("boolean");
+
+ b.Property("HealthRecordReminderBloodPressure")
+ .HasColumnType("boolean");
+
+ b.Property("HealthRecordReminderGlucose")
+ .HasColumnType("boolean");
+
+ b.Property("HealthRecordReminderHeartRate")
+ .HasColumnType("boolean");
+
+ b.Property("HealthRecordReminderSpO2")
+ .HasColumnType("boolean");
+
+ b.Property("HealthRecordReminderWeight")
+ .HasColumnType("boolean");
+
+ b.Property("MedicationReminder")
+ .HasColumnType("boolean");
+
+ b.Property("PushEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("NotificationPreferences");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsRevoked")
+ .HasColumnType("boolean");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.ToTable("RefreshTokens");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.Report", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AiIndicators")
+ .HasColumnType("jsonb");
+
+ b.Property("AiSummary")
+ .HasColumnType("text");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DoctorComment")
+ .HasColumnType("text");
+
+ b.Property("DoctorName")
+ .HasColumnType("text");
+
+ b.Property("DoctorRecommendation")
+ .HasColumnType("text");
+
+ b.Property("FileType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FileUrl")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ReviewedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Severity")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Reports");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AvatarUrl")
+ .HasColumnType("text");
+
+ b.Property("BirthDate")
+ .HasColumnType("date");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DoctorId")
+ .HasColumnType("uuid");
+
+ b.Property("Gender")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Role")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasDefaultValue("User");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DoctorId");
+
+ b.HasIndex("Phone")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.UserNotification", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ActionTargetId")
+ .HasColumnType("text");
+
+ b.Property("ActionType")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("IsRead")
+ .HasColumnType("boolean");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ReadAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Severity")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("SourceId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SourceId")
+ .IsUnique();
+
+ b.HasIndex("UserId", "IsDeleted", "IsRead", "CreatedAt");
+
+ b.ToTable("UserNotifications", (string)null);
+ });
+
+ modelBuilder.Entity("Health.Domain.Entities.VerificationCode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsUsed")
+ .HasColumnType("boolean");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Phone", "CreatedAt")
+ .IsDescending(false, true);
+
+ b.ToTable("VerificationCodes");
+ });
+
+ modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Arguments")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("ToolName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property