Compare commits
3 Commits
sccsbc
...
fade61ac21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fade61ac21 | ||
|
|
e654c1e0cc | ||
|
|
335a3e6440 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,10 +4,6 @@
|
|||||||
*.key
|
*.key
|
||||||
*.pfx
|
*.pfx
|
||||||
|
|
||||||
# Android release keystore
|
|
||||||
health_app/android/**/*.jks
|
|
||||||
health_app/android/key.properties
|
|
||||||
|
|
||||||
# .NET build outputs
|
# .NET build outputs
|
||||||
backend/**/bin/
|
backend/**/bin/
|
||||||
backend/**/obj/
|
backend/**/obj/
|
||||||
|
|||||||
1
audit/2026-07-13/window.xml
Normal file
1
audit/2026-07-13/window.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/01-current.xml
Normal file
1
audit/2026-07-14/01-current.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/02-drawer.xml
Normal file
1
audit/2026-07-14/02-drawer.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/03-health-archive.xml
Normal file
1
audit/2026-07-14/03-health-archive.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/04-reports.xml
Normal file
1
audit/2026-07-14/04-reports.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/05-medications.xml
Normal file
1
audit/2026-07-14/05-medications.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/06-diet-records.xml
Normal file
1
audit/2026-07-14/06-diet-records.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/07-calendar.xml
Normal file
1
audit/2026-07-14/07-calendar.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/08-followups.xml
Normal file
1
audit/2026-07-14/08-followups.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,302]"><node NAF="true" index="0" text="" resource-id="" class="android.widget.Button" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[14,127][182,295]" /><node index="1" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="复查随访" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[600,173][840,250]" /></node><node index="1" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="暂无随访安排" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[600,1816][840,1872]" /><node index="2" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="医生创建随访后将显示在这里" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[401,1886][1039,1952]" /></node></node></node></node></node></node></node></node></hierarchy>
|
||||||
1
audit/2026-07-14/09-exercise.xml
Normal file
1
audit/2026-07-14/09-exercise.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/10-devices.xml
Normal file
1
audit/2026-07-14/10-devices.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/11-notifications.xml
Normal file
1
audit/2026-07-14/11-notifications.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/12-settings.xml
Normal file
1
audit/2026-07-14/12-settings.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/13-profile.xml
Normal file
1
audit/2026-07-14/13-profile.xml
Normal file
File diff suppressed because one or more lines are too long
261
audit/2026-07-14/project-audit-report.md
Normal file
261
audit/2026-07-14/project-audit-report.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# 小脉健康项目全面检查报告
|
||||||
|
|
||||||
|
检查日期:2026-07-14
|
||||||
|
检查范围:Flutter Android 真机主要用户路径、Flutter 静态检查与测试、.NET 后端测试、前后端接口对应关系、用户/医生/管理员业务逻辑、权限安全、合规页面与 Android/iOS 发布准备。
|
||||||
|
本轮只检查,没有修改业务代码,也没有使用 Docker。
|
||||||
|
|
||||||
|
## 一、总体结论
|
||||||
|
|
||||||
|
项目主体功能已经比较完整,普通用户主路径能进入,当前 Flutter 静态检查通过,Flutter 47 项测试和后端 44 项测试全部通过。但测试通过不等于已经稳定:本次仍确认了多项自动测试没有覆盖的真实断点。
|
||||||
|
|
||||||
|
当前不适合进入正式环境或商店审核。最优先的不是继续统一小间距,而是先处理问诊鉴权、管理员验证码、上传文件访问、蓝牙上传、账号注销清理和正式包权限。这些问题会影响真实数据、安全或用户是否能完成任务。
|
||||||
|
|
||||||
|
## 二、必须先修的问题
|
||||||
|
|
||||||
|
### 1. AI 流式聊天的 token 只被“读出来”,没有验证真伪
|
||||||
|
|
||||||
|
- 用户表现:正常用户不一定马上发现,但攻击者可以自己伪造一个 token,冒充任意用户进入 AI 对话、读取该用户上下文,并可能触发健康数据写入。
|
||||||
|
- 原因:SSE 接口没有强制登录;备用 token 处理只解析 JWT 内容,没有校验签名、过期时间、签发方和接收方。
|
||||||
|
- 位置:`backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` 的 `GetUserIdFromToken`。
|
||||||
|
- 优先级:最高。
|
||||||
|
|
||||||
|
### 2. 医生问诊实时通道没有登录和问诊归属校验
|
||||||
|
|
||||||
|
- 用户表现:知道问诊编号的人可以加入房间,甚至把自己声明成“用户”或“医生”发送消息;消息还会真实写入数据库。
|
||||||
|
- 原因:SignalR Hub 没有鉴权,加入房间和发送消息时也没有核对当前账号是否属于该患者或医生。
|
||||||
|
- 位置:`backend/src/Health.WebApi/Hubs/ConsultationHub.cs`。
|
||||||
|
- 优先级:最高。
|
||||||
|
|
||||||
|
### 3. 管理员存在固定手机号和固定验证码
|
||||||
|
|
||||||
|
- 用户表现:正式环境若沿用当前代码,知道固定号码的人可用 `000000` 登录管理员端。
|
||||||
|
- 原因:管理员手机号和验证码直接写在认证服务中,而且不只在开发环境生效。
|
||||||
|
- 位置:`backend/src/Health.Infrastructure/Auth/AuthService.cs`。
|
||||||
|
- 优先级:最高。
|
||||||
|
|
||||||
|
### 4. 上传的报告、聊天图片和 PDF 可被未登录访问
|
||||||
|
|
||||||
|
- 用户表现:文件链接一旦泄露,不登录也能打开健康报告或聊天附件。
|
||||||
|
- 原因:整个 `/uploads` 目录作为公开静态目录提供,没有按用户、医生关系或临时授权校验。
|
||||||
|
- 位置:`backend/src/Health.WebApi/Program.cs` 的上传目录静态映射。
|
||||||
|
- 优先级:最高。
|
||||||
|
|
||||||
|
### 5. 蓝牙血压计能读到数据,但自动上传会失败
|
||||||
|
|
||||||
|
- 用户表现:设备连接成功、血压已经读取,最后却提示上传失败;血压和心率不会真正保存到后端。
|
||||||
|
- 原因:前端提交到 `/api/health-records/batch`,当前后端没有这个接口。
|
||||||
|
- 位置:`health_app/lib/pages/device/device_management_page.dart` 与 `backend/src/Health.WebApi/Endpoints/health_endpoints.cs`。
|
||||||
|
- 优先级:高。
|
||||||
|
|
||||||
|
### 6. 每次打开医生问诊页都会创建一条新问诊并消耗次数
|
||||||
|
|
||||||
|
- 用户表现:只是进入页面看一下、没有发送任何内容,也会新增问诊;反复进出会快速消耗每月次数。显示剩余 0 次时仍可能继续创建,甚至出现负数。
|
||||||
|
- 原因:前端初始化先查次数,然后无条件创建;后端创建接口没有真正执行三次上限,也没有验证医生仍然有效。
|
||||||
|
- 位置:`health_app/lib/providers/consultation_provider.dart`、`backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs`。
|
||||||
|
- 优先级:高。
|
||||||
|
|
||||||
|
### 7. 问诊消息发送失败时仍显示为已发送
|
||||||
|
|
||||||
|
- 用户表现:网络断开或服务器失败后,消息仍留在聊天里,没有失败标记和重试入口,用户会误以为医生已经收到。
|
||||||
|
- 原因:发送异常被静默忽略,本地消息没有回滚或转成失败状态。
|
||||||
|
- 位置:`health_app/lib/providers/consultation_provider.dart` 的 `sendMessage`。
|
||||||
|
- 优先级:高,且不符合“不能假成功”的项目要求。
|
||||||
|
|
||||||
|
### 8. Android 正式包没有网络权限
|
||||||
|
|
||||||
|
- 用户表现:Debug 真机可联网,但以后构建 release 包后,所有登录、数据、AI 和报告请求都无法联网。
|
||||||
|
- 原因:`INTERNET` 只在 debug/profile Manifest 中,main Manifest 没有;release 不会合并 debug 配置。
|
||||||
|
- 位置:`health_app/android/app/src/main/AndroidManifest.xml`。
|
||||||
|
- 优先级:正式包前必须修。
|
||||||
|
|
||||||
|
### 9. 账号注销只删数据库记录,不会清理服务器上传文件
|
||||||
|
|
||||||
|
- 用户表现:App 显示账号已删除,但报告原图、聊天图片、PDF 等仍可能留在服务器目录;公开链接仍可能继续访问。
|
||||||
|
- 原因:账号注销批量删除数据库数据时没有收集并删除物理文件。单独删除报告有删文件,注销账号没有走同一流程。
|
||||||
|
- 位置:`backend/src/Health.Infrastructure/Users/EfUserRepository.cs`。
|
||||||
|
- 优先级:高,涉及合规承诺和真实删除。
|
||||||
|
|
||||||
|
### 10. iOS 相机和相册权限说明缺失
|
||||||
|
|
||||||
|
- 用户表现:以后在 iPhone 上拍饮食、拍报告或选相册时,系统可能直接终止 App,而不是正常弹出权限框。
|
||||||
|
- 原因:Info.plist 有蓝牙说明,但没有 `NSCameraUsageDescription` 和 `NSPhotoLibraryUsageDescription`。
|
||||||
|
- 位置:`health_app/ios/Runner/Info.plist`。
|
||||||
|
- 优先级:iOS 调试前必须修。
|
||||||
|
|
||||||
|
## 三、重要功能和稳定性问题
|
||||||
|
|
||||||
|
### 11. 多个请求同时遇到登录过期时,可能刚刷新成功又被强制退出
|
||||||
|
|
||||||
|
多个 401 会同时拿同一个刷新 token。第一个请求刷新并保存新 token 后,第二个请求可能因旧 token 已失效而失败,随后把刚保存的新 token 一并清掉。用户会偶发跳回登录页。
|
||||||
|
|
||||||
|
位置:`health_app/lib/core/api_client.dart` 的 401 刷新逻辑。
|
||||||
|
|
||||||
|
### 12. 启动时只要服务器暂时连不上,就把用户当成未登录
|
||||||
|
|
||||||
|
刷新 token 的请求遇到断网或本地后端未启动时,认证状态直接变为未登录,但本地 token 又没有清除。用户会看到登录页,网络恢复后也不会自动重试,只能重启或重新操作。
|
||||||
|
|
||||||
|
位置:`health_app/lib/providers/auth_provider.dart` 的 `_checkAuth`。
|
||||||
|
|
||||||
|
### 13. 问诊轮询用随机 GUID 判断“之后的消息”,可能漏消息
|
||||||
|
|
||||||
|
SignalR 失败后会回退轮询。后端把随机消息 ID 与上一条随机 ID 做大小比较,这不等于消息时间顺序,可能漏掉医生新回复。正确游标应基于服务器时间或稳定递增字段。
|
||||||
|
|
||||||
|
位置:`backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs`。
|
||||||
|
|
||||||
|
### 14. 问诊 AI 开场白只存在前端,没有保存
|
||||||
|
|
||||||
|
用户看到的“AI 分身问候”不是数据库消息,医生端看不到;重新进入又会新建问诊并生成另一条本地问候,历史不连续。
|
||||||
|
|
||||||
|
位置:`health_app/lib/providers/consultation_provider.dart`。
|
||||||
|
|
||||||
|
### 15. 短信验证码没有频率、次数和防刷限制
|
||||||
|
|
||||||
|
发送接口可反复调用,验证码校验也没有失败次数限制。正式短信接入后会产生短信轰炸、费用和验证码暴力尝试风险。
|
||||||
|
|
||||||
|
位置:认证端点和 `AuthService`。
|
||||||
|
|
||||||
|
### 16. 管理员新增、停用、修改、删除医生时,几套医生数据可能不同步
|
||||||
|
|
||||||
|
- 手机号已属于普通用户时,仍可创建医生实体,但不会把已有用户升级并绑定医生档案。
|
||||||
|
- 修改医生手机号/姓名时,没有同步医生登录账号与医生档案。
|
||||||
|
- 停用医生只改一处状态,医生档案的状态可能仍是启用。
|
||||||
|
- 删除医生后,医生登录用户仍可能保留 Doctor 角色,但档案已失去关联,登录后只能看到“档案未关联”。
|
||||||
|
- 医生手机号没有唯一约束,可能出现重复医生。
|
||||||
|
|
||||||
|
位置:`backend/src/Health.Infrastructure/Admin/AdminService.cs`。
|
||||||
|
|
||||||
|
### 17. 附件本地路径没有限制在 uploads 目录内
|
||||||
|
|
||||||
|
附件 URL 中的相对路径直接拼到服务器路径,没有做规范化后再确认最终路径仍位于 uploads 内,也没有核对文件是否属于当前用户。这会带来跨用户读取或路径越界风险。
|
||||||
|
|
||||||
|
位置:`backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs`。
|
||||||
|
|
||||||
|
### 18. 通知点击先联网标记已读,失败时目的页面也打不开
|
||||||
|
|
||||||
|
用户点一条未读通知时,先请求后端标记已读;如果这一步失败,后续报告/用药/健康页面不会打开,也没有明确提示。
|
||||||
|
|
||||||
|
位置:`health_app/lib/pages/notifications/notification_center_page.dart`。
|
||||||
|
|
||||||
|
### 19. 用户端与医生端部分“今天/本月”按 UTC 计算
|
||||||
|
|
||||||
|
中国时区的凌晨时段可能被算到上一天或上个月,影响问诊次数、今日随访和提醒归类。正式环境应统一明确业务时区。
|
||||||
|
|
||||||
|
位置:问诊和医生工作台中使用 `DateTime.UtcNow.Date` 或 UTC 月份的逻辑。
|
||||||
|
|
||||||
|
## 四、UI 与交互问题
|
||||||
|
|
||||||
|
### 20. 用药和运动列表默认露出整片红色删除背景
|
||||||
|
|
||||||
|
真机截图中,用药行和运动行在没有滑动时已经整行变红;运动行还出现“打卡”和“删除”重叠。用户会误以为数据异常或正处于危险删除状态。
|
||||||
|
|
||||||
|
原因是通用滑动删除组件始终铺红色底层,而这些列表行本身透明。测试只验证滑动动作,没有验证未滑动时的最终视觉。
|
||||||
|
|
||||||
|
证据:`05-medications.png`、`09-exercise.png`。
|
||||||
|
位置:`health_app/lib/widgets/common_widgets.dart`。
|
||||||
|
|
||||||
|
### 21. 首页多个纯图标按钮缺少无障碍名称
|
||||||
|
|
||||||
|
真机可访问性结构中,菜单、附件、输入框和发送按钮等多个可点击区域没有名称。普通用户能凭图标猜,读屏用户无法知道用途。
|
||||||
|
|
||||||
|
证据:`01-current.xml`。
|
||||||
|
|
||||||
|
### 22. 删除动作风格不统一
|
||||||
|
|
||||||
|
报告、用药、运动使用滑动删除,通知使用整行侧滑,蓝牙设备直接显示红色垃圾桶,设置页则用大按钮。交互成本和误触风险不同。建议统一为“列表行滑动后显示删除 + 二次确认用于高价值数据”。
|
||||||
|
|
||||||
|
### 23. 设置页的红色占比过重
|
||||||
|
|
||||||
|
“删除账号”和“退出登录”两个大面积危险操作占据页面最强视觉层级,反而压过消息、隐私和服务协议。删除账号保持红色是合理的,但退出登录不必使用整块高饱和红色。
|
||||||
|
|
||||||
|
证据:`12-settings.png`。
|
||||||
|
|
||||||
|
### 24. 侧边栏健康仪表盘过于抢眼
|
||||||
|
|
||||||
|
大面积蓝紫粉渐变和四个小框成为最强区域,与项目“克制、专业、模块色只做识别”的偏好有偏差;指标小字和透明边框的对比度也偏弱。
|
||||||
|
|
||||||
|
证据:`02-drawer.png`。
|
||||||
|
|
||||||
|
### 25. 空状态表达不统一
|
||||||
|
|
||||||
|
复查随访使用居中图标和说明;饮食使用一条白色容器;其他页面可能只留空白。建议统一空状态的标题、原因和下一步操作,但不需要每页再套一张大卡片。
|
||||||
|
|
||||||
|
证据:`06-diet-records.png`、`08-followups.png`。
|
||||||
|
|
||||||
|
### 26. 部分图标按钮只有约 40dp
|
||||||
|
|
||||||
|
例如蓝牙页顶部新增按钮使用固定 40×40,低于常见的 48dp 触控建议;对手部不便或视力较弱的健康类用户不够友好。
|
||||||
|
|
||||||
|
### 27. 大字体、横屏、小屏和读屏尚无验证证据
|
||||||
|
|
||||||
|
许多页面使用固定高度、固定行布局和多个并排信息块。当前只在一台 1440×3200 Android 真机、默认字体比例下检查,不能据此确认大字体和 iOS 横屏不会溢出。
|
||||||
|
|
||||||
|
## 五、合规与发布准备问题
|
||||||
|
|
||||||
|
### 28. 合规文档仍有主体和联系方式占位符
|
||||||
|
|
||||||
|
隐私政策、服务协议、个人信息清单和 SDK 清单里仍有 `xxx`、待定短信服务商等内容,不能用于正式审核。
|
||||||
|
|
||||||
|
### 29. App 内文档与 H5 文档存在多套日期和内容
|
||||||
|
|
||||||
|
App 内隐私/协议日期为 6 月 29 日,个人信息与 SDK 清单为 7 月 9 日;H5 清单日期为 7 月 7 日。以后修改一处很容易漏另一处。建议确定单一内容源或至少增加一致性测试。
|
||||||
|
|
||||||
|
### 30. “关于我们”写了并不存在的意见反馈入口
|
||||||
|
|
||||||
|
文档提示“设置 → 意见反馈”,实际设置页没有该入口;版本还写成 `Build 20260101`,与当前 `1.0.0+1` 不一致。
|
||||||
|
|
||||||
|
### 31. 用户同意协议没有服务端记录
|
||||||
|
|
||||||
|
当前“同意”只是登录页内存中的一个布尔值,未保存同意时间、协议版本或政策更新后的重新同意记录。服务端接口也不要求同意状态,无法证明用户同意了哪一版条款。
|
||||||
|
|
||||||
|
### 32. Android 权限声明偏多且缺少版本限制
|
||||||
|
|
||||||
|
当前声明了蓝牙广播权限,但 App 主要是扫描/连接;旧版存储和蓝牙权限也没有按 Android 版本加 `maxSdkVersion`。正式个人信息清单应与实际最小权限保持一致。
|
||||||
|
|
||||||
|
### 33. 本地 token 使用普通 SQLite 明文保存
|
||||||
|
|
||||||
|
符合项目“不用 shared_preferences”的约定,但 access token 和 refresh token 没有系统级加密保护;同时 Android 也未显式关闭备份。正式版本需要决定是否改用系统安全存储或加密数据库,并限制备份。
|
||||||
|
|
||||||
|
### 34. iOS 名称和方向仍是模板状态
|
||||||
|
|
||||||
|
Info.plist 的显示名称还是 `Health App`,并允许 iPhone 横屏;当前页面主要按竖屏设计,尚未验证横屏。Bundle ID、签名和证书则仍按项目计划待配置。
|
||||||
|
|
||||||
|
### 35. CORS 当前允许任意来源并携带凭据
|
||||||
|
|
||||||
|
本地调试方便,但正式环境应只允许确定的 H5/管理端域名,不能继续接受任意 Origin。
|
||||||
|
|
||||||
|
## 六、已确认正常或已有保护的部分
|
||||||
|
|
||||||
|
- 当前 Flutter `analyze` 无错误。
|
||||||
|
- Flutter 47 项测试全部通过。
|
||||||
|
- 后端 44 项测试全部通过。
|
||||||
|
- 报告、用药、饮食、运动、健康数据等主要删除接口都按当前用户筛选,不是只删前端显示。
|
||||||
|
- 单独删除报告时会同时删除报告原文件。
|
||||||
|
- AI 历史对话恢复后仍保留原 conversationId,可以继续发送新消息;历史确认卡只读是合理保护。
|
||||||
|
- 管理员和医生普通 HTTP 端点已有角色过滤,问题主要集中在固定管理员验证码、医生数据同步和未鉴权 SignalR Hub。
|
||||||
|
- 设置、通知、报告列表已经较接近“一行一行、文字区分隔线”的目标风格。
|
||||||
|
- 隐私政策、服务协议、个人信息清单、SDK 清单和关于我们均已有 App 内入口与 H5 文件。
|
||||||
|
|
||||||
|
## 七、真机流程健康度
|
||||||
|
|
||||||
|
1. 首页与 AI 对话:可进入;视觉基本完整,但纯图标无障碍名称不足,AI SSE 鉴权有严重后端风险。
|
||||||
|
2. 侧边栏:可打开;功能入口齐全,仪表盘色彩和层级偏重。
|
||||||
|
3. 健康档案:可加载和编辑;固定保存区需要继续验证大字体遮挡。
|
||||||
|
4. 报告管理:列表正常;文件公开访问是严重隐私风险。
|
||||||
|
5. 用药管理:数据能加载;列表红底显示错误明显。
|
||||||
|
6. 饮食记录:日期、空状态和趋势能显示;空状态样式与其他页不统一。
|
||||||
|
7. 健康日历:日期和计划能显示;业务时区需要后端统一。
|
||||||
|
8. 复查随访:空状态清楚;本次账号没有数据,未实测详情。
|
||||||
|
9. 运动计划:数据能加载;红底和操作文字重叠明显。
|
||||||
|
10. 蓝牙设备:绑定设备能显示;自动读取后的批量上传链路断开。
|
||||||
|
11. 通知中心:历史和未读数能显示;未读通知在网络失败时可能打不开目的页。
|
||||||
|
12. 设置与合规入口:入口齐全;危险操作权重过高,文档仍有占位和不一致。
|
||||||
|
13. 个人信息:真机运行版本可进入;检查期间源代码刚加入新的基本资料编辑入口,当前运行包尚未热更新到这一版。
|
||||||
|
14. 医生端:已完成代码与接口核对;没有医生测试账号,本次未做完整真机流程。
|
||||||
|
15. 管理员端:已完成代码与接口核对;没有实际执行新增/停用/删除,避免改变真实数据。
|
||||||
|
16. iOS:仅做配置与代码检查;没有证书、Mac 构建和 iPhone 实测,不能确认界面与权限流程。
|
||||||
|
|
||||||
|
## 八、证据限制
|
||||||
|
|
||||||
|
- 真机截图来自本次检查时设备上正在运行的调试包。检查过程中项目源代码仍在被更新,后加入的个人资料编辑和设备错误文案没有重新构建到该运行包,因此相关结论以当前代码为准、旧界面截图只用于对应未变化页面。
|
||||||
|
- 没有执行真实删除、账号注销、医生管理、短信发送或蓝牙测量,避免改变现有数据;这些部分通过前后端代码和接口对应关系确认。
|
||||||
|
- 没有正式域名、HTTPS、短信服务商、对象存储、iOS 签名和商店账号,因此只检查了当前准备缺口。
|
||||||
1
audit/ui-pages/window.xml
Normal file
1
audit/ui-pages/window.xml
Normal file
File diff suppressed because one or more lines are too long
@@ -35,7 +35,7 @@ public interface IAiConversationService
|
|||||||
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||||
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||||
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
||||||
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||||
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
|
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ public interface IAiConversationRepository
|
|||||||
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||||
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
||||||
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
||||||
|
Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct);
|
||||||
void Delete(Conversation conversation);
|
void Delete(Conversation conversation);
|
||||||
Task SaveChangesAsync(CancellationToken ct);
|
Task SaveChangesAsync(CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace Health.Application.AI;
|
|||||||
|
|
||||||
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
||||||
{
|
{
|
||||||
private const int HistoryConversationLimit = 7;
|
private const int HistoryConversationLimit = 30;
|
||||||
private readonly IAiConversationRepository _conversations = conversations;
|
private readonly IAiConversationRepository _conversations = conversations;
|
||||||
|
|
||||||
public async Task<OpenConversationResult> OpenAsync(
|
public async Task<OpenConversationResult> OpenAsync(
|
||||||
@@ -55,6 +55,10 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await _conversations.DeleteLegacyDietCommentaryArtifactsAsync(
|
||||||
|
userId,
|
||||||
|
DietCommentaryPolicy.LegacyPromptPrefix,
|
||||||
|
ct);
|
||||||
var conversations = await _conversations.ListAsync(userId, ct);
|
var conversations = await _conversations.ListAsync(userId, ct);
|
||||||
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
||||||
}
|
}
|
||||||
@@ -79,13 +83,14 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
public async Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||||
if (conversation == null) return;
|
if (conversation == null) return false;
|
||||||
|
|
||||||
_conversations.Delete(conversation);
|
_conversations.Delete(conversation);
|
||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
||||||
|
|||||||
35
backend/src/Health.Application/AI/DietCommentaryPolicy.cs
Normal file
35
backend/src/Health.Application/AI/DietCommentaryPolicy.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Application.AI;
|
||||||
|
|
||||||
|
public sealed record DietCommentaryFood(string Name, string Portion, int Calories);
|
||||||
|
|
||||||
|
public static class DietCommentaryPolicy
|
||||||
|
{
|
||||||
|
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
|
||||||
|
|
||||||
|
public const string SystemPrompt = """
|
||||||
|
你是健康管理 App 中的饮食建议助手。根据用户本餐食物和健康档案,直接输出2到3条简短建议。
|
||||||
|
每条建议12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
||||||
|
优先围绕控盐控油、蔬菜和蛋白质搭配、血糖血脂风险及份量控制。
|
||||||
|
不作诊断,不提供治疗、处方、停药或调药结论。语言简洁、专业、面向普通用户。
|
||||||
|
""";
|
||||||
|
|
||||||
|
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>
|
||||||
|
string.Join("、", foods.Select(food =>
|
||||||
|
$"{Normalize(food.Name, 40)}({Normalize(food.Portion, 30)},{Math.Clamp(food.Calories, 0, 10000)}千卡)"));
|
||||||
|
|
||||||
|
public static bool IsLegacyArtifact(IEnumerable<ConversationMessage> messages)
|
||||||
|
{
|
||||||
|
var userMessages = messages.Where(message => message.Role == MessageRole.User).ToList();
|
||||||
|
return userMessages.Count > 0 &&
|
||||||
|
userMessages.All(message => message.Content.TrimStart().StartsWith(LegacyPromptPrefix, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string value, int maxLength)
|
||||||
|
{
|
||||||
|
var normalized = string.Join(' ', value.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
return normalized.Length > maxLength ? normalized[..maxLength] : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,9 @@ public sealed class PatientContextService(
|
|||||||
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})");
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ namespace Health.Application.Auth;
|
|||||||
|
|
||||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
||||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||||
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name);
|
|
||||||
|
|
||||||
public interface IAuthService
|
public interface IAuthService
|
||||||
{
|
{
|
||||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||||
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct);
|
|
||||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed record HealthArchiveDto(
|
|||||||
IReadOnlyList<string> DietRestrictions,
|
IReadOnlyList<string> DietRestrictions,
|
||||||
IReadOnlyList<string> ChronicDiseases,
|
IReadOnlyList<string> ChronicDiseases,
|
||||||
string? FamilyHistory,
|
string? FamilyHistory,
|
||||||
|
string? SurgeryHistoryStatus,
|
||||||
DateTime UpdatedAt);
|
DateTime UpdatedAt);
|
||||||
|
|
||||||
public sealed record HealthArchiveUpdateRequest(
|
public sealed record HealthArchiveUpdateRequest(
|
||||||
@@ -32,7 +33,8 @@ public sealed record HealthArchiveUpdateRequest(
|
|||||||
IReadOnlyList<string>? DietRestrictions,
|
IReadOnlyList<string>? DietRestrictions,
|
||||||
IReadOnlyList<string>? ChronicDiseases,
|
IReadOnlyList<string>? ChronicDiseases,
|
||||||
string? FamilyHistory,
|
string? FamilyHistory,
|
||||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
|
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null,
|
||||||
|
string? SurgeryHistoryStatus = null);
|
||||||
|
|
||||||
public interface IHealthArchiveService
|
public interface IHealthArchiveService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
|
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Health.Application.HealthArchives;
|
namespace Health.Application.HealthArchives;
|
||||||
|
|
||||||
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
||||||
@@ -14,6 +16,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
|
|
||||||
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
Validate(request);
|
||||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||||
Apply(archive, request);
|
Apply(archive, request);
|
||||||
archive.UpdatedAt = DateTime.UtcNow;
|
archive.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -110,6 +113,14 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
||||||
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
||||||
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
|
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
|
||||||
|
if (request.SurgeryHistoryStatus != null)
|
||||||
|
archive.SurgeryHistoryStatus = request.SurgeryHistoryStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Validate(HealthArchiveUpdateRequest request)
|
||||||
|
{
|
||||||
|
if (request.SurgeryHistoryStatus is not null and not ("无" or "有"))
|
||||||
|
throw new ValidationException("手术史状态格式不正确");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object ToAiResult(HealthArchiveDto archive) => new
|
private static object ToAiResult(HealthArchiveDto archive) => new
|
||||||
@@ -123,6 +134,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
archive.DietRestrictions,
|
archive.DietRestrictions,
|
||||||
archive.ChronicDiseases,
|
archive.ChronicDiseases,
|
||||||
archive.FamilyHistory,
|
archive.FamilyHistory,
|
||||||
|
archive.SurgeryHistoryStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
|
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
|
||||||
@@ -136,6 +148,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
archive.DietRestrictions,
|
archive.DietRestrictions,
|
||||||
archive.ChronicDiseases,
|
archive.ChronicDiseases,
|
||||||
archive.FamilyHistory,
|
archive.FamilyHistory,
|
||||||
|
archive.SurgeryHistoryStatus,
|
||||||
archive.UpdatedAt);
|
archive.UpdatedAt);
|
||||||
|
|
||||||
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public interface IHealthRecordService
|
|||||||
{
|
{
|
||||||
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
||||||
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
|
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||||
|
Task<IReadOnlyList<Guid>> CreateManyAsync(Guid userId, IReadOnlyList<HealthRecordUpsertRequest> requests, CancellationToken ct);
|
||||||
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
|
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||||
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
||||||
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public static class HealthRecordRules
|
|||||||
{
|
{
|
||||||
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
||||||
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
||||||
HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m,
|
HealthMetricType.Glucose => request.Value > 6.1m || request.Value < 3.9m,
|
||||||
HealthMetricType.SpO2 => request.Value <= 94,
|
HealthMetricType.SpO2 => request.Value <= 94,
|
||||||
HealthMetricType.Weight => false,
|
HealthMetricType.Weight => false,
|
||||||
_ => false
|
_ => false
|
||||||
@@ -58,4 +58,3 @@ public static class HealthRecordRules
|
|||||||
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,44 @@ public sealed class HealthRecordService(
|
|||||||
return record.Id;
|
return record.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Guid>> CreateManyAsync(
|
||||||
|
Guid userId,
|
||||||
|
IReadOnlyList<HealthRecordUpsertRequest> requests,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (requests.Count == 0)
|
||||||
|
throw new ArgumentException("至少需要一条健康记录", nameof(requests));
|
||||||
|
|
||||||
|
foreach (var request in requests)
|
||||||
|
HealthRecordRules.Validate(request);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var records = requests.Select(request => new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
MetricType = request.Type,
|
||||||
|
Systolic = request.Systolic,
|
||||||
|
Diastolic = request.Diastolic,
|
||||||
|
Value = request.Value,
|
||||||
|
Unit = request.Unit,
|
||||||
|
Source = request.Source,
|
||||||
|
RecordedAt = request.RecordedAt ?? now,
|
||||||
|
CreatedAt = now,
|
||||||
|
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
await _records.AddAsync(record, ct);
|
||||||
|
|
||||||
|
await _records.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
await EnqueueAbnormalNotificationAsync(record, ct);
|
||||||
|
|
||||||
|
return records.Select(record => record.Id).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
|
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||||
@@ -101,6 +139,7 @@ public sealed class HealthRecordService(
|
|||||||
latest.Diastolic,
|
latest.Diastolic,
|
||||||
latest.Value,
|
latest.Value,
|
||||||
latest.Unit,
|
latest.Unit,
|
||||||
|
latest.IsAbnormal,
|
||||||
latest.RecordedAt
|
latest.RecordedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -140,7 +179,7 @@ public sealed class HealthRecordService(
|
|||||||
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
||||||
HealthMetricType.HeartRate =>
|
HealthMetricType.HeartRate =>
|
||||||
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
||||||
HealthMetricType.Glucose when record.Value >= 7.0m =>
|
HealthMetricType.Glucose when record.Value > 6.1m =>
|
||||||
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
||||||
HealthMetricType.Glucose =>
|
HealthMetricType.Glucose =>
|
||||||
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public sealed record MedicationPatchRequest(
|
|||||||
IReadOnlyList<TimeOnly>? TimeOfDay,
|
IReadOnlyList<TimeOnly>? TimeOfDay,
|
||||||
DateOnly? StartDate,
|
DateOnly? StartDate,
|
||||||
DateOnly? EndDate,
|
DateOnly? EndDate,
|
||||||
string? Notes);
|
string? Notes,
|
||||||
|
bool ClearEndDate = false);
|
||||||
|
|
||||||
public sealed record MedicationDto(
|
public sealed record MedicationDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Application.Medications;
|
||||||
|
|
||||||
|
public static class MedicationScheduleStatus
|
||||||
|
{
|
||||||
|
public static string Resolve(
|
||||||
|
TimeOnly scheduledTime,
|
||||||
|
TimeOnly currentTime,
|
||||||
|
MedicationLogStatus? logStatus)
|
||||||
|
{
|
||||||
|
if (logStatus.HasValue)
|
||||||
|
return logStatus == MedicationLogStatus.Taken ? "taken" : "skipped";
|
||||||
|
if (scheduledTime <= currentTime.AddMinutes(-30)) return "overdue";
|
||||||
|
if (scheduledTime <= currentTime.AddHours(3)) return "upcoming";
|
||||||
|
return "scheduled";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,8 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
|||||||
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
|
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
|
||||||
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
|
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
|
||||||
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
||||||
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
if (request.ClearEndDate) med.EndDate = null;
|
||||||
|
else if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
||||||
ValidateDateRange(med.StartDate, med.EndDate);
|
ValidateDateRange(med.StartDate, med.EndDate);
|
||||||
if (request.Notes != null) med.Notes = request.Notes;
|
if (request.Notes != null) med.Notes = request.Notes;
|
||||||
med.UpdatedAt = DateTime.UtcNow;
|
med.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -130,15 +131,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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Health.Application.Users;
|
|||||||
|
|
||||||
public sealed record UserProfileDto(
|
public sealed record UserProfileDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string? Phone, // Apple 用户无手机号
|
string Phone,
|
||||||
string Role,
|
string Role,
|
||||||
string? Name,
|
string? Name,
|
||||||
string? Gender,
|
string? Gender,
|
||||||
@@ -16,6 +16,10 @@ public sealed record UserProfileUpdateRequest(
|
|||||||
string? Gender,
|
string? Gender,
|
||||||
DateOnly? BirthDate);
|
DateOnly? BirthDate);
|
||||||
|
|
||||||
|
public sealed record AccountFileReferences(
|
||||||
|
IReadOnlyList<string> FileUrls,
|
||||||
|
IReadOnlyList<string> ConversationMetadataJson);
|
||||||
|
|
||||||
public interface IUserService
|
public interface IUserService
|
||||||
{
|
{
|
||||||
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
||||||
@@ -26,6 +30,12 @@ public interface IUserService
|
|||||||
public interface IUserRepository
|
public interface IUserRepository
|
||||||
{
|
{
|
||||||
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
||||||
|
Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct);
|
||||||
Task SaveChangesAsync(CancellationToken ct);
|
Task SaveChangesAsync(CancellationToken ct);
|
||||||
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
|
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IAccountFileCleanup
|
||||||
|
{
|
||||||
|
Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
namespace Health.Application.Users;
|
namespace Health.Application.Users;
|
||||||
|
|
||||||
public sealed class UserService(IUserRepository users) : IUserService
|
public sealed class UserService(
|
||||||
|
IUserRepository users,
|
||||||
|
IAccountFileCleanup fileCleanup) : IUserService
|
||||||
{
|
{
|
||||||
private readonly IUserRepository _users = users;
|
private readonly IUserRepository _users = users;
|
||||||
|
private readonly IAccountFileCleanup _fileCleanup = fileCleanup;
|
||||||
|
|
||||||
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -30,6 +33,10 @@ public sealed class UserService(IUserRepository users) : IUserService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
|
public async Task DeleteAccountAsync(Guid userId, CancellationToken ct)
|
||||||
_users.DeleteAccountDataAsync(userId, ct);
|
{
|
||||||
|
var references = await _users.GetAccountFileReferencesAsync(userId, ct);
|
||||||
|
await _fileCleanup.DeleteAsync(userId, references, ct);
|
||||||
|
await _users.DeleteAccountDataAsync(userId, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public sealed class HealthArchive
|
|||||||
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
||||||
public List<string> ChronicDiseases { get; set; } = []; // 慢病史
|
public List<string> ChronicDiseases { get; set; } = []; // 慢病史
|
||||||
public string? FamilyHistory { get; set; } // 家族病史
|
public string? FamilyHistory { get; set; } // 家族病史
|
||||||
|
public string? SurgeryHistoryStatus { get; set; } // 无/有,区分未填写与无手术史
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public User User { get; set; } = null!;
|
public User User { get; set; } = null!;
|
||||||
@@ -89,6 +90,10 @@ public sealed class NotificationPreference
|
|||||||
public bool FollowUpReminder { get; set; } = true;
|
public bool FollowUpReminder { get; set; } = true;
|
||||||
public bool DoctorReply { get; set; } = true;
|
public bool DoctorReply { get; set; } = true;
|
||||||
public bool AbnormalAlert { get; set; } = true;
|
public bool AbnormalAlert { get; set; } = true;
|
||||||
|
public bool PushEnabled { get; set; } = true;
|
||||||
|
public bool DndEnabled { get; set; }
|
||||||
|
public int DndStartMinutes { get; set; } = 22 * 60;
|
||||||
|
public int DndEndMinutes { get; set; } = 8 * 60;
|
||||||
// 健康录入提醒——总开关 + 5 子项
|
// 健康录入提醒——总开关 + 5 子项
|
||||||
public bool HealthRecordReminder { get; set; } = true;
|
public bool HealthRecordReminder { get; set; } = true;
|
||||||
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ namespace Health.Domain.Entities;
|
|||||||
public sealed class User
|
public sealed class User
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string? Phone { get; set; } // Apple 用户无手机号
|
public string Phone { get; set; } = string.Empty;
|
||||||
public string? AppleUserId { get; set; } // Apple Sign-In 唯一标识
|
|
||||||
public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
|
public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
|
||||||
public Guid? DoctorId { get; set; } // 患者选择的医生
|
public Guid? DoctorId { get; set; } // 患者选择的医生
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ 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}" }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -157,9 +157,9 @@ public sealed class PromptManager
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
private const string UnifiedPrompt = """
|
private const string UnifiedPrompt = """
|
||||||
你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。
|
你是患者端统一 AI 健康助手“小脉”。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的健康解释、数据记录、用药管理、运动管理和预问诊辅助。
|
||||||
|
|
||||||
核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
|
核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称“已录入”“已保存”或“请点击确认”,实际却没有调用写入工具并生成确认卡片。
|
||||||
|
|
||||||
领域判断与可用工具:
|
领域判断与可用工具:
|
||||||
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
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 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||||
|
- 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片
|
||||||
|
- 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊
|
||||||
|
- 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||||
- 回复语气温暖、专业
|
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ using Health.Infrastructure.Services;
|
|||||||
|
|
||||||
namespace Health.Infrastructure.Auth;
|
namespace Health.Infrastructure.Auth;
|
||||||
|
|
||||||
public sealed class AuthService(
|
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
|
||||||
AppDbContext db,
|
|
||||||
JwtProvider jwt,
|
|
||||||
SmsService sms,
|
|
||||||
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
|
|
||||||
) : IAuthService
|
|
||||||
{
|
{
|
||||||
private const string AdminPhone = "12345678910";
|
private const string AdminPhone = "12345678910";
|
||||||
private const string AdminSmsCode = "000000";
|
private const string AdminSmsCode = "000000";
|
||||||
@@ -16,7 +11,6 @@ public sealed class AuthService(
|
|||||||
private readonly AppDbContext _db = db;
|
private readonly AppDbContext _db = db;
|
||||||
private readonly JwtProvider _jwt = jwt;
|
private readonly JwtProvider _jwt = jwt;
|
||||||
private readonly SmsService _sms = sms;
|
private readonly SmsService _sms = sms;
|
||||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
|
||||||
|
|
||||||
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
|
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -105,64 +99,13 @@ public sealed class AuthService(
|
|||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
|
||||||
{
|
|
||||||
// 1. 验证 Apple identityToken
|
|
||||||
string appleUserId;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 查找已有用户
|
|
||||||
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
|
||||||
if (existingUser != null)
|
|
||||||
{
|
|
||||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
|
||||||
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
|
||||||
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return new AuthResult(0, new
|
|
||||||
{
|
|
||||||
tokens.accessToken, tokens.refreshToken,
|
|
||||||
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
|
||||||
var newUser = new User
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Phone = null,
|
|
||||||
AppleUserId = appleUserId,
|
|
||||||
Role = "User",
|
|
||||||
Name = command.Name,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
UpdatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
_db.Users.Add(newUser);
|
|
||||||
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
|
||||||
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
|
||||||
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return new AuthResult(0, new
|
|
||||||
{
|
|
||||||
newTokens.accessToken, newTokens.refreshToken,
|
|
||||||
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
|
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
|
||||||
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
|
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
|
||||||
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
|
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role)
|
||||||
{
|
{
|
||||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
var access = _jwt.GenerateAccessToken(userId, phone, role);
|
||||||
var refresh = _jwt.GenerateRefreshToken();
|
var refresh = _jwt.GenerateRefreshToken();
|
||||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||||
return (access, refresh);
|
return (access, refresh);
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Health.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddAppleSignIn : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Phone",
|
|
||||||
table: "Users",
|
|
||||||
type: "text",
|
|
||||||
nullable: true,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "AppleUserId",
|
|
||||||
table: "Users",
|
|
||||||
type: "text",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Users_AppleUserId",
|
|
||||||
table: "Users",
|
|
||||||
column: "AppleUserId",
|
|
||||||
unique: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropIndex(
|
|
||||||
name: "IX_Users_AppleUserId",
|
|
||||||
table: "Users");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "AppleUserId",
|
|
||||||
table: "Users");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Phone",
|
|
||||||
table: "Users",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "",
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text",
|
|
||||||
oldNullable: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,8 +13,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|||||||
namespace Health.Infrastructure.Data.Migrations
|
namespace Health.Infrastructure.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(AppDbContext))]
|
[DbContext(typeof(AppDbContext))]
|
||||||
[Migration("20260710052811_AddAppleSignIn")]
|
[Migration("20260713022401_AddPushAndDndPreferences")]
|
||||||
partial class AddAppleSignIn
|
partial class AddPushAndDndPreferences
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -676,6 +676,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");
|
||||||
|
|
||||||
@@ -703,6 +712,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");
|
||||||
|
|
||||||
@@ -806,9 +818,6 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("AppleUserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("AvatarUrl")
|
b.Property<string>("AvatarUrl")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -828,6 +837,7 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Role")
|
b.Property<string>("Role")
|
||||||
@@ -842,9 +852,6 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("AppleUserId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("DoctorId");
|
b.HasIndex("DoctorId");
|
||||||
|
|
||||||
b.HasIndex("Phone")
|
b.HasIndex("Phone")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPushAndDndPreferences : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "DndEnabled",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DndEndMinutes",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DndStartMinutes",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "PushEnabled",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DndEnabled",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DndEndMinutes",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DndStartMinutes",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PushEnabled",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHealthArchiveSurgeryStatus : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SurgeryHistoryStatus",
|
||||||
|
table: "HealthArchives",
|
||||||
|
type: "character varying(8)",
|
||||||
|
maxLength: 8,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SurgeryHistoryStatus",
|
||||||
|
table: "HealthArchives");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -486,6 +486,10 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<DateOnly?>("SurgeryDate")
|
b.Property<DateOnly?>("SurgeryDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("SurgeryHistoryStatus")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
b.Property<string>("SurgeryType")
|
b.Property<string>("SurgeryType")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -673,6 +677,15 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("AbnormalAlert")
|
b.Property<bool>("AbnormalAlert")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("DndEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("DndEndMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("DndStartMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<bool>("DoctorReply")
|
b.Property<bool>("DoctorReply")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -700,6 +713,9 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("MedicationReminder")
|
b.Property<bool>("MedicationReminder")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PushEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -803,9 +819,6 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("AppleUserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("AvatarUrl")
|
b.Property<string>("AvatarUrl")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -825,6 +838,7 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Role")
|
b.Property<string>("Role")
|
||||||
@@ -839,9 +853,6 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("AppleUserId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("DoctorId");
|
b.HasIndex("DoctorId");
|
||||||
|
|
||||||
b.HasIndex("Phone")
|
b.HasIndex("Phone")
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
builder.Entity<User>(e =>
|
builder.Entity<User>(e =>
|
||||||
{
|
{
|
||||||
e.HasIndex(u => u.Phone).IsUnique();
|
e.HasIndex(u => u.Phone).IsUnique();
|
||||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识
|
|
||||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||||
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
@@ -171,6 +170,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 =>
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
using Health.Application.Notifications;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
public sealed class EfReminderCatchUpService(AppDbContext db) : IReminderCatchUpService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db = db;
|
||||||
|
|
||||||
|
public async Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pref = await GetPreferenceAsync(userId, ct);
|
||||||
|
if (!pref.PushEnabled) return new ReminderCatchUpResult(0);
|
||||||
|
|
||||||
|
var beijingNow = utcNow.AddHours(8);
|
||||||
|
var today = DateOnly.FromDateTime(beijingNow);
|
||||||
|
var todayStartUtc = beijingNow.Date.AddHours(-8);
|
||||||
|
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
created += await CreateHealthRecordReminderAsync(userId, pref, beijingNow, todayStartUtc, todayEndUtc, ct);
|
||||||
|
created += await CreateMedicationRemindersAsync(userId, pref, beijingNow, today, todayStartUtc, todayEndUtc, ct);
|
||||||
|
created += await CreateExerciseRemindersAsync(userId, pref, beijingNow, today, ct);
|
||||||
|
created += await CreateFollowUpRemindersAsync(userId, pref, beijingNow, ct);
|
||||||
|
|
||||||
|
if (created > 0)
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ReminderCatchUpResult(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<NotificationPreference> GetPreferenceAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pref = await _db.NotificationPreferences.FirstOrDefaultAsync(x => x.UserId == userId, ct);
|
||||||
|
if (pref != null) return pref;
|
||||||
|
|
||||||
|
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||||
|
await _db.NotificationPreferences.AddAsync(pref, ct);
|
||||||
|
return pref;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateHealthRecordReminderAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateTime todayStartUtc,
|
||||||
|
DateTime todayEndUtc,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.HealthRecordReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var window = HealthRecordWindow(beijingNow);
|
||||||
|
if (window == null) return 0;
|
||||||
|
|
||||||
|
var recordedTypes = await _db.HealthRecords.AsNoTracking()
|
||||||
|
.Where(r => r.UserId == userId && r.RecordedAt >= todayStartUtc && r.RecordedAt < todayEndUtc)
|
||||||
|
.Select(r => r.MetricType)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var missing = EnabledHealthMetrics(pref)
|
||||||
|
.Where(x => !recordedTypes.Contains(x.Type))
|
||||||
|
.ToList();
|
||||||
|
if (missing.Count == 0) return 0;
|
||||||
|
|
||||||
|
var date = DateOnly.FromDateTime(beijingNow);
|
||||||
|
var sourceId = StableGuid($"health_record|{userId:N}|{date:yyyyMMdd}|{window}");
|
||||||
|
return await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"health_record_reminder",
|
||||||
|
window == "evening" ? "今日健康指标还未完成" : "记得录入今日健康指标",
|
||||||
|
$"建议录入:{string.Join("、", missing.Select(x => x.Label))}",
|
||||||
|
"info",
|
||||||
|
"health",
|
||||||
|
null,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateMedicationRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateOnly today,
|
||||||
|
DateTime todayStartUtc,
|
||||||
|
DateTime todayEndUtc,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.MedicationReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var now = TimeOnly.FromDateTime(beijingNow);
|
||||||
|
var earliest = now.AddHours(-4);
|
||||||
|
var medications = await _db.Medications.AsNoTracking()
|
||||||
|
.Where(m => m.UserId == userId && m.IsActive)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var medication in medications.Where(m => IsMedicationActiveOn(m, today) && ShouldDoseToday(m, today)))
|
||||||
|
{
|
||||||
|
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInTimeWindow(t, earliest, now)))
|
||||||
|
{
|
||||||
|
var logged = await _db.MedicationLogs.AsNoTracking().AnyAsync(l =>
|
||||||
|
l.UserId == userId &&
|
||||||
|
l.MedicationId == medication.Id &&
|
||||||
|
l.ScheduledTime == scheduledTime &&
|
||||||
|
l.CreatedAt >= todayStartUtc &&
|
||||||
|
l.CreatedAt < todayEndUtc, ct);
|
||||||
|
if (logged) continue;
|
||||||
|
|
||||||
|
var sourceId = StableGuid($"medication|{userId:N}|{medication.Id:N}|{today:yyyyMMdd}|{scheduledTime:HHmm}");
|
||||||
|
var details = string.Join(" ", new[] { medication.Dosage, scheduledTime.ToString("HH:mm") }
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"MedicationReminder",
|
||||||
|
"用药时间到了",
|
||||||
|
$"请按计划服用{medication.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。",
|
||||||
|
"info",
|
||||||
|
"medication",
|
||||||
|
medication.Id.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateExerciseRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var now = TimeOnly.FromDateTime(beijingNow);
|
||||||
|
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
|
||||||
|
.Include(x => x.Plan)
|
||||||
|
.Where(x => x.Plan.UserId == userId &&
|
||||||
|
x.ScheduledDate == today &&
|
||||||
|
!x.IsCompleted &&
|
||||||
|
!x.IsRestDay &&
|
||||||
|
x.Plan.ReminderTime <= now)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var item in dueItems)
|
||||||
|
{
|
||||||
|
var sourceId = StableGuid($"exercise|{userId:N}|{item.Id:N}|{today:yyyyMMdd}");
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"ExerciseReminder",
|
||||||
|
"今日运动计划",
|
||||||
|
$"今天安排了{item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
|
||||||
|
"info",
|
||||||
|
"exercise",
|
||||||
|
item.PlanId.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateFollowUpRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.FollowUpReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var nowUtc = beijingNow.AddHours(-8);
|
||||||
|
var upcomingEndUtc = nowUtc.AddHours(24);
|
||||||
|
var followUps = await _db.FollowUps.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId && x.ScheduledAt >= nowUtc && x.ScheduledAt <= upcomingEndUtc)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var followUp in followUps)
|
||||||
|
{
|
||||||
|
var sourceId = StableGuid($"followup|{userId:N}|{followUp.Id:N}|24h");
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"follow_up_reminder",
|
||||||
|
"复查提醒",
|
||||||
|
$"{followUp.Title} 即将到期,请提前安排时间。",
|
||||||
|
"info",
|
||||||
|
"followup",
|
||||||
|
followUp.Id.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> AddNotificationIfMissingAsync(
|
||||||
|
Guid userId,
|
||||||
|
Guid sourceId,
|
||||||
|
string type,
|
||||||
|
string title,
|
||||||
|
string message,
|
||||||
|
string severity,
|
||||||
|
string? actionType,
|
||||||
|
string? actionTargetId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await _db.UserNotifications.AsNoTracking().AnyAsync(x => x.SourceId == sourceId, ct))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
await _db.UserNotifications.AddAsync(new UserNotification
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
SourceId = sourceId,
|
||||||
|
Type = type,
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
Severity = severity,
|
||||||
|
ActionType = actionType,
|
||||||
|
ActionTargetId = actionTargetId,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}, ct);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? HealthRecordWindow(DateTime beijingNow)
|
||||||
|
{
|
||||||
|
var minutes = beijingNow.Hour * 60 + beijingNow.Minute;
|
||||||
|
if (minutes is >= 7 * 60 and <= 10 * 60 + 30) return "morning";
|
||||||
|
if (minutes is >= 11 * 60 + 30 and <= 14 * 60) return "noon";
|
||||||
|
if (minutes is >= 18 * 60 and <= 21 * 60 + 30) return "evening";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<(HealthMetricType Type, string Label)> EnabledHealthMetrics(NotificationPreference pref)
|
||||||
|
{
|
||||||
|
var result = new List<(HealthMetricType, string)>();
|
||||||
|
if (pref.HealthRecordReminderBloodPressure) result.Add((HealthMetricType.BloodPressure, "血压"));
|
||||||
|
if (pref.HealthRecordReminderHeartRate) result.Add((HealthMetricType.HeartRate, "心率"));
|
||||||
|
if (pref.HealthRecordReminderGlucose) result.Add((HealthMetricType.Glucose, "血糖"));
|
||||||
|
if (pref.HealthRecordReminderSpO2) result.Add((HealthMetricType.SpO2, "血氧"));
|
||||||
|
if (pref.HealthRecordReminderWeight) result.Add((HealthMetricType.Weight, "体重"));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInDnd(NotificationPreference pref, DateTime beijingNow)
|
||||||
|
{
|
||||||
|
if (!pref.DndEnabled) return false;
|
||||||
|
var minuteOfDay = beijingNow.Hour * 60 + beijingNow.Minute;
|
||||||
|
return pref.DndStartMinutes <= pref.DndEndMinutes
|
||||||
|
? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes
|
||||||
|
: minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMedicationActiveOn(Medication medication, DateOnly date) =>
|
||||||
|
(medication.StartDate == null || medication.StartDate <= date) &&
|
||||||
|
(medication.EndDate == null || medication.EndDate >= date);
|
||||||
|
|
||||||
|
private static bool ShouldDoseToday(Medication medication, DateOnly today)
|
||||||
|
{
|
||||||
|
if (medication.Frequency is MedicationFrequency.Daily
|
||||||
|
or MedicationFrequency.TwiceDaily
|
||||||
|
or MedicationFrequency.ThreeTimesDaily
|
||||||
|
or MedicationFrequency.AsNeeded)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var startDate = medication.StartDate ?? today;
|
||||||
|
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
|
||||||
|
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
|
||||||
|
if (medication.Frequency == MedicationFrequency.Weekly)
|
||||||
|
return today.DayOfWeek == startDate.DayOfWeek;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInTimeWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
|
||||||
|
end >= start ? value >= start && value <= end : value >= start || value <= end;
|
||||||
|
|
||||||
|
private static Guid StableGuid(string key)
|
||||||
|
{
|
||||||
|
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
||||||
|
return new Guid(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificat
|
|||||||
NotificationMessage message,
|
NotificationMessage message,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var pushEnabled = await _db.NotificationPreferences.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId)
|
||||||
|
.Select(x => (bool?)x.PushEnabled)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (pushEnabled is false) return false;
|
||||||
|
|
||||||
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
|
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -67,7 +73,7 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
|||||||
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
||||||
.ExecuteUpdateAsync(setters => setters
|
.ExecuteUpdateAsync(setters => setters
|
||||||
.SetProperty(x => x.Status, "Failed")
|
.SetProperty(x => x.Status, "Failed")
|
||||||
.SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
|
.SetProperty(x => x.LastError, "Notification task timed out and reached max retry count")
|
||||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,59 +87,89 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
|||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
if (candidate == null) return false;
|
if (candidate == null) return false;
|
||||||
|
|
||||||
var claimed = await _db.NotificationOutbox
|
var task = await _db.NotificationOutbox
|
||||||
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
|
.FirstOrDefaultAsync(x => x.Id == candidate.Id && x.Status == "Pending", ct);
|
||||||
.ExecuteUpdateAsync(setters => setters
|
if (task == null) return true;
|
||||||
.SetProperty(x => x.Status, "Processing")
|
|
||||||
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
|
task.Status = "Processing";
|
||||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
task.Attempts += 1;
|
||||||
if (claimed == 0) return true;
|
task.UpdatedAt = now;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
|
var message = JsonSerializer.Deserialize<NotificationMessage>(task.Payload)
|
||||||
?? throw new InvalidOperationException("通知消息内容为空");
|
?? throw new InvalidOperationException("Notification payload is empty");
|
||||||
|
|
||||||
if (!await _db.UserNotifications.AsNoTracking()
|
var preference = await _db.NotificationPreferences.AsNoTracking()
|
||||||
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
|
.FirstOrDefaultAsync(x => x.UserId == task.UserId, ct);
|
||||||
|
var deliver = preference == null || ShouldDeliver(preference, message.Type, DateTime.UtcNow.AddHours(8));
|
||||||
|
|
||||||
|
if (deliver && !await _db.UserNotifications.AsNoTracking()
|
||||||
|
.AnyAsync(x => x.SourceId == task.SourceTaskId, ct))
|
||||||
{
|
{
|
||||||
await _db.UserNotifications.AddAsync(new UserNotification
|
await _db.UserNotifications.AddAsync(new UserNotification
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
UserId = candidate.UserId,
|
UserId = task.UserId,
|
||||||
SourceId = candidate.SourceTaskId,
|
SourceId = task.SourceTaskId,
|
||||||
Type = message.Type,
|
Type = message.Type,
|
||||||
Title = message.Title,
|
Title = message.Title,
|
||||||
Message = message.Message,
|
Message = message.Message,
|
||||||
Severity = message.Severity,
|
Severity = message.Severity,
|
||||||
ActionType = message.ActionType,
|
ActionType = message.ActionType,
|
||||||
ActionTargetId = message.ActionTargetId,
|
ActionTargetId = message.ActionTargetId,
|
||||||
CreatedAt = candidate.CreatedAt,
|
CreatedAt = task.CreatedAt,
|
||||||
}, ct);
|
}, ct);
|
||||||
// Persist the inbox item first. If completion fails, stale-task
|
|
||||||
// recovery can safely retry because SourceId is idempotent.
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
task.Status = "Completed";
|
||||||
.ExecuteUpdateAsync(setters => setters
|
task.LastError = null;
|
||||||
.SetProperty(x => x.Status, "Completed")
|
task.UpdatedAt = DateTime.UtcNow;
|
||||||
.SetProperty(x => x.LastError, (string?)null)
|
await _db.SaveChangesAsync(ct);
|
||||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var attempts = candidate.Attempts + 1;
|
task.Status = task.Attempts >= MaxAttempts ? "Failed" : "Pending";
|
||||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
|
||||||
.ExecuteUpdateAsync(setters => setters
|
task.LastError = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
|
||||||
.SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
|
task.UpdatedAt = DateTime.UtcNow;
|
||||||
.SetProperty(x => x.AvailableAt,
|
await _db.SaveChangesAsync(ct);
|
||||||
DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5))
|
|
||||||
.SetProperty(x => x.LastError,
|
|
||||||
ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message)
|
|
||||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool ShouldDeliver(NotificationPreference preference, string type, DateTime nowCst)
|
||||||
|
{
|
||||||
|
if (!preference.PushEnabled) return false;
|
||||||
|
|
||||||
|
var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute;
|
||||||
|
var inDnd = preference.DndEnabled && (preference.DndStartMinutes <= preference.DndEndMinutes
|
||||||
|
? minuteOfDay >= preference.DndStartMinutes && minuteOfDay < preference.DndEndMinutes
|
||||||
|
: minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes);
|
||||||
|
if (inDnd) return false;
|
||||||
|
|
||||||
|
return NormalizeType(type) switch
|
||||||
|
{
|
||||||
|
"medication_reminder" => preference.MedicationReminder,
|
||||||
|
"exercise_reminder" => true,
|
||||||
|
"follow_up_reminder" => preference.FollowUpReminder,
|
||||||
|
"doctor_reply" => preference.DoctorReply,
|
||||||
|
"abnormal_alert" => preference.AbnormalAlert,
|
||||||
|
"health_record_reminder" => preference.HealthRecordReminder,
|
||||||
|
"health_metric_alert" => preference.AbnormalAlert,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeType(string type) => type switch
|
||||||
|
{
|
||||||
|
"MedicationReminder" => "medication_reminder",
|
||||||
|
"ExerciseReminder" => "exercise_reminder",
|
||||||
|
"HealthMetricAlert" => "health_metric_alert",
|
||||||
|
"FollowUpReminder" => "follow_up_reminder",
|
||||||
|
_ => type.ToLowerInvariant(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
|
|
||||||
namespace Health.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Apple IdentityToken 验证:下载 Apple 公钥、验签、检查声明
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AppleTokenValidator
|
|
||||||
{
|
|
||||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
|
||||||
private const string AppleIssuer = "https://appleid.apple.com";
|
|
||||||
private static readonly HttpClient _http = new();
|
|
||||||
private static List<JsonWebKey>? _cachedKeys;
|
|
||||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
|
||||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
|
||||||
|
|
||||||
private readonly string _clientId;
|
|
||||||
|
|
||||||
public AppleTokenValidator(IConfiguration config)
|
|
||||||
{
|
|
||||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
|
||||||
/// </summary>
|
|
||||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var handler = new JwtSecurityTokenHandler();
|
|
||||||
// 先解码获取 kid
|
|
||||||
var rawToken = handler.ReadJwtToken(identityToken);
|
|
||||||
var kid = rawToken.Header.Kid;
|
|
||||||
|
|
||||||
var keys = await GetKeysAsync(ct);
|
|
||||||
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
|
||||||
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
|
||||||
|
|
||||||
var validationParams = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidIssuer = AppleIssuer,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
IssuerSigningKey = key,
|
|
||||||
ClockSkew = TimeSpan.FromMinutes(1),
|
|
||||||
};
|
|
||||||
|
|
||||||
handler.ValidateToken(identityToken, validationParams, out _);
|
|
||||||
// 从原始 JWT 取 sub(避免 .NET 的 ClaimType 映射把 "sub" 转成 NameIdentifier)
|
|
||||||
var sub = rawToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
|
||||||
if (string.IsNullOrWhiteSpace(sub))
|
|
||||||
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
|
||||||
|
|
||||||
return sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
|
||||||
|
|
||||||
await _lock.WaitAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
|
||||||
|
|
||||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
|
||||||
var jwks = new JsonWebKeySet(response);
|
|
||||||
_cachedKeys = jwks.Keys.ToList();
|
|
||||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
|
||||||
return _cachedKeys;
|
|
||||||
}
|
|
||||||
finally { _lock.Release(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,44 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
|||||||
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
|
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
|
||||||
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||||
|
|
||||||
|
public async Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var fileUrls = new List<string>();
|
||||||
|
|
||||||
|
var userAvatar = await _db.Users
|
||||||
|
.Where(u => u.Id == userId)
|
||||||
|
.Select(u => u.AvatarUrl)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, userAvatar);
|
||||||
|
|
||||||
|
var doctorProfile = await _db.DoctorProfiles
|
||||||
|
.Where(p => p.UserId == userId)
|
||||||
|
.Select(p => new { p.DoctorId, p.AvatarUrl })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, doctorProfile?.AvatarUrl);
|
||||||
|
|
||||||
|
if (doctorProfile?.DoctorId != null)
|
||||||
|
{
|
||||||
|
var doctorAvatar = await _db.Doctors
|
||||||
|
.Where(d => d.Id == doctorProfile.DoctorId)
|
||||||
|
.Select(d => d.AvatarUrl)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, doctorAvatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileUrls.AddRange(await _db.Reports
|
||||||
|
.Where(r => r.UserId == userId && r.FileUrl != "")
|
||||||
|
.Select(r => r.FileUrl)
|
||||||
|
.ToListAsync(ct));
|
||||||
|
|
||||||
|
var metadata = await _db.ConversationMessages
|
||||||
|
.Where(m => m.Conversation!.UserId == userId && m.MetadataJson != null && m.MetadataJson != "")
|
||||||
|
.Select(m => m.MetadataJson!)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new AccountFileReferences(fileUrls.Distinct().ToList(), metadata);
|
||||||
|
}
|
||||||
|
|
||||||
public Task SaveChangesAsync(CancellationToken ct) =>
|
public Task SaveChangesAsync(CancellationToken ct) =>
|
||||||
_db.SaveChangesAsync(ct);
|
_db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
@@ -50,4 +88,9 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
|||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AddIfPresent(List<string> values, string? value)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) values.Add(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||||
|
AddMetadataUrls(fileUrls, metadataJson);
|
||||||
|
|
||||||
|
foreach (var fileUrl in fileUrls)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var localPath = ResolveLocalPath(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? ResolveLocalPath(string fileUrl)
|
||||||
|
{
|
||||||
|
var urlPath = fileUrl;
|
||||||
|
if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri))
|
||||||
|
urlPath = absoluteUri.AbsolutePath;
|
||||||
|
|
||||||
|
var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (uploadsIndex < 0) return null;
|
||||||
|
|
||||||
|
string relativePath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]);
|
||||||
|
}
|
||||||
|
catch (UriFormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryIndex = relativePath.IndexOfAny(['?', '#']);
|
||||||
|
if (queryIndex >= 0) relativePath = relativePath[..queryIndex];
|
||||||
|
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
|
var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath));
|
||||||
|
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||||
|
+ Path.DirectorySeparatorChar;
|
||||||
|
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,107 +1,66 @@
|
|||||||
using Health.Application.Notifications;
|
using Health.Application.Notifications;
|
||||||
using Health.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Health.WebApi.BackgroundServices;
|
namespace Health.WebApi.BackgroundServices;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
|
/// Produces in-app catch-up reminders for health records.
|
||||||
/// 对未录入指标且开关开启的用户推送提醒。
|
/// The actual reminder rules live in IReminderCatchUpService so foreground
|
||||||
/// 中国时区 UTC+8。
|
/// app checks and background scans stay consistent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HealthRecordReminderService(
|
public sealed class HealthRecordReminderService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
private static readonly int[] ReminderHoursCst = [9, 13, 19];
|
||||||
private readonly ILogger<HealthRecordReminderService> _logger = logger;
|
|
||||||
|
|
||||||
// 北京时间 9:00 和 12:00 触发
|
|
||||||
private static readonly int[] ReminderHoursCst = [9, 12];
|
|
||||||
|
|
||||||
private DateTime _lastFired = DateTime.MinValue;
|
private DateTime _lastFired = DateTime.MinValue;
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("健康录入提醒服务已启动");
|
logger.LogInformation("Health record in-app reminder service started");
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
// 当前小时是否在触发列表,且今天该小时还没触发过
|
if (ReminderHoursCst.Contains(nowCst.Hour) &&
|
||||||
if (ReminderHoursCst.Contains(nowCst.Hour)
|
(_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||||
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
|
||||||
{
|
{
|
||||||
await ScanAndPushAsync(nowCst, stoppingToken);
|
await CheckUsersAsync(DateTime.UtcNow, stoppingToken);
|
||||||
_lastFired = nowCst;
|
_lastFired = nowCst;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Health record in-app reminder scan failed");
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
|
private async Task CheckUsersAsync(DateTime utcNow, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
var catchUp = scope.ServiceProvider.GetRequiredService<IReminderCatchUpService>();
|
||||||
|
|
||||||
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
|
var userIds = await db.NotificationPreferences.AsNoTracking()
|
||||||
var todayEnd = todayStart.AddDays(1);
|
.Where(x => x.PushEnabled && x.HealthRecordReminder)
|
||||||
|
.Select(x => x.UserId)
|
||||||
// 取所有启用了健康录入提醒的用户偏好
|
|
||||||
var prefs = await db.NotificationPreferences
|
|
||||||
.Where(p => p.HealthRecordReminder)
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
foreach (var pref in prefs)
|
var created = 0;
|
||||||
|
foreach (var userId in userIds)
|
||||||
{
|
{
|
||||||
// 该用户当天已录入的指标
|
var result = await catchUp.CheckDueAsync(userId, utcNow, ct);
|
||||||
var recordedTypes = await db.HealthRecords
|
created += result.CreatedCount;
|
||||||
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
|
|
||||||
.Select(r => r.MetricType)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
// 哪些指标启用开关但当天没录入
|
|
||||||
var missing = new List<(HealthMetricType type, bool enabled, string label)>
|
|
||||||
{
|
|
||||||
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
|
|
||||||
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
|
|
||||||
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
|
|
||||||
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
|
|
||||||
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
|
|
||||||
};
|
|
||||||
|
|
||||||
var missingLabels = missing
|
|
||||||
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
|
|
||||||
.Select(m => m.label)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (missingLabels.Count == 0) continue;
|
|
||||||
|
|
||||||
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
|
|
||||||
var sourceId = MakeSourceId(pref.UserId, nowCst);
|
|
||||||
var summary = string.Join("、", missingLabels);
|
|
||||||
|
|
||||||
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
|
|
||||||
Type: "health_record_reminder",
|
|
||||||
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
|
|
||||||
Message: $"建议录入:{summary}",
|
|
||||||
Severity: "info",
|
|
||||||
ActionType: "health",
|
|
||||||
ActionTargetId: null), ct);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
|
if (created > 0)
|
||||||
{
|
logger.LogInformation("Created {Count} in-app catch-up reminders", created);
|
||||||
// 通过哈希构造稳定的 Guid,保证同一用户同一时段不重复
|
|
||||||
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
|
|
||||||
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
|
||||||
return new Guid(bytes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,8 +321,12 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 一键清空当前用户的全部对话
|
// 一键清空当前用户的全部对话
|
||||||
@@ -335,6 +339,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,
|
||||||
@@ -623,7 +670,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);
|
||||||
@@ -818,3 +865,6 @@ public static class AiChatEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record DietCommentaryFoodRequest(string Name, string Portion, int Calories);
|
||||||
|
public sealed record DietCommentaryRequest(IReadOnlyList<DietCommentaryFoodRequest>? Foods);
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ public static class AuthEndpoints
|
|||||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
||||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
||||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
|
||||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
||||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
@@ -30,5 +28,4 @@ public static class AuthEndpoints
|
|||||||
public sealed record SendSmsRequest(string Phone);
|
public sealed record SendSmsRequest(string Phone);
|
||||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
|
||||||
public sealed record RefreshRequest(string RefreshToken);
|
public sealed record RefreshRequest(string RefreshToken);
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ public static class DietEndpoints
|
|||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await diets.DeleteAsync(userId, id, ct);
|
var deleted = await diets.DeleteAsync(userId, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "饮食记录不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ 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));
|
||||||
|
}
|
||||||
|
|
||||||
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 +70,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);
|
||||||
|
|
||||||
// 待办列表
|
// 待办列表
|
||||||
@@ -190,7 +203,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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -12,8 +12,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 +24,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)
|
||||||
@@ -48,7 +54,7 @@ public static class FileEndpoints
|
|||||||
id = fileId,
|
id = fileId,
|
||||||
name = file.FileName,
|
name = file.FileName,
|
||||||
size = file.Length,
|
size = file.Length,
|
||||||
url = $"/uploads/{storedName}",
|
url = $"/uploads/users/{userDirectoryName}/{storedName}",
|
||||||
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -56,4 +62,9 @@ public static class FileEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
|
||||||
|
? id
|
||||||
|
: Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ public static class HealthEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("/batch", async (
|
||||||
|
IReadOnlyList<CreateHealthRecordRequest> requests,
|
||||||
|
HttpContext http,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var ids = await healthRecords.CreateManyAsync(
|
||||||
|
userId,
|
||||||
|
requests.Select(request => request.ToServiceRequest()).ToList(),
|
||||||
|
ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { ids }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (
|
group.MapPut("/{id:guid}", async (
|
||||||
Guid id,
|
Guid id,
|
||||||
CreateHealthRecordRequest req,
|
CreateHealthRecordRequest req,
|
||||||
|
|||||||
@@ -36,8 +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) =>
|
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
@@ -75,8 +77,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 +114,19 @@ public static class MedicationEndpoints
|
|||||||
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
||||||
frequency = freq;
|
frequency = freq;
|
||||||
|
|
||||||
|
var hasEndDate = root.TryGetProperty("endDate", out var ed);
|
||||||
|
var clearEndDate = hasEndDate &&
|
||||||
|
(ed.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(ed.GetString()));
|
||||||
|
|
||||||
return new MedicationPatchRequest(
|
return new MedicationPatchRequest(
|
||||||
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
||||||
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||||
frequency,
|
frequency,
|
||||||
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
||||||
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
||||||
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
hasEndDate && !clearEndDate && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||||
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
|
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null,
|
||||||
|
clearEndDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ public static class NotificationEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("/check-due", async (HttpContext http, IReminderCatchUpService reminders, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||||
|
var result = await reminders.CheckDueAsync(userId, DateTime.UtcNow, ct);
|
||||||
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
@@ -90,6 +98,10 @@ public static class NotificationEndpoints
|
|||||||
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
||||||
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
||||||
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
||||||
|
if (body.PushEnabled.HasValue) pref.PushEnabled = body.PushEnabled.Value;
|
||||||
|
if (body.DndEnabled.HasValue) pref.DndEnabled = body.DndEnabled.Value;
|
||||||
|
if (body.DndStartMinutes is >= 0 and < 1440) pref.DndStartMinutes = body.DndStartMinutes.Value;
|
||||||
|
if (body.DndEndMinutes is >= 0 and < 1440) pref.DndEndMinutes = body.DndEndMinutes.Value;
|
||||||
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
||||||
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
||||||
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
||||||
@@ -100,7 +112,7 @@ public static class NotificationEndpoints
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
|
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
|
||||||
if (body.HealthRecordReminder is true && pref.HealthRecordReminder)
|
if (body.HealthRecordReminder is true && pref.PushEnabled && pref.HealthRecordReminder)
|
||||||
{
|
{
|
||||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
var todayStart = nowCst.Date.AddHours(-8);
|
var todayStart = nowCst.Date.AddHours(-8);
|
||||||
@@ -153,6 +165,10 @@ public static class NotificationEndpoints
|
|||||||
followUpReminder = pref.FollowUpReminder,
|
followUpReminder = pref.FollowUpReminder,
|
||||||
doctorReply = pref.DoctorReply,
|
doctorReply = pref.DoctorReply,
|
||||||
abnormalAlert = pref.AbnormalAlert,
|
abnormalAlert = pref.AbnormalAlert,
|
||||||
|
pushEnabled = pref.PushEnabled,
|
||||||
|
dndEnabled = pref.DndEnabled,
|
||||||
|
dndStartMinutes = pref.DndStartMinutes,
|
||||||
|
dndEndMinutes = pref.DndEndMinutes,
|
||||||
healthRecordReminder = pref.HealthRecordReminder,
|
healthRecordReminder = pref.HealthRecordReminder,
|
||||||
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
||||||
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
||||||
@@ -167,6 +183,10 @@ public sealed record NotificationPrefsUpdateDto(
|
|||||||
bool? FollowUpReminder,
|
bool? FollowUpReminder,
|
||||||
bool? DoctorReply,
|
bool? DoctorReply,
|
||||||
bool? AbnormalAlert,
|
bool? AbnormalAlert,
|
||||||
|
bool? PushEnabled,
|
||||||
|
bool? DndEnabled,
|
||||||
|
int? DndStartMinutes,
|
||||||
|
int? DndEndMinutes,
|
||||||
bool? HealthRecordReminder,
|
bool? HealthRecordReminder,
|
||||||
bool? HealthRecordReminderBloodPressure,
|
bool? HealthRecordReminderBloodPressure,
|
||||||
bool? HealthRecordReminderHeartRate,
|
bool? HealthRecordReminderHeartRate,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ builder.Services.AddAuthorization();
|
|||||||
// ---- 业务服务 ----
|
// ---- 业务服务 ----
|
||||||
builder.Services.AddSingleton<JwtProvider>();
|
builder.Services.AddSingleton<JwtProvider>();
|
||||||
builder.Services.AddSingleton<SmsService>();
|
builder.Services.AddSingleton<SmsService>();
|
||||||
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
|
||||||
builder.Services.AddSingleton<PromptManager>();
|
builder.Services.AddSingleton<PromptManager>();
|
||||||
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
||||||
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
||||||
@@ -143,6 +142,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 +150,8 @@ 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.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 =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Warning",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
120
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
120
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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 chatImagePath = Path.Combine(root, "owned-chat.jpg");
|
||||||
|
var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().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/owned-chat.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,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 +76,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 +132,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 });
|
||||||
@@ -154,7 +234,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 });
|
||||||
@@ -175,11 +259,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 +291,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 +302,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 +317,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);
|
||||||
|
|||||||
56
backend/tests/Health.Tests/diet_commentary_tests.cs
Normal file
56
backend/tests/Health.Tests/diet_commentary_tests.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using Health.Application.AI;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class DietCommentaryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FoodDescription_ContainsOnlyStructuredFoodData()
|
||||||
|
{
|
||||||
|
var result = DietCommentaryPolicy.BuildFoodDescription(
|
||||||
|
[
|
||||||
|
new DietCommentaryFood("番茄炒蛋", "半盘", 220),
|
||||||
|
new DietCommentaryFood("米饭", "一碗", 260),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal("番茄炒蛋(半盘,220千卡)、米饭(一碗,260千卡)", result);
|
||||||
|
Assert.DoesNotContain("请结合", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyArtifact_RequiresEveryUserMessageToUseInternalPrefix()
|
||||||
|
{
|
||||||
|
var artifact = new Conversation
|
||||||
|
{
|
||||||
|
Messages =
|
||||||
|
[
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
|
||||||
|
},
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.Assistant,
|
||||||
|
Content = "注意搭配蔬菜",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var normal = new Conversation
|
||||||
|
{
|
||||||
|
Messages =
|
||||||
|
[
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = "我想了解今天的饮食记录",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(DietCommentaryPolicy.IsLegacyArtifact(artifact.Messages));
|
||||||
|
Assert.False(DietCommentaryPolicy.IsLegacyArtifact(normal.Messages));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/tests/Health.Tests/doctor_endpoint_tests.cs
Normal file
17
backend/tests/Health.Tests/doctor_endpoint_tests.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class DoctorEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BeijingDayRange_MapsEarlyUtcTimeToTheCorrectLocalDate()
|
||||||
|
{
|
||||||
|
var utcNow = new DateTime(2026, 7, 13, 17, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var (startUtc, endUtc) = DoctorEndpoints.GetBeijingDayRange(utcNow);
|
||||||
|
|
||||||
|
Assert.Equal(new DateTime(2026, 7, 13, 16, 0, 0, DateTimeKind.Utc), startUtc);
|
||||||
|
Assert.Equal(new DateTime(2026, 7, 14, 16, 0, 0, DateTimeKind.Utc), endUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,6 +184,7 @@ public class EntityTests
|
|||||||
DietRestrictions = ["低盐", "低脂"],
|
DietRestrictions = ["低盐", "低脂"],
|
||||||
ChronicDiseases = ["高血压", "高血脂"],
|
ChronicDiseases = ["高血压", "高血脂"],
|
||||||
FamilyHistory = "父亲冠心病",
|
FamilyHistory = "父亲冠心病",
|
||||||
|
SurgeryHistoryStatus = "有",
|
||||||
};
|
};
|
||||||
db.HealthArchives.Add(archive);
|
db.HealthArchives.Add(archive);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -193,6 +194,7 @@ public class EntityTests
|
|||||||
Assert.Equal("冠心病", saved!.Diagnosis);
|
Assert.Equal("冠心病", saved!.Diagnosis);
|
||||||
Assert.Equal(2, saved.DietRestrictions.Count);
|
Assert.Equal(2, saved.DietRestrictions.Count);
|
||||||
Assert.Contains("低盐", saved.DietRestrictions);
|
Assert.Contains("低盐", saved.DietRestrictions);
|
||||||
|
Assert.Equal("有", saved.SurgeryHistoryStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
29
backend/tests/Health.Tests/medication_schedule_tests.cs
Normal file
29
backend/tests/Health.Tests/medication_schedule_tests.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Health.Application.Medications;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class MedicationScheduleTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FutureDoseOutsideReminderWindow_RemainsVisibleAsScheduled()
|
||||||
|
{
|
||||||
|
var status = MedicationScheduleStatus.Resolve(
|
||||||
|
new TimeOnly(18, 0),
|
||||||
|
new TimeOnly(8, 0),
|
||||||
|
logStatus: null);
|
||||||
|
|
||||||
|
Assert.Equal("scheduled", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoggedDose_UsesItsActualStatus()
|
||||||
|
{
|
||||||
|
var status = MedicationScheduleStatus.Resolve(
|
||||||
|
new TimeOnly(8, 0),
|
||||||
|
new TimeOnly(12, 0),
|
||||||
|
MedicationLogStatus.Taken);
|
||||||
|
|
||||||
|
Assert.Equal("taken", status);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/tests/Health.Tests/medication_update_tests.cs
Normal file
54
backend/tests/Health.Tests/medication_update_tests.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
using Health.Application.Medications;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeMedicationRepository(Medication medication) : IMedicationRepository
|
||||||
|
{
|
||||||
|
public bool Saved { get; private set; }
|
||||||
|
|
||||||
|
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<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> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId);
|
||||||
|
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(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) => Task.CompletedTask;
|
||||||
|
public void DeleteMedication(Medication value) { }
|
||||||
|
public void DeleteLog(MedicationLog log) { }
|
||||||
|
public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Health.Application.Medications;
|
using Health.Application.Medications;
|
||||||
|
using Health.Application.AI;
|
||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
using Health.Domain.Enums;
|
using Health.Domain.Enums;
|
||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
@@ -13,6 +14,50 @@ namespace Health.Tests;
|
|||||||
|
|
||||||
public sealed class PersistencePipelineTests
|
public sealed class PersistencePipelineTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ConversationCleanup_DeletesOnlyLegacyDietCommentaryArtifacts()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var artifact = new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AgentType = AgentType.Default,
|
||||||
|
};
|
||||||
|
artifact.Messages.Add(new ConversationMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = artifact.Id,
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
|
||||||
|
});
|
||||||
|
var normal = new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AgentType = AgentType.Default,
|
||||||
|
};
|
||||||
|
normal.Messages.Add(new ConversationMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = normal.Id,
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = "请分析我今天吃的米饭",
|
||||||
|
});
|
||||||
|
db.Conversations.AddRange(artifact, normal);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var repository = new EfAiConversationRepository(db);
|
||||||
|
|
||||||
|
var deleted = await repository.DeleteLegacyDietCommentaryArtifactsAsync(
|
||||||
|
userId,
|
||||||
|
DietCommentaryPolicy.LegacyPromptPrefix,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, deleted);
|
||||||
|
Assert.Equal(normal.Id, Assert.Single(db.Conversations).Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
|
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
|
||||||
{
|
{
|
||||||
@@ -168,6 +213,178 @@ public sealed class PersistencePipelineTests
|
|||||||
Assert.Empty(tasks);
|
Assert.Empty(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotificationOutbox_PushDisabledDoesNotCreateInAppNotification()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var sourceId = Guid.NewGuid();
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
PushEnabled = false,
|
||||||
|
MedicationReminder = true,
|
||||||
|
HealthRecordReminder = false,
|
||||||
|
});
|
||||||
|
db.NotificationOutbox.Add(new NotificationOutboxRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
SourceTaskId = sourceId,
|
||||||
|
Type = "MedicationReminder",
|
||||||
|
Payload = """
|
||||||
|
{"Type":"MedicationReminder","Title":"用药时间到了","Message":"请按计划服药","Severity":"info","ActionType":"medication","ActionTargetId":null}
|
||||||
|
""",
|
||||||
|
Status = "Pending",
|
||||||
|
AvailableAt = DateTime.UtcNow,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var processor = new EfNotificationOutboxProcessor(db);
|
||||||
|
|
||||||
|
await processor.ProcessNextAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(db.UserNotifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderCatchUp_PushDisabledDoesNotCreateHealthRecordReminder()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
PushEnabled = false,
|
||||||
|
HealthRecordReminder = true,
|
||||||
|
HealthRecordReminderBloodPressure = true,
|
||||||
|
HealthRecordReminderHeartRate = false,
|
||||||
|
HealthRecordReminderGlucose = false,
|
||||||
|
HealthRecordReminderSpO2 = false,
|
||||||
|
HealthRecordReminderWeight = false,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new EfReminderCatchUpService(db);
|
||||||
|
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc); // 13:00 Beijing
|
||||||
|
|
||||||
|
var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||||
|
var secondPass = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, created.CreatedCount);
|
||||||
|
Assert.Equal(0, secondPass.CreatedCount);
|
||||||
|
Assert.Empty(db.UserNotifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderCatchUp_PushEnabledCreatesOnlySelectedMissingMetric()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
PushEnabled = true,
|
||||||
|
HealthRecordReminder = true,
|
||||||
|
HealthRecordReminderBloodPressure = true,
|
||||||
|
HealthRecordReminderHeartRate = false,
|
||||||
|
HealthRecordReminderGlucose = false,
|
||||||
|
HealthRecordReminderSpO2 = false,
|
||||||
|
HealthRecordReminderWeight = false,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new EfReminderCatchUpService(db);
|
||||||
|
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.CreatedCount);
|
||||||
|
var notification = Assert.Single(db.UserNotifications);
|
||||||
|
Assert.Contains("血压", notification.Message);
|
||||||
|
Assert.DoesNotContain("心率", notification.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderCatchUp_DndStillPreventsReminder()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
PushEnabled = true,
|
||||||
|
HealthRecordReminder = true,
|
||||||
|
HealthRecordReminderBloodPressure = true,
|
||||||
|
DndEnabled = true,
|
||||||
|
DndStartMinutes = 12 * 60,
|
||||||
|
DndEndMinutes = 14 * 60,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new EfReminderCatchUpService(db);
|
||||||
|
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.CreatedCount);
|
||||||
|
Assert.Empty(db.UserNotifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderCatchUp_CreatesMedicationAndExerciseRemindersForDueItems()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 7, 13);
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
PushEnabled = true,
|
||||||
|
MedicationReminder = true,
|
||||||
|
HealthRecordReminder = false,
|
||||||
|
});
|
||||||
|
db.Medications.Add(new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
Name = "测试药物",
|
||||||
|
Dosage = "1片",
|
||||||
|
Frequency = MedicationFrequency.Daily,
|
||||||
|
TimeOfDay = [new TimeOnly(8, 0)],
|
||||||
|
StartDate = date,
|
||||||
|
IsActive = true,
|
||||||
|
});
|
||||||
|
var plan = new ExercisePlan
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
StartDate = date,
|
||||||
|
EndDate = date,
|
||||||
|
ReminderTime = new TimeOnly(9, 0),
|
||||||
|
};
|
||||||
|
plan.Items.Add(new ExercisePlanItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ScheduledDate = date,
|
||||||
|
ExerciseType = "散步",
|
||||||
|
DurationMinutes = 30,
|
||||||
|
});
|
||||||
|
db.ExercisePlans.Add(plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new EfReminderCatchUpService(db);
|
||||||
|
var nowUtc = new DateTime(2026, 7, 13, 2, 0, 0, DateTimeKind.Utc); // 10:00 Beijing
|
||||||
|
|
||||||
|
var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, created.CreatedCount);
|
||||||
|
Assert.Contains(db.UserNotifications, x => x.Type == "MedicationReminder");
|
||||||
|
Assert.Contains(db.UserNotifications, x => x.Type == "ExerciseReminder");
|
||||||
|
}
|
||||||
|
|
||||||
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
|
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
|||||||
18
backend/tests/Health.Tests/prompt_manager_tests.cs
Normal file
18
backend/tests/Health.Tests/prompt_manager_tests.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using Health.Infrastructure.AI;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class PromptManagerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UnifiedPrompt_DistinguishesQuestionsFromEntryRequests()
|
||||||
|
{
|
||||||
|
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
|
||||||
|
|
||||||
|
Assert.Contains("先判断用户是在咨询数值,还是希望记录数据", prompt);
|
||||||
|
Assert.Contains("血氧98%是不是太高了", prompt);
|
||||||
|
Assert.Contains("先回答问题,不调用 record_health_data", prompt);
|
||||||
|
Assert.Contains("帮我记录血压116/89", prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,688 +0,0 @@
|
|||||||
# Apple Sign-In 集成实施方案
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** 为健康管家 App 集成 Sign in with Apple,让 iOS 用户可以通过 Apple ID 一键登录/注册。
|
|
||||||
|
|
||||||
**Architecture:** 后端新增 `POST /api/auth/apple-login` 端点,验证 Apple 返回的 identityToken(JWT),查找或创建 User 记录(无需手机号),返回 JWT token。前端添加 `sign_in_with_apple` Flutter 包,在登录页放置 Apple 登录按钮。
|
|
||||||
|
|
||||||
**Tech Stack:** Flutter 3.44 + Dart 3.12 | C# .NET 10 + EF Core + PostgreSQL | sign_in_with_apple 包 | Apple RSA 公钥验证
|
|
||||||
|
|
||||||
**设计决策:**
|
|
||||||
- User.Phone 改为可空(Apple 用户无手机号),PostgreSQL 对多个 NULL 值不违反唯一索引
|
|
||||||
- 新增 `AppleUserId` 字段(可空字符串 + 唯一索引)标识 Apple 用户
|
|
||||||
- Apple 登录首次无需绑定医生(doctorId 可为空),用户可在个人中心后续设置
|
|
||||||
- Apple identityToken 验证:缓存 Apple 公钥,本地验签,验证 audience/issuer/有效期
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: 后端 — User 实体 + DB 迁移
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/src/Health.Domain/Entities/user.cs`
|
|
||||||
- Modify: `backend/src/Health.Infrastructure/Data/app_db_context.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: User 实体添加 AppleUserId 字段,Phone 改为可空**
|
|
||||||
|
|
||||||
`backend/src/Health.Domain/Entities/user.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace Health.Domain.Entities;
|
|
||||||
|
|
||||||
public sealed class User
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string? Phone { get; set; } // 改为可空,Apple 用户无手机号
|
|
||||||
public string? AppleUserId { get; set; } // Apple 唯一标识
|
|
||||||
public string Role { get; set; } = "User";
|
|
||||||
public Guid? DoctorId { get; set; }
|
|
||||||
public string? Name { get; set; }
|
|
||||||
// ... 其余字段不变
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: AppDbContext 添加 AppleUserId 配置**
|
|
||||||
|
|
||||||
`backend/src/Health.Infrastructure/Data/app_db_context.cs` 的 User 配置段:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.Entity<User>(e =>
|
|
||||||
{
|
|
||||||
e.HasIndex(u => u.Phone).IsUnique();
|
|
||||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // 新增
|
|
||||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: 后端 — Apple JWT 验证服务
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `backend/src/Health.Infrastructure/Services/apple_token_validator.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 创建 AppleTokenValidator**
|
|
||||||
|
|
||||||
`backend/src/Health.Infrastructure/Services/apple_token_validator.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
|
|
||||||
namespace Health.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Apple IdentityToken 验证:下载公钥、验签、检查声明
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AppleTokenValidator
|
|
||||||
{
|
|
||||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
|
||||||
private const string AppleIssuer = "https://appleid.apple.com";
|
|
||||||
private static readonly HttpClient _http = new();
|
|
||||||
private static List<JsonWebKey>? _cachedKeys;
|
|
||||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
|
||||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
|
||||||
|
|
||||||
private readonly string _clientId;
|
|
||||||
|
|
||||||
public AppleTokenValidator(IConfiguration config)
|
|
||||||
{
|
|
||||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
|
||||||
/// </summary>
|
|
||||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var handler = new JwtSecurityTokenHandler();
|
|
||||||
// 先解码 header 获取 kid
|
|
||||||
var rawToken = handler.ReadJwtToken(identityToken);
|
|
||||||
var kid = rawToken.Header.Kid;
|
|
||||||
|
|
||||||
var keys = await GetKeysAsync(ct);
|
|
||||||
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
|
||||||
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
|
||||||
|
|
||||||
var validationParams = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidIssuer = AppleIssuer,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
IssuerSigningKey = key,
|
|
||||||
ClockSkew = TimeSpan.FromMinutes(1),
|
|
||||||
};
|
|
||||||
|
|
||||||
var principal = handler.ValidateToken(identityToken, validationParams, out _);
|
|
||||||
var sub = principal.FindFirst("sub")?.Value;
|
|
||||||
if (string.IsNullOrWhiteSpace(sub))
|
|
||||||
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
|
||||||
|
|
||||||
return sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
|
||||||
|
|
||||||
await _lock.WaitAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
|
||||||
|
|
||||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
|
||||||
var jwks = new JsonWebKeySet(response);
|
|
||||||
_cachedKeys = jwks.Keys.ToList();
|
|
||||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
|
||||||
return _cachedKeys;
|
|
||||||
}
|
|
||||||
finally { _lock.Release(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 注册服务到 DI**
|
|
||||||
|
|
||||||
在 `backend/src/Health.WebApi/Program.cs` 的 services 注册区添加:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.Services.AddSingleton<AppleTokenValidator>();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: 后端 — AuthService 添加 Apple 登录
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/src/Health.Application/Auth/AuthContracts.cs`
|
|
||||||
- Modify: `backend/src/Health.Infrastructure/Auth/AuthService.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 AppleLoginCommand DTO 和接口方法**
|
|
||||||
|
|
||||||
`backend/src/Health.Application/Auth/AuthContracts.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace Health.Application.Auth;
|
|
||||||
|
|
||||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
|
||||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
|
||||||
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name); // 新增
|
|
||||||
|
|
||||||
public interface IAuthService
|
|
||||||
{
|
|
||||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
|
||||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
|
||||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
|
||||||
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct); // 新增
|
|
||||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
|
||||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: AuthService 实现 AppleLoginAsync**
|
|
||||||
|
|
||||||
`backend/src/Health.Infrastructure/Auth/AuthService.cs`:
|
|
||||||
|
|
||||||
在类的顶部依赖注入区添加 `AppleTokenValidator`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed class AuthService(
|
|
||||||
AppDbContext db,
|
|
||||||
JwtProvider jwt,
|
|
||||||
SmsService sms,
|
|
||||||
AppleTokenValidator appleValidator // 新增
|
|
||||||
) : IAuthService
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _db = db;
|
|
||||||
private readonly JwtProvider _jwt = jwt;
|
|
||||||
private readonly SmsService _sms = sms;
|
|
||||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
|
||||||
// ... 现有代码不变
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `LogoutAsync` 方法之后添加新方法,并修改 `AddTokens` 的 phone 参数类型为 `string?`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
|
||||||
{
|
|
||||||
// 1. 验证 Apple identityToken
|
|
||||||
string appleUserId;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 查找已有用户
|
|
||||||
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
|
||||||
if (existingUser != null)
|
|
||||||
{
|
|
||||||
// 已有用户 → 直接登录
|
|
||||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
|
||||||
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
|
||||||
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return new AuthResult(0, new
|
|
||||||
{
|
|
||||||
tokens.accessToken, tokens.refreshToken,
|
|
||||||
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
|
||||||
var newUser = new User
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Phone = null, // Apple 用户未绑定手机
|
|
||||||
AppleUserId = appleUserId,
|
|
||||||
Role = "User",
|
|
||||||
Name = command.Name,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
UpdatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
_db.Users.Add(newUser);
|
|
||||||
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
|
||||||
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
|
||||||
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return new AuthResult(0, new
|
|
||||||
{
|
|
||||||
newTokens.accessToken, newTokens.refreshToken,
|
|
||||||
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
同时修改 `AddTokens` 方法签名,phone 改为可空:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
|
||||||
{
|
|
||||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
|
||||||
var refresh = _jwt.GenerateRefreshToken();
|
|
||||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
|
||||||
return (access, refresh);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**说明**: `AddTokens` 的 phone 参数改为 `string?`,`GenerateAccessToken` 接收到空字符串(Apple 用户无需手机号)。`RefreshAsync` 和 `RegisterAsync` 中现有的 `AddTokens` 调用无需修改(传入 `string?` 的 `user.Phone` 自动匹配)。Admin 的 `AddTokens(AdminId, AdminPhone, "Admin")`(`AdminPhone` 是 `string` 常量)也无需修改。
|
|
||||||
|
|
||||||
- [ ] **Step 3: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: 后端 — 添加 Apple 登录端点
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 AppleLoginRequest 和端点**
|
|
||||||
|
|
||||||
`backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`:
|
|
||||||
|
|
||||||
在现有端点中添加:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 在 MapAuthEndpoints 方法内、logout 端点之后添加:
|
|
||||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
|
||||||
```
|
|
||||||
|
|
||||||
在文件末尾添加 DTO:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
|
||||||
```
|
|
||||||
|
|
||||||
文件最终结果为:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Health.Application.Auth;
|
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
|
||||||
|
|
||||||
public static class AuthEndpoints
|
|
||||||
{
|
|
||||||
public static void MapAuthEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
|
|
||||||
app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
|
||||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
|
||||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
|
||||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
|
||||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
await auth.LogoutAsync(request.RefreshToken, ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IResult ToResult(AuthResult result) =>
|
|
||||||
Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record SendSmsRequest(string Phone);
|
|
||||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
|
||||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
|
||||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
|
||||||
public sealed record RefreshRequest(string RefreshToken);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: 后端 — 重启并测试端点连通性
|
|
||||||
|
|
||||||
- [ ] **Step 1: 重启后端服务**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet run &
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 测试无 token 调用(预期返回 40005,token 无效)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -w "\nHTTP %{http_code}" -X POST "http://localhost:5000/api/auth/apple-login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"identityToken":"invalid","name":"test"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: HTTP 200, code=40005, message 包含"验证失败"
|
|
||||||
|
|
||||||
- [ ] **Step 3: 测试真实场景**
|
|
||||||
|
|
||||||
此时无法用 curl 直接测(需要真实的 Apple identityToken)。稍后在 Task 8 中从 App 端联合验证。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: 前端 — 添加 sign_in_with_apple 依赖
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/pubspec.yaml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加依赖**
|
|
||||||
|
|
||||||
`health_app/pubspec.yaml`,在 dependencies 区添加:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Apple 登录
|
|
||||||
sign_in_with_apple: ^6.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 安装依赖**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter pub get
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: 前端 — AuthProvider 添加 Apple 登录方法
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/providers/auth_provider.dart`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 appleLogin 方法**
|
|
||||||
|
|
||||||
`health_app/lib/providers/auth_provider.dart`,在 `login()` 方法之后、`logout()` 方法之前添加:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// Apple 登录
|
|
||||||
Future<String?> appleLogin(String identityToken, String? authorizationCode, String? name) async {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiClientProvider);
|
|
||||||
final response = await api.post(
|
|
||||||
'/api/auth/apple-login',
|
|
||||||
data: {
|
|
||||||
'identityToken': identityToken,
|
|
||||||
if (authorizationCode != null) 'authorizationCode': authorizationCode,
|
|
||||||
if (name != null && name.isNotEmpty) 'name': name,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
final data = response.data['data'];
|
|
||||||
if (data == null) return response.data['message'] ?? 'Apple 登录失败';
|
|
||||||
|
|
||||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
|
||||||
final user = data['user'];
|
|
||||||
state = AuthState(
|
|
||||||
isLoggedIn: true,
|
|
||||||
isLoading: false,
|
|
||||||
user: UserInfo(
|
|
||||||
id: user['id'] ?? '',
|
|
||||||
phone: user['phone'] ?? '',
|
|
||||||
role: user['role'] ?? 'User',
|
|
||||||
name: user['name'],
|
|
||||||
avatarUrl: user['avatarUrl'],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
return 'Apple 登录失败: $e';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**: `UserInfo` 的 `phone` 字段当前是 `required String`。Apple 用户可能无手机号。`user['phone']` 可能是 null。在 login() 方法中 Phone 断言 `phone: user['phone'] ?? ''` 已有兜底,appleLogin 保持一致。
|
|
||||||
|
|
||||||
- [ ] **Step 2: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter analyze lib/providers/auth_provider.dart
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: 前端 — 登录页添加 Apple 登录按钮
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/pages/auth/login_page.dart`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 Apple 登录按钮组件和点击逻辑**
|
|
||||||
|
|
||||||
`health_app/lib/pages/auth/login_page.dart`:
|
|
||||||
|
|
||||||
顶部添加 import:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `_LoginPageState` 类中添加 Apple 登录方法(在 `_startCountdown` 方法之后):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
Future<void> _appleSignIn() async {
|
|
||||||
if (_loading) return;
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
_error = null;
|
|
||||||
_successMsg = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final credential = await SignInWithApple.getAppleIDCredential(
|
|
||||||
scopes: [
|
|
||||||
AppleIDAuthorizationScopes.fullName,
|
|
||||||
],
|
|
||||||
webAuthenticationOptions: WebAuthenticationOptions(
|
|
||||||
clientId: 'com.datalumina.YYA.signin',
|
|
||||||
redirectUri: Uri.parse('https://erpapi.datalumina.cn/xiaomai/api/auth/apple-login'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final identityToken = credential.identityToken;
|
|
||||||
if (identityToken == null) {
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
_error = 'Apple 登录未返回身份信息,请重试';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final name = credential.givenName != null && credential.familyName != null
|
|
||||||
? '${credential.familyName}${credential.givenName}'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
final err = await ref.read(authProvider.notifier).appleLogin(
|
|
||||||
identityToken,
|
|
||||||
credential.authorizationCode,
|
|
||||||
name,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _loading = false);
|
|
||||||
if (err != null) {
|
|
||||||
setState(() => _error = err);
|
|
||||||
} else {
|
|
||||||
_goHome();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
if (e is SignInWithAppleAuthorizationException) {
|
|
||||||
_error = 'Apple 登录已取消';
|
|
||||||
} else {
|
|
||||||
_error = 'Apple 登录失败: $e';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `_LoginFormCard` 的 children 中,在 `_PrimaryButton` 和"去注册"链接之间(`_PrimaryButton` 之后,`const SizedBox(height: 16)` 之前)插入 Apple 按钮:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
// --- Apple 登录按钮 ---
|
|
||||||
SignInWithAppleButton(
|
|
||||||
onPressed: _appleSignIn,
|
|
||||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
|
||||||
height: 48,
|
|
||||||
cornerRadius: 14,
|
|
||||||
),
|
|
||||||
// --- 结束 ---
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
```
|
|
||||||
|
|
||||||
即在 build 方法中 `_LoginFormCard` child 的末尾(`_PrimaryButton` 之后、`GestureDetector` "去注册" 之前):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// ... 上面是 _PrimaryButton 的 const SizedBox(height: 22) 和 _PrimaryButton 本身
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
SignInWithAppleButton(
|
|
||||||
onPressed: _appleSignIn,
|
|
||||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
|
||||||
height: 48,
|
|
||||||
cornerRadius: 14,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => setState(() {
|
|
||||||
_isLogin = !_isLogin;
|
|
||||||
_error = null;
|
|
||||||
_successMsg = null;
|
|
||||||
}),
|
|
||||||
// ... 去注册/去登录 链接
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 1 补充**: `_submit()` 方法中也需要加 `_loading` 检查,确保 Apple 按钮点击期间短信登录不响应。现有 `_submit` 已有 `setState(() => _loading = true)`,问题不大。
|
|
||||||
|
|
||||||
- [ ] **Step 2: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter analyze lib/pages/auth/login_page.dart
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: 编译 iOS 验证无语法错误**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter build ios --debug --no-codesign
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: iOS — Xcode 配置 Sign in with Apple
|
|
||||||
|
|
||||||
- [ ] **Step 1: 在 Xcode 中启用 Sign in with Apple Capability**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
open health_app/ios/Runner.xcworkspace
|
|
||||||
```
|
|
||||||
|
|
||||||
在 Xcode 中操作:
|
|
||||||
1. 选择 Runner target → Signing & Capabilities
|
|
||||||
2. 点击 "+ Capability" → 选择 "Sign in with Apple"
|
|
||||||
3. 确认 .entitlements 文件自动生成了 `com.apple.developer.applesignin` entitlement
|
|
||||||
|
|
||||||
这也会自动在 `project.pbxproj` 中写入相关配置,后续 git diff 可以看到变化。
|
|
||||||
|
|
||||||
- [ ] **Step 2: 验证 .entitlements 文件**
|
|
||||||
|
|
||||||
确认 `health_app/ios/Runner/Runner.entitlements` 包含:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<key>com.apple.developer.applesignin</key>
|
|
||||||
<array>
|
|
||||||
<string>Default</string>
|
|
||||||
</array>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 10: App Store Connect 配置
|
|
||||||
|
|
||||||
- [ ] **Step 1: 在 App Store Connect 中启用 Sign in with Apple**
|
|
||||||
|
|
||||||
1. 打开 [App Store Connect](https://appstoreconnect.apple.com)
|
|
||||||
2. 进入 App 记录 → App 隐私 → Sign in with Apple
|
|
||||||
3. 确认已为 `com.datalumina.YYA` 这个 App ID 启用了 Sign in with Apple capability
|
|
||||||
|
|
||||||
如果还未创建 App Store Connect 上的 App 记录,需要在 Certificates, Identifiers & Profiles 中为 App ID 手动勾选 "Sign in with Apple" capability。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 验证计划
|
|
||||||
|
|
||||||
**后端单元验证:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 后端编译通过
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
|
|
||||||
# 2. 启动后端
|
|
||||||
cd backend/src/Health.WebApi && dotnet run
|
|
||||||
|
|
||||||
# 3. 测试 Apple 登录端点(无 token 场景)
|
|
||||||
curl -s -X POST "http://localhost:5000/api/auth/apple-login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"identityToken":"test","name":"测试"}'
|
|
||||||
# 预期: HTTP 200, body 包含 code=40005
|
|
||||||
|
|
||||||
# 4. 测试现有发送短信端点未被影响
|
|
||||||
curl -s -X POST "http://localhost:5000/api/auth/send-sms" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"phone":"13800138000"}'
|
|
||||||
# 预期: HTTP 200, code=0
|
|
||||||
```
|
|
||||||
|
|
||||||
**前端验证:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 编译检查
|
|
||||||
cd health_app && flutter analyze
|
|
||||||
|
|
||||||
# 2. iOS 构建通过
|
|
||||||
flutter build ios --debug --no-codesign
|
|
||||||
|
|
||||||
# 3. 在模拟器上运行
|
|
||||||
flutter run -d "iPhone 17 Pro"
|
|
||||||
# 验证:登录页显示 Apple 登录按钮;点击后弹出 Apple ID 授权界面
|
|
||||||
```
|
|
||||||
|
|
||||||
**端到端验证(模拟器):**
|
|
||||||
1. 模拟器上启动 App → 进入登录页
|
|
||||||
2. 点击 Apple 登录按钮 → Apple 授权弹窗出现 → 选择"继续"
|
|
||||||
3. 后端验证 identityToken → 创建新用户或登录已有 → 返回 JWT → App 进入首页
|
|
||||||
4. 二次打开 App → 如果之前 Apple 登录成功 + refresh_token 有效 → 自动恢复登录态
|
|
||||||
|
|
||||||
**DB 验证:**
|
|
||||||
```sql
|
|
||||||
-- 确认 Apple 用户创建成功
|
|
||||||
SELECT id, phone, apple_user_id, name, role FROM "Users" WHERE apple_user_id IS NOT NULL;
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Secondary Page Color Refresh Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Execute this plan inline in the current session. Do not create a branch or worktree.
|
||||||
|
|
||||||
|
**Goal:** Produce a restrained first-pass visual refresh for secondary pages while leaving the home page and all business behavior unchanged.
|
||||||
|
|
||||||
|
**Architecture:** Centralize a light neutral secondary-page canvas with a white AppBar in `GradientScaffold`, keep the existing home implementation untouched, and introduce one reusable outlined create FAB for the pages that currently expose add/upload actions. Fix swipe-delete rows at the shared widget layer so medication and exercise rows always paint an opaque white content surface over the red delete action.
|
||||||
|
|
||||||
|
**Tech Stack:** Flutter, Material 3, flutter_test.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Work on the existing `main` branch and preserve all unrelated dirty-worktree changes.
|
||||||
|
- Do not change backend APIs, providers, routing, or page business logic.
|
||||||
|
- Keep the current home page visual design unchanged.
|
||||||
|
- Secondary pages use a `#F7F8FA` canvas with white content surfaces; module colors remain content accents only.
|
||||||
|
- Reuse the existing `AppRadius` and `AppColors.actionOutlineGradient` tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Lock the shared secondary-page visual behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/test/swipe_delete_tile_test.dart`
|
||||||
|
- Create: `health_app/test/secondary_page_visuals_test.dart`
|
||||||
|
- Modify: `health_app/lib/core/app_theme.dart`
|
||||||
|
- Modify: `health_app/lib/widgets/common_widgets.dart`
|
||||||
|
|
||||||
|
- [ ] Add failing widget tests for an opaque swipe-row surface, a white `GradientScaffold`, and the shared outlined create FAB.
|
||||||
|
- [ ] Run the targeted tests and confirm they fail for the missing visual behavior.
|
||||||
|
- [ ] Make `GradientScaffold` and the global AppBar surface white without changing the home page.
|
||||||
|
- [ ] Add the reusable white, purple-gradient-outline create FAB.
|
||||||
|
- [ ] Paint swipe-row content white so red appears only in the revealed action area.
|
||||||
|
- [ ] Run the targeted tests and confirm they pass.
|
||||||
|
|
||||||
|
### Task 2: Adopt the common create action on active user pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/lib/pages/chart/trend_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/exercise/exercise_plan_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/report/report_pages.dart`
|
||||||
|
|
||||||
|
- [ ] Replace page-specific solid-color FAB styling with the shared create FAB.
|
||||||
|
- [ ] Preserve every existing callback, tooltip, route, and upload sheet.
|
||||||
|
- [ ] Format only the touched Dart files.
|
||||||
|
- [ ] Run the focused widget tests, the full Flutter test suite, and `flutter analyze`.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 通知设置改造设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让通知设置展示真实的服务端状态,保存失败时明确提示并恢复原值;将五个健康指标开关收纳到底部选择弹窗中,同时修复通知总开关未真正参与后端投递判断的问题。
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
主页面保持四个区域:通知总开关、通知类型、健康录入提醒、免打扰时段。
|
||||||
|
|
||||||
|
健康录入提醒区域只展示:
|
||||||
|
|
||||||
|
- “每日健康录入提醒”总开关。
|
||||||
|
- “提醒指标”入口,副标题展示当前已选指标摘要。
|
||||||
|
|
||||||
|
点击“提醒指标”后打开底部弹窗。弹窗使用复选框选择血压、心率、血糖、血氧和体重,至少保留一项,点击“保存”后一次性提交全部指标设置。
|
||||||
|
|
||||||
|
## 状态与保存
|
||||||
|
|
||||||
|
前端使用明确的通知偏好状态对象,不再直接使用无类型的 Map。状态区分首次加载、单项保存、指标批量保存和错误信息。
|
||||||
|
|
||||||
|
- 首次加载成功后才展示设置内容。
|
||||||
|
- 首次加载失败展示错误页和重试按钮,不使用默认值冒充服务端数据。
|
||||||
|
- 普通开关修改时只锁定当前操作,后端成功后采用响应中的完整偏好更新页面。
|
||||||
|
- 保存失败恢复原值并显示错误提示。
|
||||||
|
- 指标弹窗先维护本地草稿,点击保存时一次提交五个字段,避免连续请求和部分成功。
|
||||||
|
- 免打扰时间采用相同的真实保存与失败恢复规则。
|
||||||
|
|
||||||
|
## 后端行为
|
||||||
|
|
||||||
|
保留现有 `/api/notification-prefs` GET 和 PUT 接口。PUT 继续支持部分字段更新并返回保存后的完整偏好。
|
||||||
|
|
||||||
|
通知发送管线必须优先检查 `PushEnabled`。总开关关闭时,不生成或投递任何用户通知;健康录入、用药、随访、医生回复和异常提醒继续读取各自开关,免打扰规则继续生效。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- 网络或服务错误显示后端可读信息,无法解析时使用统一提示。
|
||||||
|
- 保存过程中禁止同一设置重复提交。
|
||||||
|
- 指标未选择任何一项时,弹窗内提示至少选择一项,不发送请求。
|
||||||
|
- 后端返回失败时不显示成功状态。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- Flutter 单元测试覆盖偏好解析、指标摘要、至少选择一项和失败回滚。
|
||||||
|
- Flutter 组件测试覆盖加载失败、指标弹窗批量选择和保存中禁用。
|
||||||
|
- 后端测试覆盖 `PushEnabled=false` 时所有通知类型均不投递,以及各分类开关和免打扰继续生效。
|
||||||
|
- 运行 `flutter analyze`、Flutter 全量测试和相关 .NET 测试。
|
||||||
79
docs/ui-design-system.md
Normal file
79
docs/ui-design-system.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 健康 App 前端 UI 设计系统
|
||||||
|
|
||||||
|
这份规范用于后续逐页精修。首页是品牌化例外,保持当前紫色背景和已有结构;以下规则主要约束普通二级页面。
|
||||||
|
|
||||||
|
## 1. 页面层级
|
||||||
|
|
||||||
|
- 标题栏:纯白背景,深色标题和返回图标。
|
||||||
|
- 页面画布:`#F7F8FA`,只负责建立内容边界,不作为卡片颜色。
|
||||||
|
- 主要内容组:纯白背景,区域间保留 12–16 px 的画布间距。
|
||||||
|
- 列表行:同一内容组内连续排列,用文字区域浅分隔线区分,不为每一行单独套卡片。
|
||||||
|
- 重点对象:报告、设备、AI 分析和关键概览可以使用独立白色卡片。
|
||||||
|
|
||||||
|
## 2. 颜色角色
|
||||||
|
|
||||||
|
- 品牌紫:通用选中、焦点、品牌按钮描边和少量重点操作。
|
||||||
|
- 模块色:只用于统一样式的图标、极浅图标底、图表曲线、小标签和状态识别。
|
||||||
|
- 中性色:页面画布、分隔线、正文层级和不可用状态。
|
||||||
|
- 语义色:绿色表示成功/正常,黄色表示提醒/等待,红色表示错误/危险,蓝色表示信息/进行中。
|
||||||
|
- 禁止模块色用于整页背景、大面积头部、大卡片底色和普通新增按钮。
|
||||||
|
|
||||||
|
## 3. 间距
|
||||||
|
|
||||||
|
- 页面左右边距:16 px。
|
||||||
|
- 页面顶部内容间距:12 px。
|
||||||
|
- 内容组间距:12–16 px。
|
||||||
|
- 列表图标与文字:12 px。
|
||||||
|
- 列表行垂直内边距:根据内容使用 12 或 16 px,不通过缩小字体制造紧凑感。
|
||||||
|
|
||||||
|
## 4. 圆角与阴影
|
||||||
|
|
||||||
|
- 所有圆角使用 `AppRadius`,不得在页面里随意新增数值。
|
||||||
|
- 连续内容组默认使用 16 px 圆角。
|
||||||
|
- 输入框和小型操作使用 10–14 px 圆角。
|
||||||
|
- 普通内容组默认无阴影、无描边。
|
||||||
|
- 浮动操作可以使用一层轻微中性阴影;同一组件不同时使用明显边框和重阴影。
|
||||||
|
|
||||||
|
## 5. 字体层级
|
||||||
|
|
||||||
|
- 页面标题:19–20 px,粗体,颜色 `textPrimary`。
|
||||||
|
- 区域标题:18 px,粗体。
|
||||||
|
- 列表主标题:16–17 px,中粗体。
|
||||||
|
- 正文和补充说明:13–15 px,保证对比度,不使用过浅小字。
|
||||||
|
- 数值、单位、份量和热量在同一行时保持基线、字号和字重协调。
|
||||||
|
- 选中状态不能改变文字大小、字重或位置。
|
||||||
|
|
||||||
|
## 6. 图标
|
||||||
|
|
||||||
|
- 全 App 优先使用同一套 Material 线性 `outlined/rounded` 图标,不混用大面积实心图标。
|
||||||
|
- 普通列表图标底座统一为 40×40 px、10 px 圆角,图标统一为 22 px。
|
||||||
|
- 不同模块可以改变图标和极浅底色,但尺寸、线条感觉、底座形状和对齐必须一致。
|
||||||
|
- 无底座的小型操作图标统一为 20–24 px,并保证至少 44 px 点击区域。
|
||||||
|
|
||||||
|
## 7. 按钮
|
||||||
|
|
||||||
|
- 通用新增:白底、紫色渐变细描边、深紫色线性加号;所有模块共用同一组件。
|
||||||
|
- 页面主要操作:优先使用白底紫色品牌描边;是否使用实心按钮由逐页任务重要性决定。
|
||||||
|
- 次要操作:白底或极浅灰底、深色文字。
|
||||||
|
- 危险操作:白底红字;只有确认删除阶段显示红色危险面。
|
||||||
|
- 新增、上传、绑定和记录属于通用操作,不跟随模块颜色。
|
||||||
|
|
||||||
|
## 8. 列表与滑动操作
|
||||||
|
|
||||||
|
- 列表行连续排列,分隔线从文字区域开始,不穿过左侧图标。
|
||||||
|
- 整行可点击,附属按钮不能缩小主点击区域。
|
||||||
|
- 删除背景默认完全隐藏,向左打开删除操作后才出现。
|
||||||
|
- 删除失败时保留真实列表数据,不提前移除前端项目。
|
||||||
|
|
||||||
|
## 9. 弹窗、表单和状态
|
||||||
|
|
||||||
|
- 弹窗、底部面板统一白色,不跟随模块色。
|
||||||
|
- 输入框焦点继续使用品牌紫,错误提示使用语义红。
|
||||||
|
- 空状态说明原因并提供一个明确下一步;加载、空、失败和未开放必须能被用户区分。
|
||||||
|
- 暂未启用的问诊和随访显示明确准备状态,不伪装成可用功能。
|
||||||
|
|
||||||
|
## 10. 逐页精修原则
|
||||||
|
|
||||||
|
- 每次只精修一个真实页面或一组完全同构页面。
|
||||||
|
- 先看真机截图,再确定该页的信息层级、内容组、操作和模块色用量。
|
||||||
|
- 不因建立设计系统而批量重排所有页面;公共组件只提供一致基础,页面布局仍按实际任务设计。
|
||||||
@@ -5,13 +5,6 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载正式签名配置
|
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
|
||||||
val keystoreProperties = java.util.Properties()
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.datalumina.YYA"
|
namespace = "com.datalumina.YYA"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -27,28 +20,21 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.datalumina.YYA"
|
applicationId = "com.datalumina.YYA"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
// 正式签名配置
|
|
||||||
signingConfigs {
|
|
||||||
create("release") {
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
|
||||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
|
||||||
storePassword = keystoreProperties["storePassword"] as String?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||||
<application
|
<application
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
IOS:
|
|
||||||
SHA1:
|
|
||||||
B1:17:1D:FD:7E:CA:CE:61:65:C2:2D:8B:A0:7C:57:5A:EB:EF:20:5E
|
|
||||||
SHA256:
|
|
||||||
D0:1E:AF:24:40:06:42:1B:FA:86:5E:3D:90:ED:FA:D7:3F:2D:E2:99:52:60:58:F4:85:4F:18:95:1E:B9:CC:96
|
|
||||||
MD5:
|
|
||||||
15:3B:89:ED:6B:C2:1B:6A:F9:6D:E0:71:44:54:CB:0F
|
|
||||||
|
|
||||||
Android:
|
|
||||||
SHA1: 48:48:22:D2:DA:B5:0D:4B:3F:D6:CA:8C:77:B1:36:B5:B5:8F:47:D1
|
|
||||||
SHA256: 0F:10:E7:B5:D0:8A:1E:4B:A6:67:EB:D4:D4:7F:00:C1:08:7E:EB:46:72:51:73:B2:AC:30:DB:69:18:DD:15:A2
|
|
||||||
MD5:D9:75:8F:17:66:17:3F:83:49:1A:82:34:59:9A:08:25
|
|
||||||
@@ -20,5 +20,7 @@
|
|||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
# Apple Sign-In requires CocoaPods (plugin not yet on SPM)
|
|
||||||
platform :ios, '13.0'
|
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
|
||||||
|
|
||||||
project 'Runner', {
|
|
||||||
'Debug' => :debug,
|
|
||||||
'Profile' => :release,
|
|
||||||
'Release' => :release,
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutter_root
|
|
||||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
|
||||||
unless File.exist?(generated_xcode_build_settings_path)
|
|
||||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
|
||||||
end
|
|
||||||
|
|
||||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
|
||||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
|
||||||
return matches[1].strip if matches
|
|
||||||
end
|
|
||||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
|
||||||
end
|
|
||||||
|
|
||||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
|
||||||
|
|
||||||
flutter_ios_podfile_setup
|
|
||||||
|
|
||||||
target 'Runner' do
|
|
||||||
use_frameworks!
|
|
||||||
|
|
||||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
|
||||||
target 'RunnerTests' do
|
|
||||||
inherit! :search_paths
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
post_install do |installer|
|
|
||||||
installer.pods_project.targets.each do |target|
|
|
||||||
flutter_additional_ios_build_settings(target)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
PODS:
|
|
||||||
- Flutter (1.0.0)
|
|
||||||
- sign_in_with_apple (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
|
||||||
- Flutter (from `Flutter`)
|
|
||||||
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
|
||||||
Flutter:
|
|
||||||
:path: Flutter
|
|
||||||
sign_in_with_apple:
|
|
||||||
:path: ".symlinks/plugins/sign_in_with_apple/ios"
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
|
||||||
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: 261798c42a06ff769f68b1209723cd96e5586217
|
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
|
||||||
@@ -11,12 +11,9 @@
|
|||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */; };
|
|
||||||
D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -45,18 +42,12 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
6331745AE8BC5EE4BCD290B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
65BAE750C7D318CC27513494 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -64,46 +55,19 @@
|
|||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
ECE998D63CAE2AC9FF401968 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
5B6028AD445E1E5F91C5C300 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
|
||||||
B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
04069A07808CEF12713E8A9C /* Pods */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
ECE998D63CAE2AC9FF401968 /* Pods-Runner.debug.xcconfig */,
|
|
||||||
6331745AE8BC5EE4BCD290B9 /* Pods-Runner.release.xcconfig */,
|
|
||||||
65BAE750C7D318CC27513494 /* Pods-Runner.profile.xcconfig */,
|
|
||||||
5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */,
|
|
||||||
93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */,
|
|
||||||
C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */,
|
|
||||||
);
|
|
||||||
name = Pods;
|
|
||||||
path = Pods;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -112,19 +76,9 @@
|
|||||||
path = RunnerTests;
|
path = RunnerTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
789F6ED1EF63DAEA6E803F1D /* Frameworks */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */,
|
|
||||||
C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */,
|
|
||||||
);
|
|
||||||
name = Frameworks;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
@@ -140,8 +94,6 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
04069A07808CEF12713E8A9C /* Pods */,
|
|
||||||
789F6ED1EF63DAEA6E803F1D /* Frameworks */,
|
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -176,10 +128,8 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */,
|
|
||||||
331C807D294A63A400263BE5 /* Sources */,
|
331C807D294A63A400263BE5 /* Sources */,
|
||||||
331C807F294A63A400263BE5 /* Resources */,
|
331C807F294A63A400263BE5 /* Resources */,
|
||||||
5B6028AD445E1E5F91C5C300 /* Frameworks */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -195,23 +145,18 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */,
|
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
97C146EC1CF9000F007C117D /* Resources */,
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
packageProductDependencies = (
|
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
|
||||||
);
|
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
@@ -245,9 +190,6 @@
|
|||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
packageReferences = (
|
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
|
||||||
);
|
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
@@ -280,28 +222,6 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
|
||||||
"${PODS_ROOT}/Manifest.lock",
|
|
||||||
);
|
|
||||||
name = "[CP] Check Pods Manifest.lock";
|
|
||||||
outputFileListPaths = (
|
|
||||||
);
|
|
||||||
outputPaths = (
|
|
||||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -333,45 +253,6 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
};
|
};
|
||||||
B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
|
||||||
outputFileListPaths = (
|
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
|
||||||
C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
|
||||||
"${PODS_ROOT}/Manifest.lock",
|
|
||||||
);
|
|
||||||
name = "[CP] Check Pods Manifest.lock";
|
|
||||||
outputFileListPaths = (
|
|
||||||
);
|
|
||||||
outputPaths = (
|
|
||||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -467,7 +348,8 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
@@ -479,16 +361,14 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -498,14 +378,13 @@
|
|||||||
};
|
};
|
||||||
331C8088294A63A400263BE5 /* Debug */ = {
|
331C8088294A63A400263BE5 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */;
|
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -516,14 +395,13 @@
|
|||||||
};
|
};
|
||||||
331C8089294A63A400263BE5 /* Release */ = {
|
331C8089294A63A400263BE5 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */;
|
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -532,14 +410,13 @@
|
|||||||
};
|
};
|
||||||
331C808A294A63A400263BE5 /* Profile */ = {
|
331C808A294A63A400263BE5 /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */;
|
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -598,6 +475,7 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -647,7 +525,8 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -661,16 +540,14 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -685,16 +562,14 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -736,20 +611,6 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
|
||||||
isa = XCLocalSwiftPackageReference;
|
|
||||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
|
||||||
};
|
|
||||||
/* End XCLocalSwiftPackageReference section */
|
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = FlutterGeneratedPluginSwiftPackage;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
|
||||||
};
|
};
|
||||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"pins" : [
|
|
||||||
{
|
|
||||||
"identity" : "dkcamera",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "master",
|
|
||||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "dkimagepickercontroller",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "4.3.9",
|
|
||||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "dkphotogallery",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "master",
|
|
||||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "sdwebimage",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
|
||||||
"version" : "5.21.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swiftygif",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
|
||||||
"version" : "5.4.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "tocropviewcontroller",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
|
||||||
"version" : "2.8.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version" : 2
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1510"
|
LastUpgradeVersion = "1510"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
<PreActions>
|
|
||||||
<ExecutionAction
|
|
||||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
|
||||||
<ActionContent
|
|
||||||
title = "Run Prepare Flutter Framework Script"
|
|
||||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
|
||||||
<EnvironmentBuildable>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
|
||||||
BuildableName = "Runner.app"
|
|
||||||
BlueprintName = "Runner"
|
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</EnvironmentBuildable>
|
|
||||||
</ActionContent>
|
|
||||||
</ExecutionAction>
|
|
||||||
</PreActions>
|
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -70,9 +52,9 @@
|
|||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = ""
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
|
|||||||
@@ -4,7 +4,4 @@
|
|||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "group:Runner.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
<FileRef
|
|
||||||
location = "group:Pods/Pods.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"pins" : [
|
|
||||||
{
|
|
||||||
"identity" : "dkcamera",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "master",
|
|
||||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "dkimagepickercontroller",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "4.3.9",
|
|
||||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "dkphotogallery",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "master",
|
|
||||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "sdwebimage",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
|
||||||
"version" : "5.21.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swiftygif",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
|
||||||
"version" : "5.4.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "tocropviewcontroller",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
|
||||||
"version" : "2.8.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version" : 2
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,12 @@ import Flutter
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>小脉健康</string>
|
<string>Health App</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -15,7 +13,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>小脉健康</string>
|
<string>health_app</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -26,39 +24,6 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
||||||
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
|
||||||
<string>需要使用蓝牙连接血压计</string>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
|
||||||
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
|
||||||
<string>需要保存图片到相册</string>
|
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>需要访问相册以选取饮食照片、体检报告或个人头像</string>
|
|
||||||
<key>UIApplicationSceneManifest</key>
|
|
||||||
<dict>
|
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
|
||||||
<false/>
|
|
||||||
<key>UISceneConfigurations</key>
|
|
||||||
<dict>
|
|
||||||
<key>UIWindowSceneSessionRoleApplication</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>UISceneClassName</key>
|
|
||||||
<string>UIWindowScene</string>
|
|
||||||
<key>UISceneConfigurationName</key>
|
|
||||||
<string>flutter</string>
|
|
||||||
<key>UISceneDelegateClassName</key>
|
|
||||||
<string>FlutterSceneDelegate</string>
|
|
||||||
<key>UISceneStoryboardFile</key>
|
|
||||||
<string>Main</string>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
@@ -76,5 +41,13 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
||||||
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
<string>需要使用蓝牙连接血压计</string>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.developer.applesignin</key>
|
|
||||||
<array>
|
|
||||||
<string>Default</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -48,6 +48,8 @@ final appReadyProvider = Provider<bool>((ref) {
|
|||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash
|
if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash
|
||||||
if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页
|
if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页
|
||||||
|
final currentRoute = ref.watch(routeStackProvider).last.name;
|
||||||
|
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
|
||||||
final role = auth.user?.role ?? 'User';
|
final role = auth.user?.role ?? 'User';
|
||||||
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
||||||
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
||||||
@@ -104,10 +106,15 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
final stack = ref.watch(routeStackProvider);
|
final stack = ref.watch(routeStackProvider);
|
||||||
final current = stack.last;
|
final current = stack.last;
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
final isPublicStaticText = current.name == 'staticText';
|
||||||
|
|
||||||
// 登录后自动跳转(在下一帧完成,无闪烁)
|
// 登录后自动跳转(在下一帧完成,无闪烁)
|
||||||
if (authState.isLoggedIn && current.name == 'login') {
|
if (authState.isLoggedIn && current.name == 'login') {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ref.invalidate(latestHealthProvider);
|
||||||
|
ref.invalidate(medicationListProvider);
|
||||||
|
ref.invalidate(medicationReminderProvider);
|
||||||
|
ref.invalidate(currentExercisePlanProvider);
|
||||||
final role = authState.user?.role ?? 'User';
|
final role = authState.user?.role ?? 'User';
|
||||||
if (role == 'Admin') {
|
if (role == 'Admin') {
|
||||||
goRoute(ref, 'adminHome');
|
goRoute(ref, 'adminHome');
|
||||||
@@ -120,7 +127,8 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (!authState.isLoading &&
|
if (!authState.isLoading &&
|
||||||
!authState.isLoggedIn &&
|
!authState.isLoggedIn &&
|
||||||
current.name != 'login') {
|
current.name != 'login' &&
|
||||||
|
!isPublicStaticText) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
goRoute(ref, 'login');
|
goRoute(ref, 'login');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import 'dart:io';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'local_database.dart';
|
import 'local_database.dart';
|
||||||
|
|
||||||
/// API 基础地址(生产模式)。本地开发可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||||
const String baseUrl = String.fromEnvironment(
|
const String baseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'https://erpapi.datalumina.cn/xiaomai',
|
defaultValue: 'http://10.4.237.12:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
|
|||||||
@@ -20,30 +20,30 @@ class AppColors {
|
|||||||
static const Color meadowAccent = Color(0xFFBAE6FD);
|
static const Color meadowAccent = Color(0xFFBAE6FD);
|
||||||
static const Color sageAccent = Color(0xFFE9D5FF);
|
static const Color sageAccent = Color(0xFFE9D5FF);
|
||||||
|
|
||||||
static const Color health = Color(0xFF3B82F6);
|
static const Color health = Color(0xFF34D399);
|
||||||
static const Color healthLight = Color(0xFFEFF6FF);
|
static const Color healthLight = Color(0xFFF0FDF7);
|
||||||
static const Color healthBorder = Color(0xFFBFDBFE);
|
static const Color healthBorder = Color(0xFFC8F7E1);
|
||||||
static const Color medication = Color(0xFF8B5CF6);
|
static const Color medication = Color(0xFF00B8D9);
|
||||||
static const Color medicationLight = Color(0xFFF5F3FF);
|
static const Color medicationLight = Color(0xFFEAFBFF);
|
||||||
static const Color medicationBorder = Color(0xFFDDD6FE);
|
static const Color medicationBorder = Color(0xFFB8F0FA);
|
||||||
static const Color exercise = Color(0xFF10B981);
|
static const Color exercise = Color(0xFF4F7DD9);
|
||||||
static const Color exerciseLight = Color(0xFFECFDF5);
|
static const Color exerciseLight = Color(0xFFEFF4FF);
|
||||||
static const Color exerciseBorder = Color(0xFFA7F3D0);
|
static const Color exerciseBorder = Color(0xFFD5E2FF);
|
||||||
static const Color report = Color(0xFF2563EB);
|
static const Color report = Color(0xFF6366F1);
|
||||||
static const Color reportLight = Color(0xFFEEF2FF);
|
static const Color reportLight = Color(0xFFEFFBFF);
|
||||||
static const Color reportBorder = Color(0xFFC7D2FE);
|
static const Color reportBorder = Color(0xFFBAE6FD);
|
||||||
static const Color diet = Color(0xFFF97316);
|
static const Color diet = Color(0xFFF7971E);
|
||||||
static const Color dietLight = Color(0xFFFFF7ED);
|
static const Color dietLight = Color(0xFFFFF8DB);
|
||||||
static const Color dietBorder = Color(0xFFFED7AA);
|
static const Color dietBorder = Color(0xFFFFE58A);
|
||||||
static const Color device = Color(0xFF06B6D4);
|
static const Color device = Color(0xFF2563EB);
|
||||||
static const Color deviceLight = Color(0xFFECFEFF);
|
static const Color deviceLight = Color(0xFFEFF6FF);
|
||||||
static const Color deviceBorder = Color(0xFFA5F3FC);
|
static const Color deviceBorder = Color(0xFFBFDBFE);
|
||||||
static const Color notification = Color(0xFF7C5CFF);
|
static const Color notification = Color(0xFF7C5CFF);
|
||||||
static const Color notificationLight = Color(0xFFF5F3FF);
|
static const Color notificationLight = Color(0xFFF5F3FF);
|
||||||
static const Color notificationBorder = Color(0xFFDDD6FE);
|
static const Color notificationBorder = Color(0xFFDDD6FE);
|
||||||
static const Color doctor = Color(0xFF0F766E);
|
static const Color doctor = Color(0xFFFFAFBD);
|
||||||
static const Color doctorLight = Color(0xFFF0FDFA);
|
static const Color doctorLight = Color(0xFFFFF3EF);
|
||||||
static const Color doctorBorder = Color(0xFF99F6E4);
|
static const Color doctorBorder = Color(0xFFFFD7CB);
|
||||||
static const Color calendar = Color(0xFF6366F1);
|
static const Color calendar = Color(0xFF6366F1);
|
||||||
static const Color calendarLight = Color(0xFFEEF2FF);
|
static const Color calendarLight = Color(0xFFEEF2FF);
|
||||||
static const Color calendarBorder = Color(0xFFC7D2FE);
|
static const Color calendarBorder = Color(0xFFC7D2FE);
|
||||||
@@ -51,7 +51,7 @@ class AppColors {
|
|||||||
static const Color followupLight = Color(0xFFFDF2F8);
|
static const Color followupLight = Color(0xFFFDF2F8);
|
||||||
static const Color followupBorder = Color(0xFFFBCFE8);
|
static const Color followupBorder = Color(0xFFFBCFE8);
|
||||||
|
|
||||||
static const Color background = Color(0xFFF8FAFF);
|
static const Color background = Color(0xFFF7F8FA);
|
||||||
static const Color backgroundSoft = Color(0xFFFFFFFF);
|
static const Color backgroundSoft = Color(0xFFFFFFFF);
|
||||||
static const Color cardBackground = Color(0xFFFFFFFF);
|
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||||
static const Color cardInner = Color(0xFFF1F5F9);
|
static const Color cardInner = Color(0xFFF1F5F9);
|
||||||
@@ -70,10 +70,13 @@ class AppColors {
|
|||||||
|
|
||||||
static const Color success = Color(0xFF16A34A);
|
static const Color success = Color(0xFF16A34A);
|
||||||
static const Color successLight = Color(0xFFEAF8EF);
|
static const Color successLight = Color(0xFFEAF8EF);
|
||||||
|
static const Color successText = Color(0xFF15803D);
|
||||||
static const Color error = Color(0xFFEF4444);
|
static const Color error = Color(0xFFEF4444);
|
||||||
static const Color errorLight = Color(0xFFFEECEF);
|
static const Color errorLight = Color(0xFFFEECEF);
|
||||||
|
static const Color errorText = Color(0xFFDC2626);
|
||||||
static const Color warning = Color(0xFFF59E0B);
|
static const Color warning = Color(0xFFF59E0B);
|
||||||
static const Color warningLight = Color(0xFFFFF7E6);
|
static const Color warningLight = Color(0xFFFFF7E6);
|
||||||
|
static const Color warningText = Color(0xFFB45309);
|
||||||
static const Color infoLight = Color(0xFFEFF5FF);
|
static const Color infoLight = Color(0xFFEFF5FF);
|
||||||
|
|
||||||
static const LinearGradient primaryGradient = LinearGradient(
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
@@ -157,45 +160,45 @@ class AppColors {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient doctorGradient = LinearGradient(
|
static const LinearGradient doctorGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.centerLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.centerRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
|
colors: [Color(0xFFFFC3A0), Color(0xFFFFAFBD)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient healthGradient = LinearGradient(
|
static const LinearGradient healthGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.centerLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.centerRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
colors: [Color(0xFF6EE7B7), Color(0xFF67E8F9)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient medicationGradient = LinearGradient(
|
static const LinearGradient medicationGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)],
|
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient exerciseGradient = LinearGradient(
|
static const LinearGradient exerciseGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF34D399), Color(0xFF059669)],
|
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient reportGradient = LinearGradient(
|
static const LinearGradient reportGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
colors: [Color(0xFF38BDF8), Color(0xFF818CF8)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient dietGradient = LinearGradient(
|
static const LinearGradient dietGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFFB923C), Color(0xFFF97316)],
|
colors: [Color(0xFFF7971E), Color(0xFFFFD200)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient deviceGradient = LinearGradient(
|
static const LinearGradient deviceGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF22D3EE), Color(0xFF0891B2)],
|
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient notificationGradient = LinearGradient(
|
static const LinearGradient notificationGradient = LinearGradient(
|
||||||
|
|||||||
@@ -168,4 +168,3 @@ class AppTextStyles {
|
|||||||
height: 1.25,
|
height: 1.25,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class AppModuleVisual {
|
|||||||
class AppModuleVisuals {
|
class AppModuleVisuals {
|
||||||
AppModuleVisuals._();
|
AppModuleVisuals._();
|
||||||
|
|
||||||
|
static const healthOverviewIcon = Icons.trending_up;
|
||||||
|
|
||||||
static const ai = AppModuleVisual(
|
static const ai = AppModuleVisual(
|
||||||
module: AppModule.ai,
|
module: AppModule.ai,
|
||||||
label: 'AI',
|
label: 'AI',
|
||||||
@@ -73,7 +75,7 @@ class AppModuleVisuals {
|
|||||||
static const exercise = AppModuleVisual(
|
static const exercise = AppModuleVisual(
|
||||||
module: AppModule.exercise,
|
module: AppModule.exercise,
|
||||||
label: '运动',
|
label: '运动',
|
||||||
icon: LucideIcons.footprints,
|
icon: Icons.directions_run_outlined,
|
||||||
color: AppColors.exercise,
|
color: AppColors.exercise,
|
||||||
lightColor: AppColors.exerciseLight,
|
lightColor: AppColors.exerciseLight,
|
||||||
borderColor: AppColors.exerciseBorder,
|
borderColor: AppColors.exerciseBorder,
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import '../pages/settings/notification_prefs_page.dart';
|
|||||||
import '../pages/notifications/notification_center_page.dart';
|
import '../pages/notifications/notification_center_page.dart';
|
||||||
import '../pages/history/conversation_history_page.dart';
|
import '../pages/history/conversation_history_page.dart';
|
||||||
import '../pages/profile/profile_page.dart';
|
import '../pages/profile/profile_page.dart';
|
||||||
|
import '../pages/profile/profile_edit_page.dart';
|
||||||
import '../pages/diet/diet_capture_page.dart';
|
import '../pages/diet/diet_capture_page.dart';
|
||||||
|
import '../pages/exercise/exercise_plan_page.dart';
|
||||||
import '../pages/device/device_scan_page.dart';
|
import '../pages/device/device_scan_page.dart';
|
||||||
import '../pages/device/device_management_page.dart';
|
import '../pages/device/device_management_page.dart';
|
||||||
import '../pages/remaining_pages.dart';
|
import '../pages/remaining_pages.dart';
|
||||||
@@ -67,9 +69,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
case 'medCheckIn':
|
case 'medCheckIn':
|
||||||
return MedicationCheckInPage(medId: params['id']);
|
return MedicationCheckInPage(medId: params['id']);
|
||||||
case 'medicationEdit':
|
case 'medicationEdit':
|
||||||
return const MedicationEditPage();
|
return MedicationEditPage(id: params['id']);
|
||||||
case 'reports':
|
case 'reports':
|
||||||
return const ReportListPage();
|
return ReportListPage(openUploadOnEnter: params['openUpload'] == 'true');
|
||||||
case 'aiAnalysis':
|
case 'aiAnalysis':
|
||||||
final id = _requiredParam(params, 'id');
|
final id = _requiredParam(params, 'id');
|
||||||
return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
|
return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
|
||||||
@@ -77,11 +79,12 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
return ReportOriginalPage(
|
return ReportOriginalPage(
|
||||||
url: params['url'] ?? '',
|
url: params['url'] ?? '',
|
||||||
title: params['title'] ?? '原始报告',
|
title: params['title'] ?? '原始报告',
|
||||||
|
fileType: params['fileType'] ?? 'Image',
|
||||||
);
|
);
|
||||||
case 'exercisePlan':
|
case 'exercisePlan':
|
||||||
return const ExercisePlanPage();
|
return const EnterpriseExercisePlanPage();
|
||||||
case 'exercisePlanDetail':
|
case 'exercisePlanDetail':
|
||||||
return const ExercisePlanPage();
|
return const EnterpriseExercisePlanPage();
|
||||||
case 'exerciseCreate':
|
case 'exerciseCreate':
|
||||||
return const ExercisePlanCreatePage();
|
return const ExercisePlanCreatePage();
|
||||||
case 'dietRecords':
|
case 'dietRecords':
|
||||||
@@ -90,6 +93,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
return const DietCapturePage();
|
return const DietCapturePage();
|
||||||
case 'profile':
|
case 'profile':
|
||||||
return const ProfilePage();
|
return const ProfilePage();
|
||||||
|
case 'profileEdit':
|
||||||
|
return const ProfileEditPage();
|
||||||
case 'doctorProfile':
|
case 'doctorProfile':
|
||||||
return const DoctorProfileEditPage();
|
return const DoctorProfileEditPage();
|
||||||
case 'doctorSettings':
|
case 'doctorSettings':
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
final monthCtrl = FixedExtentScrollController(initialItem: m - 1);
|
final monthCtrl = FixedExtentScrollController(initialItem: m - 1);
|
||||||
final dayCtrl = FixedExtentScrollController(initialItem: d - 1);
|
final dayCtrl = FixedExtentScrollController(initialItem: d - 1);
|
||||||
|
|
||||||
int daysInMonth(int year, int month) =>
|
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
|
||||||
DateTime(year, month + 1, 0).day;
|
|
||||||
|
|
||||||
void clampDay() {
|
void clampDay() {
|
||||||
final maxDay = daysInMonth(y, m);
|
final maxDay = daysInMonth(y, m);
|
||||||
@@ -70,12 +69,22 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
children: [
|
children: [
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
|
child: const Text(
|
||||||
|
'取消',
|
||||||
|
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
),
|
),
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
child: const Text(
|
||||||
|
'确定',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
clampDay();
|
clampDay();
|
||||||
Navigator.pop(ctx, DateTime(y, m, d));
|
Navigator.pop(ctx, DateTime(y, m, d));
|
||||||
@@ -87,7 +96,9 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: yearCtrl,
|
scrollController: yearCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
@@ -97,9 +108,16 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
final maxDay = daysInMonth(y, m);
|
final maxDay = daysInMonth(y, m);
|
||||||
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
||||||
},
|
},
|
||||||
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = minDate.year; i <= maxDate.year; i++)
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Center(child: Text('$i', style: _pickerItemStyle)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: monthCtrl,
|
scrollController: monthCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
@@ -109,15 +127,37 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
final maxDay = daysInMonth(y, m);
|
final maxDay = daysInMonth(y, m);
|
||||||
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
||||||
},
|
},
|
||||||
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 1; i <= 12; i++)
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: dayCtrl,
|
scrollController: dayCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => d = i + 1,
|
onSelectedItemChanged: (i) => d = i + 1,
|
||||||
children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 1; i <= daysInMonth(y, m); i++)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -153,13 +193,24 @@ Future<TimeOfDay?> showAppTimePicker(
|
|||||||
children: [
|
children: [
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
|
child: const Text(
|
||||||
|
'取消',
|
||||||
|
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
),
|
),
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
child: const Text(
|
||||||
onPressed: () => Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)),
|
'确定',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -168,20 +219,46 @@ Future<TimeOfDay?> showAppTimePicker(
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(flex: 1),
|
const Spacer(flex: 1),
|
||||||
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: hourCtrl,
|
scrollController: hourCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => hour = i,
|
onSelectedItemChanged: (i) => hour = i,
|
||||||
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 0; i <= 23; i++)
|
||||||
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: minuteCtrl,
|
scrollController: minuteCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => minute = i,
|
onSelectedItemChanged: (i) => minute = i,
|
||||||
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 0; i <= 59; i++)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const Spacer(flex: 1),
|
const Spacer(flex: 1),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -211,8 +288,7 @@ Future<DateTime?> showAppDateTimePicker(
|
|||||||
final hourCtrl = FixedExtentScrollController(initialItem: h);
|
final hourCtrl = FixedExtentScrollController(initialItem: h);
|
||||||
final minuteCtrl = FixedExtentScrollController(initialItem: min);
|
final minuteCtrl = FixedExtentScrollController(initialItem: min);
|
||||||
|
|
||||||
int daysInMonth(int year, int month) =>
|
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
|
||||||
DateTime(year, month + 1, 0).day;
|
|
||||||
|
|
||||||
void clampDay() {
|
void clampDay() {
|
||||||
final maxDay = daysInMonth(y, m);
|
final maxDay = daysInMonth(y, m);
|
||||||
@@ -236,12 +312,22 @@ Future<DateTime?> showAppDateTimePicker(
|
|||||||
children: [
|
children: [
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
|
child: const Text(
|
||||||
|
'取消',
|
||||||
|
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
),
|
),
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
child: const Text(
|
||||||
|
'确定',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
clampDay();
|
clampDay();
|
||||||
Navigator.pop(ctx, DateTime(y, m, d, h, min));
|
Navigator.pop(ctx, DateTime(y, m, d, h, min));
|
||||||
@@ -253,41 +339,102 @@ Future<DateTime?> showAppDateTimePicker(
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: yearCtrl,
|
scrollController: yearCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) { y = minDate.year + i; clampDay(); },
|
onSelectedItemChanged: (i) {
|
||||||
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
|
y = minDate.year + i;
|
||||||
))),
|
clampDay();
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
},
|
||||||
|
children: [
|
||||||
|
for (var i = minDate.year; i <= maxDate.year; i++)
|
||||||
|
Center(child: Text('$i', style: _pickerItemStyle)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: monthCtrl,
|
scrollController: monthCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) { m = i + 1; clampDay(); },
|
onSelectedItemChanged: (i) {
|
||||||
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
m = i + 1;
|
||||||
))),
|
clampDay();
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
},
|
||||||
|
children: [
|
||||||
|
for (var i = 1; i <= 12; i++)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: dayCtrl,
|
scrollController: dayCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => d = i + 1,
|
onSelectedItemChanged: (i) => d = i + 1,
|
||||||
children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 1; i <= daysInMonth(y, m); i++)
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: hourCtrl,
|
scrollController: hourCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => h = i,
|
onSelectedItemChanged: (i) => h = i,
|
||||||
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 0; i <= 23; i++)
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: minuteCtrl,
|
scrollController: minuteCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => min = i,
|
onSelectedItemChanged: (i) => min = i,
|
||||||
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 0; i <= 59; i++)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -322,28 +469,47 @@ Future<int?> showAppCountPicker(
|
|||||||
children: [
|
children: [
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
|
child: const Text(
|
||||||
|
'取消',
|
||||||
|
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
),
|
),
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
child: const Text(
|
||||||
|
'确定',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(ctx, selected),
|
onPressed: () => Navigator.pop(ctx, selected),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _wrapPicker(CupertinoPicker(
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: FixedExtentScrollController(initialItem: selected - min),
|
scrollController: FixedExtentScrollController(
|
||||||
|
initialItem: selected - min,
|
||||||
|
),
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => selected = i + min,
|
onSelectedItemChanged: (i) => selected = i + min,
|
||||||
children: [
|
children: [
|
||||||
for (var i = min; i <= max; i++)
|
for (var i = min; i <= max; i++)
|
||||||
Center(child: Text(label.isEmpty ? '$i' : '$i$label', style: _pickerItemStyle)),
|
Center(
|
||||||
|
child: Text(
|
||||||
|
label.isEmpty ? '$i' : '$i$label',
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -362,7 +528,16 @@ class AppBackground extends StatelessWidget {
|
|||||||
final content = safeArea ? SafeArea(child: child) : child;
|
final content = safeArea ? SafeArea(child: child) : child;
|
||||||
|
|
||||||
// 全局浅灰白背景,已弃用紫色渐变。
|
// 全局浅灰白背景,已弃用紫色渐变。
|
||||||
return ColoredBox(color: const Color(0xFFF6F8FC), child: content);
|
return DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,10 +561,13 @@ class GradientScaffold extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
// 内部页面统一使用浅灰白底,对话流首页因直接使用 AppBackground 仍保留紫色渐变。
|
// 二级页面用浅中性画布承托白色内容组;首页拥有自己的独立背景实现。
|
||||||
backgroundColor: const Color(0xFFF6F8FC),
|
backgroundColor: AppColors.background,
|
||||||
appBar: appBar,
|
appBar: appBar,
|
||||||
body: body,
|
body: ColoredBox(
|
||||||
|
color: AppColors.background,
|
||||||
|
child: SizedBox.expand(child: body ?? const SizedBox.shrink()),
|
||||||
|
),
|
||||||
floatingActionButton: floatingActionButton,
|
floatingActionButton: floatingActionButton,
|
||||||
drawer: drawer,
|
drawer: drawer,
|
||||||
bottomNavigationBar: bottomNavigationBar,
|
bottomNavigationBar: bottomNavigationBar,
|
||||||
@@ -485,22 +663,22 @@ class AppTheme {
|
|||||||
surface: surface,
|
surface: surface,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: Colors.transparent,
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
splashColor: primary.withValues(alpha: 0.06),
|
splashColor: primary.withValues(alpha: 0.06),
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
hoverColor: primaryLight.withValues(alpha: 0.55),
|
hoverColor: primaryLight.withValues(alpha: 0.55),
|
||||||
fontFamily: null,
|
fontFamily: null,
|
||||||
|
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
backgroundColor: Colors.white,
|
||||||
foregroundColor: text,
|
foregroundColor: text,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
titleTextStyle: const TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
fontSize: 17,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
color: text,
|
color: text,
|
||||||
),
|
),
|
||||||
toolbarHeight: 52,
|
toolbarHeight: 52,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user