Compare commits
3 Commits
sccsbc
...
fade61ac21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fade61ac21 | ||
|
|
e654c1e0cc | ||
|
|
335a3e6440 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,10 +4,6 @@
|
||||
*.key
|
||||
*.pfx
|
||||
|
||||
# Android release keystore
|
||||
health_app/android/**/*.jks
|
||||
health_app/android/key.properties
|
||||
|
||||
# .NET build outputs
|
||||
backend/**/bin/
|
||||
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<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, 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);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ public interface IAiConversationRepository
|
||||
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
||||
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
||||
Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct);
|
||||
void Delete(Conversation conversation);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Health.Application.AI;
|
||||
|
||||
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
||||
{
|
||||
private const int HistoryConversationLimit = 7;
|
||||
private const int HistoryConversationLimit = 30;
|
||||
private readonly IAiConversationRepository _conversations = conversations;
|
||||
|
||||
public async Task<OpenConversationResult> OpenAsync(
|
||||
@@ -55,6 +55,10 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
||||
|
||||
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
await _conversations.DeleteLegacyDietCommentaryArtifactsAsync(
|
||||
userId,
|
||||
DietCommentaryPolicy.LegacyPromptPrefix,
|
||||
ct);
|
||||
var conversations = await _conversations.ListAsync(userId, ct);
|
||||
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
||||
}
|
||||
@@ -79,13 +83,14 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||
{
|
||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||
if (conversation == null) return;
|
||||
if (conversation == null) return false;
|
||||
|
||||
_conversations.Delete(conversation);
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<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 (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||
if (archive.Surgeries.Count > 0)
|
||||
if (archive.SurgeryHistoryStatus == "无")
|
||||
sb.AppendLine("手术史: 无");
|
||||
else if (archive.Surgeries.Count > 0)
|
||||
sb.AppendLine($"手术: {string.Join(";", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
|
||||
else if (!string.IsNullOrEmpty(archive.SurgeryType))
|
||||
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||
|
||||
@@ -2,14 +2,12 @@ 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);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed record HealthArchiveDto(
|
||||
IReadOnlyList<string> DietRestrictions,
|
||||
IReadOnlyList<string> ChronicDiseases,
|
||||
string? FamilyHistory,
|
||||
string? SurgeryHistoryStatus,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record HealthArchiveUpdateRequest(
|
||||
@@ -32,7 +33,8 @@ public sealed record HealthArchiveUpdateRequest(
|
||||
IReadOnlyList<string>? DietRestrictions,
|
||||
IReadOnlyList<string>? ChronicDiseases,
|
||||
string? FamilyHistory,
|
||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
|
||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null,
|
||||
string? SurgeryHistoryStatus = null);
|
||||
|
||||
public interface IHealthArchiveService
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Health.Application.HealthArchives;
|
||||
|
||||
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
||||
@@ -14,6 +16,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
|
||||
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
Validate(request);
|
||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||
Apply(archive, request);
|
||||
archive.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -110,6 +113,14 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
||||
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
||||
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
|
||||
if (request.SurgeryHistoryStatus != null)
|
||||
archive.SurgeryHistoryStatus = request.SurgeryHistoryStatus;
|
||||
}
|
||||
|
||||
private static void Validate(HealthArchiveUpdateRequest request)
|
||||
{
|
||||
if (request.SurgeryHistoryStatus is not null and not ("无" or "有"))
|
||||
throw new ValidationException("手术史状态格式不正确");
|
||||
}
|
||||
|
||||
private static object ToAiResult(HealthArchiveDto archive) => new
|
||||
@@ -123,6 +134,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
archive.SurgeryHistoryStatus,
|
||||
};
|
||||
|
||||
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
|
||||
@@ -136,6 +148,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
archive.SurgeryHistoryStatus,
|
||||
archive.UpdatedAt);
|
||||
|
||||
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
||||
|
||||
@@ -27,6 +27,7 @@ public interface IHealthRecordService
|
||||
{
|
||||
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||
Task<IReadOnlyList<Guid>> CreateManyAsync(Guid userId, IReadOnlyList<HealthRecordUpsertRequest> requests, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
||||
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
|
||||
@@ -9,7 +9,7 @@ public static class HealthRecordRules
|
||||
{
|
||||
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
||||
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
||||
HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m,
|
||||
HealthMetricType.Glucose => request.Value > 6.1m || request.Value < 3.9m,
|
||||
HealthMetricType.SpO2 => request.Value <= 94,
|
||||
HealthMetricType.Weight => false,
|
||||
_ => false
|
||||
@@ -58,4 +58,3 @@ public static class HealthRecordRules
|
||||
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,44 @@ public sealed class HealthRecordService(
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Guid>> CreateManyAsync(
|
||||
Guid userId,
|
||||
IReadOnlyList<HealthRecordUpsertRequest> requests,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (requests.Count == 0)
|
||||
throw new ArgumentException("至少需要一条健康记录", nameof(requests));
|
||||
|
||||
foreach (var request in requests)
|
||||
HealthRecordRules.Validate(request);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var records = requests.Select(request => new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MetricType = request.Type,
|
||||
Systolic = request.Systolic,
|
||||
Diastolic = request.Diastolic,
|
||||
Value = request.Value,
|
||||
Unit = request.Unit,
|
||||
Source = request.Source,
|
||||
RecordedAt = request.RecordedAt ?? now,
|
||||
CreatedAt = now,
|
||||
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
|
||||
}).ToList();
|
||||
|
||||
foreach (var record in records)
|
||||
await _records.AddAsync(record, ct);
|
||||
|
||||
await _records.SaveChangesAsync(ct);
|
||||
|
||||
foreach (var record in records)
|
||||
await EnqueueAbnormalNotificationAsync(record, ct);
|
||||
|
||||
return records.Select(record => record.Id).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||
@@ -101,6 +139,7 @@ public sealed class HealthRecordService(
|
||||
latest.Diastolic,
|
||||
latest.Value,
|
||||
latest.Unit,
|
||||
latest.IsAbnormal,
|
||||
latest.RecordedAt
|
||||
};
|
||||
}
|
||||
@@ -140,7 +179,7 @@ public sealed class HealthRecordService(
|
||||
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
||||
HealthMetricType.HeartRate =>
|
||||
("心率偏低提醒", $"本次心率为 {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"),
|
||||
HealthMetricType.Glucose =>
|
||||
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
||||
|
||||
@@ -20,7 +20,8 @@ public sealed record MedicationPatchRequest(
|
||||
IReadOnlyList<TimeOnly>? TimeOfDay,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
bool ClearEndDate = false);
|
||||
|
||||
public sealed record MedicationDto(
|
||||
Guid Id,
|
||||
|
||||
@@ -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.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
|
||||
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
||||
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
||||
if (request.ClearEndDate) med.EndDate = null;
|
||||
else if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
||||
ValidateDateRange(med.StartDate, med.EndDate);
|
||||
if (request.Notes != null) med.Notes = request.Notes;
|
||||
med.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -130,15 +131,10 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
foreach (var scheduledTime in med.TimeOfDay)
|
||||
{
|
||||
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
|
||||
string status;
|
||||
if (log != null)
|
||||
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
|
||||
else if (scheduledTime <= now.AddMinutes(-30))
|
||||
status = "overdue";
|
||||
else if (scheduledTime <= now.AddHours(3))
|
||||
status = "upcoming";
|
||||
else
|
||||
continue;
|
||||
var status = MedicationScheduleStatus.Resolve(
|
||||
scheduledTime,
|
||||
now,
|
||||
log?.Status);
|
||||
|
||||
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
|
||||
}
|
||||
|
||||
@@ -58,3 +58,10 @@ public interface INotificationOutboxProcessor
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<bool> ProcessNextAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record ReminderCatchUpResult(int CreatedCount);
|
||||
|
||||
public interface IReminderCatchUpService
|
||||
{
|
||||
Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Health.Application.Users;
|
||||
|
||||
public sealed record UserProfileDto(
|
||||
Guid Id,
|
||||
string? Phone, // Apple 用户无手机号
|
||||
string Phone,
|
||||
string Role,
|
||||
string? Name,
|
||||
string? Gender,
|
||||
@@ -16,6 +16,10 @@ public sealed record UserProfileUpdateRequest(
|
||||
string? Gender,
|
||||
DateOnly? BirthDate);
|
||||
|
||||
public sealed record AccountFileReferences(
|
||||
IReadOnlyList<string> FileUrls,
|
||||
IReadOnlyList<string> ConversationMetadataJson);
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
||||
@@ -26,6 +30,12 @@ public interface IUserService
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
||||
Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IAccountFileCleanup
|
||||
{
|
||||
Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
namespace Health.Application.Users;
|
||||
|
||||
public sealed class UserService(IUserRepository users) : IUserService
|
||||
public sealed class UserService(
|
||||
IUserRepository users,
|
||||
IAccountFileCleanup fileCleanup) : IUserService
|
||||
{
|
||||
private readonly IUserRepository _users = users;
|
||||
private readonly IAccountFileCleanup _fileCleanup = fileCleanup;
|
||||
|
||||
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
@@ -30,6 +33,10 @@ public sealed class UserService(IUserRepository users) : IUserService
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
|
||||
_users.DeleteAccountDataAsync(userId, ct);
|
||||
public async Task DeleteAccountAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var references = await _users.GetAccountFileReferencesAsync(userId, ct);
|
||||
await _fileCleanup.DeleteAsync(userId, references, ct);
|
||||
await _users.DeleteAccountDataAsync(userId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class HealthArchive
|
||||
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
||||
public List<string> ChronicDiseases { get; set; } = []; // 慢病史
|
||||
public string? FamilyHistory { get; set; } // 家族病史
|
||||
public string? SurgeryHistoryStatus { get; set; } // 无/有,区分未填写与无手术史
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
@@ -89,6 +90,10 @@ public sealed class NotificationPreference
|
||||
public bool FollowUpReminder { get; set; } = true;
|
||||
public bool DoctorReply { get; set; } = true;
|
||||
public bool AbnormalAlert { get; set; } = true;
|
||||
public bool PushEnabled { get; set; } = true;
|
||||
public bool DndEnabled { get; set; }
|
||||
public int DndStartMinutes { get; set; } = 22 * 60;
|
||||
public int DndEndMinutes { get; set; } = 8 * 60;
|
||||
// 健康录入提醒——总开关 + 5 子项
|
||||
public bool HealthRecordReminder { get; set; } = true;
|
||||
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
||||
|
||||
@@ -6,8 +6,7 @@ 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 Sign-In 唯一标识
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
|
||||
public Guid? DoctorId { get; set; } // 患者选择的医生
|
||||
public string? Name { get; set; }
|
||||
|
||||
@@ -43,7 +43,7 @@ public static class MedicationAgentHandler
|
||||
return toolName switch
|
||||
{
|
||||
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
||||
_ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,29 @@ public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversatio
|
||||
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
|
||||
await _db.ConversationMessages.AddAsync(message, ct);
|
||||
|
||||
public async Task<int> DeleteLegacyDietCommentaryArtifactsAsync(
|
||||
Guid userId,
|
||||
string promptPrefix,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var candidates = await _db.Conversations
|
||||
.Include(conversation => conversation.Messages)
|
||||
.Where(conversation =>
|
||||
conversation.UserId == userId &&
|
||||
conversation.Messages.Any(message =>
|
||||
message.Role == MessageRole.User &&
|
||||
message.Content.StartsWith(promptPrefix)))
|
||||
.ToListAsync(ct);
|
||||
var artifacts = candidates
|
||||
.Where(conversation => DietCommentaryPolicy.IsLegacyArtifact(conversation.Messages))
|
||||
.ToList();
|
||||
if (artifacts.Count == 0) return 0;
|
||||
|
||||
_db.Conversations.RemoveRange(artifacts);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return artifacts.Count;
|
||||
}
|
||||
|
||||
public void Delete(Conversation conversation) =>
|
||||
_db.Conversations.Remove(conversation);
|
||||
|
||||
|
||||
@@ -157,9 +157,9 @@ public sealed class PromptManager
|
||||
""";
|
||||
|
||||
private const string UnifiedPrompt = """
|
||||
你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。
|
||||
你是患者端统一 AI 健康助手“小脉”。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的健康解释、数据记录、用药管理、运动管理和预问诊辅助。
|
||||
|
||||
核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
|
||||
核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称“已录入”“已保存”或“请点击确认”,实际却没有调用写入工具并生成确认卡片。
|
||||
|
||||
领域判断与可用工具:
|
||||
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
||||
@@ -182,10 +182,18 @@ public sealed class PromptManager
|
||||
|
||||
规则:
|
||||
- 用户可能一句话涉及多个领域,全部处理
|
||||
- 数值明确+指标明确→直接生成待确认命令,不要追问
|
||||
- 先判断用户是在咨询数值,还是希望记录数据,不能仅凭消息中出现指标和数值就自动录入
|
||||
- 明确提问时先回答问题,不调用 record_health_data。例如“血氧98%是不是太高了”是在咨询,应解释正常范围,不生成录入确认卡片;除非用户同时明确要求记录
|
||||
- 明确要求“记录、录入、保存、记一下”时调用 record_health_data。例如“帮我记录血压116/89”必须生成待确认命令
|
||||
- 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录
|
||||
- 指标或数值缺失时只追问缺失部分,不猜测数值
|
||||
- 多个明确指标要逐项调用 record_health_data,不能遗漏
|
||||
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
||||
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||
- 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片
|
||||
- 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊
|
||||
- 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温暖、专业
|
||||
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -3,12 +3,7 @@ using Health.Infrastructure.Services;
|
||||
|
||||
namespace Health.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthService(
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
SmsService sms,
|
||||
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
|
||||
) : IAuthService
|
||||
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
|
||||
{
|
||||
private const string AdminPhone = "12345678910";
|
||||
private const string AdminSmsCode = "000000";
|
||||
@@ -16,7 +11,6 @@ public sealed class AuthService(
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly JwtProvider _jwt = jwt;
|
||||
private readonly SmsService _sms = sms;
|
||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
||||
|
||||
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
|
||||
{
|
||||
@@ -105,64 +99,13 @@ public sealed class AuthService(
|
||||
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) =>
|
||||
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
|
||||
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
|
||||
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role)
|
||||
{
|
||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
||||
var access = _jwt.GenerateAccessToken(userId, phone, role);
|
||||
var refresh = _jwt.GenerateRefreshToken();
|
||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
return (access, refresh);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260710052811_AddAppleSignIn")]
|
||||
partial class AddAppleSignIn
|
||||
[Migration("20260713022401_AddPushAndDndPreferences")]
|
||||
partial class AddPushAndDndPreferences
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -676,6 +676,15 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.Property<bool>("AbnormalAlert")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("DndEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("DndEndMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DndStartMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("DoctorReply")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -703,6 +712,9 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.Property<bool>("MedicationReminder")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("PushEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -806,9 +818,6 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AppleUserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -828,6 +837,7 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Role")
|
||||
@@ -842,9 +852,6 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppleUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("DoctorId");
|
||||
|
||||
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")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("SurgeryHistoryStatus")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("SurgeryType")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -673,6 +677,15 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.Property<bool>("AbnormalAlert")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("DndEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("DndEndMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DndStartMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("DoctorReply")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -700,6 +713,9 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.Property<bool>("MedicationReminder")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("PushEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -803,9 +819,6 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AppleUserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -825,6 +838,7 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Role")
|
||||
@@ -839,9 +853,6 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppleUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("DoctorId");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
|
||||
@@ -50,7 +50,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
builder.Entity<User>(e =>
|
||||
{
|
||||
e.HasIndex(u => u.Phone).IsUnique();
|
||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识
|
||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
@@ -171,6 +170,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
builder.Entity<HealthArchive>(e =>
|
||||
{
|
||||
e.HasIndex(a => a.UserId).IsUnique();
|
||||
e.Property(a => a.SurgeryHistoryStatus).HasMaxLength(8);
|
||||
});
|
||||
|
||||
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,
|
||||
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))
|
||||
return false;
|
||||
|
||||
@@ -67,7 +73,7 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
||||
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -81,59 +87,89 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (candidate == null) return false;
|
||||
|
||||
var claimed = await _db.NotificationOutbox
|
||||
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Processing")
|
||||
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
if (claimed == 0) return true;
|
||||
var task = await _db.NotificationOutbox
|
||||
.FirstOrDefaultAsync(x => x.Id == candidate.Id && x.Status == "Pending", ct);
|
||||
if (task == null) return true;
|
||||
|
||||
task.Status = "Processing";
|
||||
task.Attempts += 1;
|
||||
task.UpdatedAt = now;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
|
||||
?? throw new InvalidOperationException("通知消息内容为空");
|
||||
var message = JsonSerializer.Deserialize<NotificationMessage>(task.Payload)
|
||||
?? throw new InvalidOperationException("Notification payload is empty");
|
||||
|
||||
if (!await _db.UserNotifications.AsNoTracking()
|
||||
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
|
||||
var preference = await _db.NotificationPreferences.AsNoTracking()
|
||||
.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
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = candidate.UserId,
|
||||
SourceId = candidate.SourceTaskId,
|
||||
UserId = task.UserId,
|
||||
SourceId = task.SourceTaskId,
|
||||
Type = message.Type,
|
||||
Title = message.Title,
|
||||
Message = message.Message,
|
||||
Severity = message.Severity,
|
||||
ActionType = message.ActionType,
|
||||
ActionTargetId = message.ActionTargetId,
|
||||
CreatedAt = candidate.CreatedAt,
|
||||
CreatedAt = task.CreatedAt,
|
||||
}, 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.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Completed")
|
||||
.SetProperty(x => x.LastError, (string?)null)
|
||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
||||
task.Status = "Completed";
|
||||
task.LastError = null;
|
||||
task.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var attempts = candidate.Attempts + 1;
|
||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
|
||||
.SetProperty(x => x.AvailableAt,
|
||||
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);
|
||||
task.Status = task.Attempts >= MaxAttempts ? "Failed" : "Pending";
|
||||
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
|
||||
task.LastError = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
|
||||
task.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
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) =>
|
||||
_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) =>
|
||||
_db.SaveChangesAsync(ct);
|
||||
|
||||
@@ -50,4 +88,9 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
||||
|
||||
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.Domain.Enums;
|
||||
|
||||
namespace Health.WebApi.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
|
||||
/// 对未录入指标且开关开启的用户推送提醒。
|
||||
/// 中国时区 UTC+8。
|
||||
/// Produces in-app catch-up reminders for health records.
|
||||
/// The actual reminder rules live in IReminderCatchUpService so foreground
|
||||
/// app checks and background scans stay consistent.
|
||||
/// </summary>
|
||||
public sealed class HealthRecordReminderService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly ILogger<HealthRecordReminderService> _logger = logger;
|
||||
|
||||
// 北京时间 9:00 和 12:00 触发
|
||||
private static readonly int[] ReminderHoursCst = [9, 12];
|
||||
|
||||
private static readonly int[] ReminderHoursCst = [9, 13, 19];
|
||||
private DateTime _lastFired = DateTime.MinValue;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("健康录入提醒服务已启动");
|
||||
logger.LogInformation("Health record in-app reminder service started");
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||
// 当前小时是否在触发列表,且今天该小时还没触发过
|
||||
if (ReminderHoursCst.Contains(nowCst.Hour)
|
||||
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||
if (ReminderHoursCst.Contains(nowCst.Hour) &&
|
||||
(_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||
{
|
||||
await ScanAndPushAsync(nowCst, stoppingToken);
|
||||
await CheckUsersAsync(DateTime.UtcNow, stoppingToken);
|
||||
_lastFired = nowCst;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Health record in-app reminder scan failed");
|
||||
}
|
||||
|
||||
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 producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
||||
var catchUp = scope.ServiceProvider.GetRequiredService<IReminderCatchUpService>();
|
||||
|
||||
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
|
||||
var todayEnd = todayStart.AddDays(1);
|
||||
|
||||
// 取所有启用了健康录入提醒的用户偏好
|
||||
var prefs = await db.NotificationPreferences
|
||||
.Where(p => p.HealthRecordReminder)
|
||||
var userIds = await db.NotificationPreferences.AsNoTracking()
|
||||
.Where(x => x.PushEnabled && x.HealthRecordReminder)
|
||||
.Select(x => x.UserId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var pref in prefs)
|
||||
var created = 0;
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
// 该用户当天已录入的指标
|
||||
var recordedTypes = await db.HealthRecords
|
||||
.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);
|
||||
var result = await catchUp.CheckDueAsync(userId, utcNow, ct);
|
||||
created += result.CreatedCount;
|
||||
}
|
||||
}
|
||||
|
||||
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
|
||||
{
|
||||
// 通过哈希构造稳定的 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);
|
||||
if (created > 0)
|
||||
logger.LogInformation("Created {Count} in-app catch-up reminders", created);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,8 +321,12 @@ public static class AiChatEndpoints
|
||||
var userId = GetUserId(http);
|
||||
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||
|
||||
await conversations.DeleteAsync(userId.Value, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var deleted = await conversations.DeleteAsync(userId.Value, id, ct);
|
||||
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 });
|
||||
});
|
||||
|
||||
// 饮食分析页专用建议:不创建会话,不保存提示词或回复到历史记录。
|
||||
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 (
|
||||
HttpRequest httpRequest,
|
||||
HttpContext http,
|
||||
@@ -623,7 +670,7 @@ public static class AiChatEndpoints
|
||||
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
||||
break;
|
||||
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;
|
||||
case "spo2":
|
||||
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)));
|
||||
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) =>
|
||||
@@ -30,5 +28,4 @@ public static class AuthEndpoints
|
||||
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);
|
||||
|
||||
@@ -26,8 +26,10 @@ public static class DietEndpoints
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
await diets.DeleteAsync(userId, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var deleted = await diets.DeleteAsync(userId, id, ct);
|
||||
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) =>
|
||||
|
||||
@@ -29,6 +29,20 @@ public static class DoctorEndpoints
|
||||
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)
|
||||
{
|
||||
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 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 todayStart = DateTime.UtcNow.Date;
|
||||
var todayEnd = todayStart.AddDays(1);
|
||||
var (todayStart, todayEnd) = GetBeijingDayRange(DateTime.UtcNow);
|
||||
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.DietRestrictions,
|
||||
user.HealthArchive.ChronicDiseases,
|
||||
user.HealthArchive.FamilyHistory
|
||||
user.HealthArchive.FamilyHistory,
|
||||
user.HealthArchive.SurgeryHistoryStatus
|
||||
},
|
||||
latestRecords,
|
||||
trendRecords,
|
||||
|
||||
@@ -57,8 +57,10 @@ public static class ExerciseEndpoints
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
await exercises.DeleteAsync(userId, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var deleted = await exercises.DeleteAsync(userId, id, ct);
|
||||
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) =>
|
||||
|
||||
@@ -12,8 +12,9 @@ public static class FileEndpoints
|
||||
{
|
||||
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)
|
||||
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 = "未上传文件" });
|
||||
|
||||
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);
|
||||
|
||||
foreach (var file in files)
|
||||
@@ -48,7 +54,7 @@ public static class FileEndpoints
|
||||
id = fileId,
|
||||
name = file.FileName,
|
||||
size = file.Length,
|
||||
url = $"/uploads/{storedName}",
|
||||
url = $"/uploads/users/{userDirectoryName}/{storedName}",
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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 (
|
||||
Guid id,
|
||||
CreateHealthRecordRequest req,
|
||||
|
||||
@@ -36,8 +36,10 @@ public static class MedicationEndpoints
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
await medications.DeleteAsync(userId, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var deleted = await medications.DeleteAsync(userId, id, ct);
|
||||
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) =>
|
||||
@@ -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) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var cancelled = await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
||||
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))
|
||||
frequency = freq;
|
||||
|
||||
var hasEndDate = root.TryGetProperty("endDate", out var ed);
|
||||
var clearEndDate = hasEndDate &&
|
||||
(ed.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(ed.GetString()));
|
||||
|
||||
return new MedicationPatchRequest(
|
||||
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
||||
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||
frequency,
|
||||
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("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
|
||||
hasEndDate && !clearEndDate && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null,
|
||||
clearEndDate);
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
@@ -90,6 +98,10 @@ public static class NotificationEndpoints
|
||||
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
||||
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.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.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
||||
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
||||
@@ -100,7 +112,7 @@ public static class NotificationEndpoints
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 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 todayStart = nowCst.Date.AddHours(-8);
|
||||
@@ -153,6 +165,10 @@ public static class NotificationEndpoints
|
||||
followUpReminder = pref.FollowUpReminder,
|
||||
doctorReply = pref.DoctorReply,
|
||||
abnormalAlert = pref.AbnormalAlert,
|
||||
pushEnabled = pref.PushEnabled,
|
||||
dndEnabled = pref.DndEnabled,
|
||||
dndStartMinutes = pref.DndStartMinutes,
|
||||
dndEndMinutes = pref.DndEndMinutes,
|
||||
healthRecordReminder = pref.HealthRecordReminder,
|
||||
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
||||
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
||||
@@ -167,6 +183,10 @@ public sealed record NotificationPrefsUpdateDto(
|
||||
bool? FollowUpReminder,
|
||||
bool? DoctorReply,
|
||||
bool? AbnormalAlert,
|
||||
bool? PushEnabled,
|
||||
bool? DndEnabled,
|
||||
int? DndStartMinutes,
|
||||
int? DndEndMinutes,
|
||||
bool? HealthRecordReminder,
|
||||
bool? HealthRecordReminderBloodPressure,
|
||||
bool? HealthRecordReminderHeartRate,
|
||||
|
||||
@@ -49,7 +49,8 @@ public static class UserEndpoints
|
||||
req.DietRestrictions,
|
||||
req.ChronicDiseases,
|
||||
req.FamilyHistory,
|
||||
surgeries),
|
||||
surgeries,
|
||||
req.SurgeryHistoryStatus),
|
||||
ct);
|
||||
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,
|
||||
List<string>? Allergies, List<string>? DietRestrictions,
|
||||
List<string>? ChronicDiseases, string? FamilyHistory,
|
||||
List<UpdateArchiveSurgeryRequest>? Surgeries);
|
||||
List<UpdateArchiveSurgeryRequest>? Surgeries,
|
||||
string? SurgeryHistoryStatus);
|
||||
|
||||
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);
|
||||
|
||||
@@ -108,7 +108,6 @@ builder.Services.AddAuthorization();
|
||||
// ---- 业务服务 ----
|
||||
builder.Services.AddSingleton<JwtProvider>();
|
||||
builder.Services.AddSingleton<SmsService>();
|
||||
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
||||
builder.Services.AddSingleton<PromptManager>();
|
||||
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
||||
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
||||
@@ -143,6 +142,7 @@ builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>(
|
||||
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
||||
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
|
||||
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
|
||||
builder.Services.AddScoped<IReminderCatchUpService, EfReminderCatchUpService>();
|
||||
builder.Services.AddScoped<IReportService, ReportService>();
|
||||
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
||||
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
||||
@@ -150,6 +150,8 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
|
||||
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
|
||||
|
||||
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
||||
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Default": "Information",
|
||||
"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 repository = new FakeHealthArchiveRepository(new HealthArchive
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId,
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
});
|
||||
var service = new HealthArchiveService(repository);
|
||||
|
||||
@@ -75,14 +76,35 @@ public sealed class ApplicationServiceTests
|
||||
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]
|
||||
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId,
|
||||
SurgeryType = "Legacy surgery", SurgeryDate = new DateOnly(2010, 1, 2),
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
SurgeryType = "Legacy surgery",
|
||||
SurgeryDate = new DateOnly(2010, 1, 2),
|
||||
});
|
||||
var service = new HealthArchiveService(repository);
|
||||
|
||||
@@ -110,13 +132,71 @@ public sealed class ApplicationServiceTests
|
||||
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]
|
||||
public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp()
|
||||
{
|
||||
var date = new DateOnly(2026, 6, 19);
|
||||
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) };
|
||||
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 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.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 });
|
||||
@@ -175,11 +259,19 @@ public sealed class ApplicationServiceTests
|
||||
var repository = new FakeExerciseRepository();
|
||||
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
|
||||
{
|
||||
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);
|
||||
repository.SetPlan(plan);
|
||||
@@ -199,6 +291,8 @@ public sealed class ApplicationServiceTests
|
||||
private sealed class CapturingHealthRecordRepository : IHealthRecordRepository
|
||||
{
|
||||
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) =>
|
||||
Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
||||
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
|
||||
@@ -208,9 +302,9 @@ public sealed class ApplicationServiceTests
|
||||
RecordedAfter = recordedAfter;
|
||||
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 Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task SaveChangesAsync(CancellationToken ct) { SaveCount++; return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
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 AddConversationAsync(Conversation value, 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 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
|
||||
{
|
||||
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 = ["低盐", "低脂"],
|
||||
ChronicDiseases = ["高血压", "高血脂"],
|
||||
FamilyHistory = "父亲冠心病",
|
||||
SurgeryHistoryStatus = "有",
|
||||
};
|
||||
db.HealthArchives.Add(archive);
|
||||
await db.SaveChangesAsync();
|
||||
@@ -193,6 +194,7 @@ public class EntityTests
|
||||
Assert.Equal("冠心病", saved!.Diagnosis);
|
||||
Assert.Equal(2, saved.DietRestrictions.Count);
|
||||
Assert.Contains("低盐", saved.DietRestrictions);
|
||||
Assert.Equal("有", saved.SurgeryHistoryStatus);
|
||||
}
|
||||
|
||||
[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.AI;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.AI;
|
||||
@@ -13,6 +14,50 @@ namespace Health.Tests;
|
||||
|
||||
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]
|
||||
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
|
||||
{
|
||||
@@ -168,6 +213,178 @@ public sealed class PersistencePipelineTests
|
||||
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()
|
||||
{
|
||||
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")
|
||||
}
|
||||
|
||||
// 加载正式签名配置
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = java.util.Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.datalumina.YYA"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -27,28 +20,21 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
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
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
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 {
|
||||
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_SCAN" />
|
||||
<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-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
<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>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.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 */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
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 */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -45,18 +42,12 @@
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
@@ -64,46 +55,19 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
5B6028AD445E1E5F91C5C300 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase 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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -112,19 +76,9 @@
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
789F6ED1EF63DAEA6E803F1D /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */,
|
||||
C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -140,8 +94,6 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
04069A07808CEF12713E8A9C /* Pods */,
|
||||
789F6ED1EF63DAEA6E803F1D /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -176,10 +128,8 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
5B6028AD445E1E5F91C5C300 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -195,23 +145,18 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -245,9 +190,6 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -280,28 +222,6 @@
|
||||
/* End PBXResourcesBuildPhase 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 */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -333,45 +253,6 @@
|
||||
shellPath = /bin/sh;
|
||||
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 */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -467,7 +348,8 @@
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
@@ -479,16 +361,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -498,14 +378,13 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -516,14 +395,13 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -532,14 +410,13 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -598,6 +475,7 @@
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
@@ -647,7 +525,8 @@
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -661,16 +540,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -685,16 +562,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -736,20 +611,6 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
|
||||
@@ -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"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.7">
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "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>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -70,9 +52,9 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
|
||||
@@ -4,7 +4,4 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</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
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
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">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>小脉健康</string>
|
||||
<string>Health App</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -15,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>小脉健康</string>
|
||||
<string>health_app</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -26,39 +24,6 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<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>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -76,5 +41,13 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>需要使用蓝牙连接血压计</string>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</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);
|
||||
if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash
|
||||
if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页
|
||||
final currentRoute = ref.watch(routeStackProvider).last.name;
|
||||
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
|
||||
final role = auth.user?.role ?? 'User';
|
||||
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
||||
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
||||
@@ -104,10 +106,15 @@ class _RootNavigator extends ConsumerWidget {
|
||||
final stack = ref.watch(routeStackProvider);
|
||||
final current = stack.last;
|
||||
final authState = ref.watch(authProvider);
|
||||
final isPublicStaticText = current.name == 'staticText';
|
||||
|
||||
// 登录后自动跳转(在下一帧完成,无闪烁)
|
||||
if (authState.isLoggedIn && current.name == 'login') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.invalidate(latestHealthProvider);
|
||||
ref.invalidate(medicationListProvider);
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
final role = authState.user?.role ?? 'User';
|
||||
if (role == 'Admin') {
|
||||
goRoute(ref, 'adminHome');
|
||||
@@ -120,7 +127,8 @@ class _RootNavigator extends ConsumerWidget {
|
||||
}
|
||||
if (!authState.isLoading &&
|
||||
!authState.isLoggedIn &&
|
||||
current.name != 'login') {
|
||||
current.name != 'login' &&
|
||||
!isPublicStaticText) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
goRoute(ref, 'login');
|
||||
});
|
||||
|
||||
@@ -3,10 +3,10 @@ import 'dart:io';
|
||||
import 'package:dio/dio.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(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'https://erpapi.datalumina.cn/xiaomai',
|
||||
defaultValue: 'http://10.4.237.12:5000',
|
||||
);
|
||||
|
||||
class ApiException implements Exception {
|
||||
|
||||
@@ -20,30 +20,30 @@ class AppColors {
|
||||
static const Color meadowAccent = Color(0xFFBAE6FD);
|
||||
static const Color sageAccent = Color(0xFFE9D5FF);
|
||||
|
||||
static const Color health = Color(0xFF3B82F6);
|
||||
static const Color healthLight = Color(0xFFEFF6FF);
|
||||
static const Color healthBorder = Color(0xFFBFDBFE);
|
||||
static const Color medication = Color(0xFF8B5CF6);
|
||||
static const Color medicationLight = Color(0xFFF5F3FF);
|
||||
static const Color medicationBorder = Color(0xFFDDD6FE);
|
||||
static const Color exercise = Color(0xFF10B981);
|
||||
static const Color exerciseLight = Color(0xFFECFDF5);
|
||||
static const Color exerciseBorder = Color(0xFFA7F3D0);
|
||||
static const Color report = Color(0xFF2563EB);
|
||||
static const Color reportLight = Color(0xFFEEF2FF);
|
||||
static const Color reportBorder = Color(0xFFC7D2FE);
|
||||
static const Color diet = Color(0xFFF97316);
|
||||
static const Color dietLight = Color(0xFFFFF7ED);
|
||||
static const Color dietBorder = Color(0xFFFED7AA);
|
||||
static const Color device = Color(0xFF06B6D4);
|
||||
static const Color deviceLight = Color(0xFFECFEFF);
|
||||
static const Color deviceBorder = Color(0xFFA5F3FC);
|
||||
static const Color health = Color(0xFF34D399);
|
||||
static const Color healthLight = Color(0xFFF0FDF7);
|
||||
static const Color healthBorder = Color(0xFFC8F7E1);
|
||||
static const Color medication = Color(0xFF00B8D9);
|
||||
static const Color medicationLight = Color(0xFFEAFBFF);
|
||||
static const Color medicationBorder = Color(0xFFB8F0FA);
|
||||
static const Color exercise = Color(0xFF4F7DD9);
|
||||
static const Color exerciseLight = Color(0xFFEFF4FF);
|
||||
static const Color exerciseBorder = Color(0xFFD5E2FF);
|
||||
static const Color report = Color(0xFF6366F1);
|
||||
static const Color reportLight = Color(0xFFEFFBFF);
|
||||
static const Color reportBorder = Color(0xFFBAE6FD);
|
||||
static const Color diet = Color(0xFFF7971E);
|
||||
static const Color dietLight = Color(0xFFFFF8DB);
|
||||
static const Color dietBorder = Color(0xFFFFE58A);
|
||||
static const Color device = Color(0xFF2563EB);
|
||||
static const Color deviceLight = Color(0xFFEFF6FF);
|
||||
static const Color deviceBorder = Color(0xFFBFDBFE);
|
||||
static const Color notification = Color(0xFF7C5CFF);
|
||||
static const Color notificationLight = Color(0xFFF5F3FF);
|
||||
static const Color notificationBorder = Color(0xFFDDD6FE);
|
||||
static const Color doctor = Color(0xFF0F766E);
|
||||
static const Color doctorLight = Color(0xFFF0FDFA);
|
||||
static const Color doctorBorder = Color(0xFF99F6E4);
|
||||
static const Color doctor = Color(0xFFFFAFBD);
|
||||
static const Color doctorLight = Color(0xFFFFF3EF);
|
||||
static const Color doctorBorder = Color(0xFFFFD7CB);
|
||||
static const Color calendar = Color(0xFF6366F1);
|
||||
static const Color calendarLight = Color(0xFFEEF2FF);
|
||||
static const Color calendarBorder = Color(0xFFC7D2FE);
|
||||
@@ -51,7 +51,7 @@ class AppColors {
|
||||
static const Color followupLight = Color(0xFFFDF2F8);
|
||||
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 cardBackground = Color(0xFFFFFFFF);
|
||||
static const Color cardInner = Color(0xFFF1F5F9);
|
||||
@@ -70,10 +70,13 @@ class AppColors {
|
||||
|
||||
static const Color success = Color(0xFF16A34A);
|
||||
static const Color successLight = Color(0xFFEAF8EF);
|
||||
static const Color successText = Color(0xFF15803D);
|
||||
static const Color error = Color(0xFFEF4444);
|
||||
static const Color errorLight = Color(0xFFFEECEF);
|
||||
static const Color errorText = Color(0xFFDC2626);
|
||||
static const Color warning = Color(0xFFF59E0B);
|
||||
static const Color warningLight = Color(0xFFFFF7E6);
|
||||
static const Color warningText = Color(0xFFB45309);
|
||||
static const Color infoLight = Color(0xFFEFF5FF);
|
||||
|
||||
static const LinearGradient primaryGradient = LinearGradient(
|
||||
@@ -157,45 +160,45 @@ class AppColors {
|
||||
);
|
||||
|
||||
static const LinearGradient doctorGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(0xFFFFC3A0), Color(0xFFFFAFBD)],
|
||||
);
|
||||
|
||||
static const LinearGradient healthGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(0xFF6EE7B7), Color(0xFF67E8F9)],
|
||||
);
|
||||
|
||||
static const LinearGradient medicationGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)],
|
||||
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)],
|
||||
);
|
||||
|
||||
static const LinearGradient exerciseGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF34D399), Color(0xFF059669)],
|
||||
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
|
||||
);
|
||||
|
||||
static const LinearGradient reportGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
||||
colors: [Color(0xFF38BDF8), Color(0xFF818CF8)],
|
||||
);
|
||||
|
||||
static const LinearGradient dietGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFFB923C), Color(0xFFF97316)],
|
||||
colors: [Color(0xFFF7971E), Color(0xFFFFD200)],
|
||||
);
|
||||
|
||||
static const LinearGradient deviceGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF22D3EE), Color(0xFF0891B2)],
|
||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
||||
);
|
||||
|
||||
static const LinearGradient notificationGradient = LinearGradient(
|
||||
|
||||
@@ -168,4 +168,3 @@ class AppTextStyles {
|
||||
height: 1.25,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ class AppModuleVisual {
|
||||
class AppModuleVisuals {
|
||||
AppModuleVisuals._();
|
||||
|
||||
static const healthOverviewIcon = Icons.trending_up;
|
||||
|
||||
static const ai = AppModuleVisual(
|
||||
module: AppModule.ai,
|
||||
label: 'AI',
|
||||
@@ -73,7 +75,7 @@ class AppModuleVisuals {
|
||||
static const exercise = AppModuleVisual(
|
||||
module: AppModule.exercise,
|
||||
label: '运动',
|
||||
icon: LucideIcons.footprints,
|
||||
icon: Icons.directions_run_outlined,
|
||||
color: AppColors.exercise,
|
||||
lightColor: AppColors.exerciseLight,
|
||||
borderColor: AppColors.exerciseBorder,
|
||||
|
||||
@@ -15,7 +15,9 @@ import '../pages/settings/notification_prefs_page.dart';
|
||||
import '../pages/notifications/notification_center_page.dart';
|
||||
import '../pages/history/conversation_history_page.dart';
|
||||
import '../pages/profile/profile_page.dart';
|
||||
import '../pages/profile/profile_edit_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_management_page.dart';
|
||||
import '../pages/remaining_pages.dart';
|
||||
@@ -67,9 +69,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
case 'medCheckIn':
|
||||
return MedicationCheckInPage(medId: params['id']);
|
||||
case 'medicationEdit':
|
||||
return const MedicationEditPage();
|
||||
return MedicationEditPage(id: params['id']);
|
||||
case 'reports':
|
||||
return const ReportListPage();
|
||||
return ReportListPage(openUploadOnEnter: params['openUpload'] == 'true');
|
||||
case 'aiAnalysis':
|
||||
final id = _requiredParam(params, 'id');
|
||||
return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
|
||||
@@ -77,11 +79,12 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
return ReportOriginalPage(
|
||||
url: params['url'] ?? '',
|
||||
title: params['title'] ?? '原始报告',
|
||||
fileType: params['fileType'] ?? 'Image',
|
||||
);
|
||||
case 'exercisePlan':
|
||||
return const ExercisePlanPage();
|
||||
return const EnterpriseExercisePlanPage();
|
||||
case 'exercisePlanDetail':
|
||||
return const ExercisePlanPage();
|
||||
return const EnterpriseExercisePlanPage();
|
||||
case 'exerciseCreate':
|
||||
return const ExercisePlanCreatePage();
|
||||
case 'dietRecords':
|
||||
@@ -90,6 +93,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
return const DietCapturePage();
|
||||
case 'profile':
|
||||
return const ProfilePage();
|
||||
case 'profileEdit':
|
||||
return const ProfileEditPage();
|
||||
case 'doctorProfile':
|
||||
return const DoctorProfileEditPage();
|
||||
case 'doctorSettings':
|
||||
|
||||
@@ -45,8 +45,7 @@ Future<DateTime?> showAppDatePicker(
|
||||
final monthCtrl = FixedExtentScrollController(initialItem: m - 1);
|
||||
final dayCtrl = FixedExtentScrollController(initialItem: d - 1);
|
||||
|
||||
int daysInMonth(int year, int month) =>
|
||||
DateTime(year, month + 1, 0).day;
|
||||
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
|
||||
|
||||
void clampDay() {
|
||||
final maxDay = daysInMonth(y, m);
|
||||
@@ -70,12 +69,22 @@ Future<DateTime?> showAppDatePicker(
|
||||
children: [
|
||||
CupertinoButton(
|
||||
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),
|
||||
),
|
||||
CupertinoButton(
|
||||
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: () {
|
||||
clampDay();
|
||||
Navigator.pop(ctx, DateTime(y, m, d));
|
||||
@@ -87,37 +96,68 @@ Future<DateTime?> showAppDatePicker(
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: yearCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) {
|
||||
y = minDate.year + i;
|
||||
clampDay();
|
||||
final maxDay = daysInMonth(y, m);
|
||||
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))],
|
||||
))),
|
||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: monthCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) {
|
||||
m = i + 1;
|
||||
clampDay();
|
||||
final maxDay = daysInMonth(y, m);
|
||||
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))],
|
||||
))),
|
||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: dayCtrl,
|
||||
itemExtent: 40,
|
||||
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))],
|
||||
))),
|
||||
Expanded(
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: yearCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) {
|
||||
y = minDate.year + i;
|
||||
clampDay();
|
||||
final maxDay = daysInMonth(y, m);
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: monthCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) {
|
||||
m = i + 1;
|
||||
clampDay();
|
||||
final maxDay = daysInMonth(y, m);
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: dayCtrl,
|
||||
itemExtent: 40,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -153,13 +193,24 @@ Future<TimeOfDay?> showAppTimePicker(
|
||||
children: [
|
||||
CupertinoButton(
|
||||
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),
|
||||
),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
||||
onPressed: () => Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)),
|
||||
child: const Text(
|
||||
'确定',
|
||||
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(
|
||||
children: [
|
||||
const Spacer(flex: 1),
|
||||
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: hourCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) => hour = i,
|
||||
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
||||
))),
|
||||
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: minuteCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) => minute = i,
|
||||
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
||||
))),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: hourCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) => hour = i,
|
||||
children: [
|
||||
for (var i = 0; i <= 23; i++)
|
||||
Center(
|
||||
child: Text(
|
||||
i.toString().padLeft(2, '0'),
|
||||
style: _pickerItemStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: minuteCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) => minute = i,
|
||||
children: [
|
||||
for (var i = 0; i <= 59; i++)
|
||||
Center(
|
||||
child: Text(
|
||||
i.toString().padLeft(2, '0'),
|
||||
style: _pickerItemStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(flex: 1),
|
||||
],
|
||||
),
|
||||
@@ -211,8 +288,7 @@ Future<DateTime?> showAppDateTimePicker(
|
||||
final hourCtrl = FixedExtentScrollController(initialItem: h);
|
||||
final minuteCtrl = FixedExtentScrollController(initialItem: min);
|
||||
|
||||
int daysInMonth(int year, int month) =>
|
||||
DateTime(year, month + 1, 0).day;
|
||||
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
|
||||
|
||||
void clampDay() {
|
||||
final maxDay = daysInMonth(y, m);
|
||||
@@ -236,12 +312,22 @@ Future<DateTime?> showAppDateTimePicker(
|
||||
children: [
|
||||
CupertinoButton(
|
||||
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),
|
||||
),
|
||||
CupertinoButton(
|
||||
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: () {
|
||||
clampDay();
|
||||
Navigator.pop(ctx, DateTime(y, m, d, h, min));
|
||||
@@ -253,41 +339,102 @@ Future<DateTime?> showAppDateTimePicker(
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: yearCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) { y = minDate.year + i; clampDay(); },
|
||||
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
|
||||
))),
|
||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: monthCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) { m = i + 1; clampDay(); },
|
||||
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
||||
))),
|
||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: dayCtrl,
|
||||
itemExtent: 40,
|
||||
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))],
|
||||
))),
|
||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: hourCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) => h = i,
|
||||
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
||||
))),
|
||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: minuteCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) => min = i,
|
||||
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
||||
))),
|
||||
Expanded(
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: yearCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) {
|
||||
y = minDate.year + i;
|
||||
clampDay();
|
||||
},
|
||||
children: [
|
||||
for (var i = minDate.year; i <= maxDate.year; i++)
|
||||
Center(child: Text('$i', style: _pickerItemStyle)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: monthCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) {
|
||||
m = i + 1;
|
||||
clampDay();
|
||||
},
|
||||
children: [
|
||||
for (var i = 1; i <= 12; i++)
|
||||
Center(
|
||||
child: Text(
|
||||
i.toString().padLeft(2, '0'),
|
||||
style: _pickerItemStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: dayCtrl,
|
||||
itemExtent: 40,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: hourCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) => h = i,
|
||||
children: [
|
||||
for (var i = 0; i <= 23; i++)
|
||||
Center(
|
||||
child: Text(
|
||||
i.toString().padLeft(2, '0'),
|
||||
style: _pickerItemStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: minuteCtrl,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) => min = i,
|
||||
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: [
|
||||
CupertinoButton(
|
||||
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),
|
||||
),
|
||||
CupertinoButton(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _wrapPicker(CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: FixedExtentScrollController(initialItem: selected - min),
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) => selected = i + min,
|
||||
children: [
|
||||
for (var i = min; i <= max; i++)
|
||||
Center(child: Text(label.isEmpty ? '$i' : '$i$label', style: _pickerItemStyle)),
|
||||
],
|
||||
)),
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: FixedExtentScrollController(
|
||||
initialItem: selected - min,
|
||||
),
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (i) => selected = i + min,
|
||||
children: [
|
||||
for (var i = min; i <= max; i++)
|
||||
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;
|
||||
|
||||
// 全局浅灰白背景,已弃用紫色渐变。
|
||||
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
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
// 内部页面统一使用浅灰白底,对话流首页因直接使用 AppBackground 仍保留紫色渐变。
|
||||
backgroundColor: const Color(0xFFF6F8FC),
|
||||
// 二级页面用浅中性画布承托白色内容组;首页拥有自己的独立背景实现。
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: appBar,
|
||||
body: body,
|
||||
body: ColoredBox(
|
||||
color: AppColors.background,
|
||||
child: SizedBox.expand(child: body ?? const SizedBox.shrink()),
|
||||
),
|
||||
floatingActionButton: floatingActionButton,
|
||||
drawer: drawer,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
@@ -485,22 +663,22 @@ class AppTheme {
|
||||
surface: surface,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
scaffoldBackgroundColor: Colors.transparent,
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
splashColor: primary.withValues(alpha: 0.06),
|
||||
highlightColor: Colors.transparent,
|
||||
hoverColor: primaryLight.withValues(alpha: 0.55),
|
||||
fontFamily: null,
|
||||
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: text,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
scrolledUnderElevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titleTextStyle: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: text,
|
||||
),
|
||||
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