5 Commits

Author SHA1 Message Date
sccsbc
1a9d56b07c chore: 添加 appinfo 配置文件
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 15:31:36 +08:00
sccsbc
e52e21d295 chore: Android 正式签名配置 + 截图尺寸适配
- 生成 release.jks 正式签名密钥库
- build.gradle.kts 配置 release 签名自动读取 key.properties
- .gitignore 排除 keystore 和 key.properties
- 截图调整为 6.5 寸规格 (1242x2688)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 14:20:57 +08:00
ec5af19d30 chore: 补全 Apple Sign-In 迁移 + 调低开发环境日志级别
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-10 05:37:24 +00:00
sccsbc
bd4350c17f fix: 修复 Apple 登录 sub claim 映射 + 按钮文案中文化
- AppleTokenValidator: 从 JwtSecurityToken 原始 Claims 取 sub,避免 .NET ClaimType 映射导致丢失
- 登录页 Apple 按钮改为中文"通过 Apple 登录"

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 13:35:28 +08:00
sccsbc
fe4c81c54f feat: 集成 Apple Sign-In + iOS 上线准备
- 后端新增 Apple 登录端点 /api/auth/apple-login
- 新增 AppleTokenValidator 验证 IdentityToken
- User 实体添加 AppleUserId 字段,Phone 改为可空
- 前端添加 sign_in_with_apple 依赖和 Apple 登录按钮
- 统一 Bundle ID 为 com.datalumina.YYA,显示名称为小脉健康
- 配置 DEVELOPMENT_TEAM 和 Sign in with Apple Entitlements
- 补充 NSCamera/NSPhotoLibrary 权限描述
- 生产 API 地址改为 https://erpapi.datalumina.cn/xiaomai
- Flutter 升级至 3.44.6 / Dart 3.12.2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 13:24:03 +08:00
174 changed files with 7346 additions and 14630 deletions

4
.gitignore vendored
View File

@@ -4,6 +4,10 @@
*.key *.key
*.pfx *.pfx
# Android release keystore
health_app/android/**/*.jks
health_app/android/key.properties
# .NET build outputs # .NET build outputs
backend/**/bin/ backend/**/bin/
backend/**/obj/ backend/**/obj/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -35,7 +35,7 @@ public interface IAiConversationService
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct); Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct); Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct); Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct); Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct); Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
} }
@@ -48,7 +48,6 @@ public interface IAiConversationRepository
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct); Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task AddConversationAsync(Conversation conversation, CancellationToken ct); Task AddConversationAsync(Conversation conversation, CancellationToken ct);
Task AddMessageAsync(ConversationMessage message, CancellationToken ct); Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct);
void Delete(Conversation conversation); void Delete(Conversation conversation);
Task SaveChangesAsync(CancellationToken ct); Task SaveChangesAsync(CancellationToken ct);
} }

View File

@@ -5,7 +5,7 @@ namespace Health.Application.AI;
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
{ {
private const int HistoryConversationLimit = 30; private const int HistoryConversationLimit = 7;
private readonly IAiConversationRepository _conversations = conversations; private readonly IAiConversationRepository _conversations = conversations;
public async Task<OpenConversationResult> OpenAsync( public async Task<OpenConversationResult> OpenAsync(
@@ -55,10 +55,6 @@ public sealed class AiConversationService(IAiConversationRepository conversation
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct) public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
{ {
await _conversations.DeleteLegacyDietCommentaryArtifactsAsync(
userId,
DietCommentaryPolicy.LegacyPromptPrefix,
ct);
var conversations = await _conversations.ListAsync(userId, ct); var conversations = await _conversations.ListAsync(userId, ct);
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList(); return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
} }
@@ -83,14 +79,13 @@ public sealed class AiConversationService(IAiConversationRepository conversation
await _conversations.SaveChangesAsync(ct); await _conversations.SaveChangesAsync(ct);
} }
public async Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct) public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
{ {
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct); var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
if (conversation == null) return false; if (conversation == null) return;
_conversations.Delete(conversation); _conversations.Delete(conversation);
await _conversations.SaveChangesAsync(ct); await _conversations.SaveChangesAsync(ct);
return true;
} }
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct) public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)

View File

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

View File

@@ -24,9 +24,7 @@ public sealed class PatientContextService(
if (archive != null) if (archive != null)
{ {
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}"); if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
if (archive.SurgeryHistoryStatus == "无") if (archive.Surgeries.Count > 0)
sb.AppendLine("手术史: 无");
else if (archive.Surgeries.Count > 0)
sb.AppendLine($"手术: {string.Join("", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}"); sb.AppendLine($"手术: {string.Join("", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
else if (!string.IsNullOrEmpty(archive.SurgeryType)) else if (!string.IsNullOrEmpty(archive.SurgeryType))
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})"); sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");

View File

@@ -2,12 +2,14 @@ namespace Health.Application.Auth;
public sealed record AuthResult(int Code, object? Data, string? Message = null); public sealed record AuthResult(int Code, object? Data, string? Message = null);
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId); public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name);
public interface IAuthService public interface IAuthService
{ {
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct); Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct); Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct); Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct);
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct); Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
Task LogoutAsync(string refreshToken, CancellationToken ct); Task LogoutAsync(string refreshToken, CancellationToken ct);
} }

View File

@@ -22,7 +22,6 @@ public sealed record HealthArchiveDto(
IReadOnlyList<string> DietRestrictions, IReadOnlyList<string> DietRestrictions,
IReadOnlyList<string> ChronicDiseases, IReadOnlyList<string> ChronicDiseases,
string? FamilyHistory, string? FamilyHistory,
string? SurgeryHistoryStatus,
DateTime UpdatedAt); DateTime UpdatedAt);
public sealed record HealthArchiveUpdateRequest( public sealed record HealthArchiveUpdateRequest(
@@ -33,8 +32,7 @@ public sealed record HealthArchiveUpdateRequest(
IReadOnlyList<string>? DietRestrictions, IReadOnlyList<string>? DietRestrictions,
IReadOnlyList<string>? ChronicDiseases, IReadOnlyList<string>? ChronicDiseases,
string? FamilyHistory, string? FamilyHistory,
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null, IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
string? SurgeryHistoryStatus = null);
public interface IHealthArchiveService public interface IHealthArchiveService
{ {

View File

@@ -1,7 +1,5 @@
using Health.Domain.Entities; using Health.Domain.Entities;
using System.ComponentModel.DataAnnotations;
namespace Health.Application.HealthArchives; namespace Health.Application.HealthArchives;
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
@@ -16,7 +14,6 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct) public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
{ {
Validate(request);
var archive = await GetOrCreateEntityAsync(userId, ct); var archive = await GetOrCreateEntityAsync(userId, ct);
Apply(archive, request); Apply(archive, request);
archive.UpdatedAt = DateTime.UtcNow; archive.UpdatedAt = DateTime.UtcNow;
@@ -113,14 +110,6 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList(); if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList(); if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory; if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
if (request.SurgeryHistoryStatus != null)
archive.SurgeryHistoryStatus = request.SurgeryHistoryStatus;
}
private static void Validate(HealthArchiveUpdateRequest request)
{
if (request.SurgeryHistoryStatus is not null and not ("无" or "有"))
throw new ValidationException("手术史状态格式不正确");
} }
private static object ToAiResult(HealthArchiveDto archive) => new private static object ToAiResult(HealthArchiveDto archive) => new
@@ -134,7 +123,6 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
archive.DietRestrictions, archive.DietRestrictions,
archive.ChronicDiseases, archive.ChronicDiseases,
archive.FamilyHistory, archive.FamilyHistory,
archive.SurgeryHistoryStatus,
}; };
private static HealthArchiveDto ToDto(HealthArchive archive) => new( private static HealthArchiveDto ToDto(HealthArchive archive) => new(
@@ -148,7 +136,6 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
archive.DietRestrictions, archive.DietRestrictions,
archive.ChronicDiseases, archive.ChronicDiseases,
archive.FamilyHistory, archive.FamilyHistory,
archive.SurgeryHistoryStatus,
archive.UpdatedAt); archive.UpdatedAt);
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive) private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)

View File

@@ -27,7 +27,6 @@ public interface IHealthRecordService
{ {
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct); Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct); Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
Task<IReadOnlyList<Guid>> CreateManyAsync(Guid userId, IReadOnlyList<HealthRecordUpsertRequest> requests, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct); Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct); Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct); Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);

View File

@@ -9,7 +9,7 @@ public static class HealthRecordRules
{ {
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59, HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60, HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
HealthMetricType.Glucose => request.Value > 6.1m || request.Value < 3.9m, HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m,
HealthMetricType.SpO2 => request.Value <= 94, HealthMetricType.SpO2 => request.Value <= 94,
HealthMetricType.Weight => false, HealthMetricType.Weight => false,
_ => false _ => false
@@ -58,3 +58,4 @@ public static class HealthRecordRules
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理"); throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
} }
} }

View File

@@ -49,44 +49,6 @@ public sealed class HealthRecordService(
return record.Id; return record.Id;
} }
public async Task<IReadOnlyList<Guid>> CreateManyAsync(
Guid userId,
IReadOnlyList<HealthRecordUpsertRequest> requests,
CancellationToken ct)
{
if (requests.Count == 0)
throw new ArgumentException("至少需要一条健康记录", nameof(requests));
foreach (var request in requests)
HealthRecordRules.Validate(request);
var now = DateTime.UtcNow;
var records = requests.Select(request => new HealthRecord
{
Id = Guid.NewGuid(),
UserId = userId,
MetricType = request.Type,
Systolic = request.Systolic,
Diastolic = request.Diastolic,
Value = request.Value,
Unit = request.Unit,
Source = request.Source,
RecordedAt = request.RecordedAt ?? now,
CreatedAt = now,
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
}).ToList();
foreach (var record in records)
await _records.AddAsync(record, ct);
await _records.SaveChangesAsync(ct);
foreach (var record in records)
await EnqueueAbnormalNotificationAsync(record, ct);
return records.Select(record => record.Id).ToList();
}
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct) public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
{ {
var record = await _records.GetOwnedAsync(userId, id, ct); var record = await _records.GetOwnedAsync(userId, id, ct);
@@ -139,7 +101,6 @@ public sealed class HealthRecordService(
latest.Diastolic, latest.Diastolic,
latest.Value, latest.Value,
latest.Unit, latest.Unit,
latest.IsAbnormal,
latest.RecordedAt latest.RecordedAt
}; };
} }
@@ -179,7 +140,7 @@ public sealed class HealthRecordService(
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"), ("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
HealthMetricType.HeartRate => HealthMetricType.HeartRate =>
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"), ("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
HealthMetricType.Glucose when record.Value > 6.1m => HealthMetricType.Glucose when record.Value >= 7.0m =>
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"), ("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
HealthMetricType.Glucose => HealthMetricType.Glucose =>
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L低于参考范围。请及时关注身体状况。", "critical", "glucose"), ("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L低于参考范围。请及时关注身体状况。", "critical", "glucose"),

View File

@@ -20,8 +20,7 @@ public sealed record MedicationPatchRequest(
IReadOnlyList<TimeOnly>? TimeOfDay, IReadOnlyList<TimeOnly>? TimeOfDay,
DateOnly? StartDate, DateOnly? StartDate,
DateOnly? EndDate, DateOnly? EndDate,
string? Notes, string? Notes);
bool ClearEndDate = false);
public sealed record MedicationDto( public sealed record MedicationDto(
Guid Id, Guid Id,

View File

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

View File

@@ -54,8 +54,7 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value; if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay); if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value; if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
if (request.ClearEndDate) med.EndDate = null; if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
else if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
ValidateDateRange(med.StartDate, med.EndDate); ValidateDateRange(med.StartDate, med.EndDate);
if (request.Notes != null) med.Notes = request.Notes; if (request.Notes != null) med.Notes = request.Notes;
med.UpdatedAt = DateTime.UtcNow; med.UpdatedAt = DateTime.UtcNow;
@@ -131,10 +130,15 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
foreach (var scheduledTime in med.TimeOfDay) foreach (var scheduledTime in med.TimeOfDay)
{ {
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime); var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
var status = MedicationScheduleStatus.Resolve( string status;
scheduledTime, if (log != null)
now, status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
log?.Status); else if (scheduledTime <= now.AddMinutes(-30))
status = "overdue";
else if (scheduledTime <= now.AddHours(3))
status = "upcoming";
else
continue;
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status)); reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
} }

View File

@@ -58,10 +58,3 @@ public interface INotificationOutboxProcessor
Task RecoverStaleAsync(CancellationToken ct); Task RecoverStaleAsync(CancellationToken ct);
Task<bool> ProcessNextAsync(CancellationToken ct); Task<bool> ProcessNextAsync(CancellationToken ct);
} }
public sealed record ReminderCatchUpResult(int CreatedCount);
public interface IReminderCatchUpService
{
Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct);
}

View File

@@ -4,7 +4,7 @@ namespace Health.Application.Users;
public sealed record UserProfileDto( public sealed record UserProfileDto(
Guid Id, Guid Id,
string Phone, string? Phone, // Apple 用户无手机号
string Role, string Role,
string? Name, string? Name,
string? Gender, string? Gender,
@@ -16,10 +16,6 @@ public sealed record UserProfileUpdateRequest(
string? Gender, string? Gender,
DateOnly? BirthDate); DateOnly? BirthDate);
public sealed record AccountFileReferences(
IReadOnlyList<string> FileUrls,
IReadOnlyList<string> ConversationMetadataJson);
public interface IUserService public interface IUserService
{ {
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct); Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
@@ -30,12 +26,6 @@ public interface IUserService
public interface IUserRepository public interface IUserRepository
{ {
Task<User?> GetAsync(Guid userId, CancellationToken ct); Task<User?> GetAsync(Guid userId, CancellationToken ct);
Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct); Task SaveChangesAsync(CancellationToken ct);
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct); Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
} }
public interface IAccountFileCleanup
{
Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct);
}

View File

@@ -1,11 +1,8 @@
namespace Health.Application.Users; namespace Health.Application.Users;
public sealed class UserService( public sealed class UserService(IUserRepository users) : IUserService
IUserRepository users,
IAccountFileCleanup fileCleanup) : IUserService
{ {
private readonly IUserRepository _users = users; private readonly IUserRepository _users = users;
private readonly IAccountFileCleanup _fileCleanup = fileCleanup;
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct) public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
{ {
@@ -33,10 +30,6 @@ public sealed class UserService(
return true; return true;
} }
public async Task DeleteAccountAsync(Guid userId, CancellationToken ct) public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
{ _users.DeleteAccountDataAsync(userId, ct);
var references = await _users.GetAccountFileReferencesAsync(userId, ct);
await _fileCleanup.DeleteAsync(userId, references, ct);
await _users.DeleteAccountDataAsync(userId, ct);
}
} }

View File

@@ -35,7 +35,6 @@ public sealed class HealthArchive
public List<string> DietRestrictions { get; set; } = []; // 饮食限制 public List<string> DietRestrictions { get; set; } = []; // 饮食限制
public List<string> ChronicDiseases { get; set; } = []; // 慢病史 public List<string> ChronicDiseases { get; set; } = []; // 慢病史
public string? FamilyHistory { get; set; } // 家族病史 public string? FamilyHistory { get; set; } // 家族病史
public string? SurgeryHistoryStatus { get; set; } // 无/有,区分未填写与无手术史
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!; public User User { get; set; } = null!;
@@ -90,10 +89,6 @@ public sealed class NotificationPreference
public bool FollowUpReminder { get; set; } = true; public bool FollowUpReminder { get; set; } = true;
public bool DoctorReply { get; set; } = true; public bool DoctorReply { get; set; } = true;
public bool AbnormalAlert { get; set; } = true; public bool AbnormalAlert { get; set; } = true;
public bool PushEnabled { get; set; } = true;
public bool DndEnabled { get; set; }
public int DndStartMinutes { get; set; } = 22 * 60;
public int DndEndMinutes { get; set; } = 8 * 60;
// 健康录入提醒——总开关 + 5 子项 // 健康录入提醒——总开关 + 5 子项
public bool HealthRecordReminder { get; set; } = true; public bool HealthRecordReminder { get; set; } = true;
public bool HealthRecordReminderBloodPressure { get; set; } = true; public bool HealthRecordReminderBloodPressure { get; set; } = true;

View File

@@ -6,7 +6,8 @@ namespace Health.Domain.Entities;
public sealed class User public sealed class User
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string Phone { get; set; } = string.Empty; public string? Phone { get; set; } // Apple 用户无手机号
public string? AppleUserId { get; set; } // Apple Sign-In 唯一标识
public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin" public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
public Guid? DoctorId { get; set; } // 患者选择的医生 public Guid? DoctorId { get; set; } // 患者选择的医生
public string? Name { get; set; } public string? Name { get; set; }

View File

@@ -43,7 +43,7 @@ public static class MedicationAgentHandler
return toolName switch return toolName switch
{ {
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct), "manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
_ => new { success = false, message = $"未知工具: {toolName}" } _ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
}; };
} }

View File

@@ -37,29 +37,6 @@ public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversatio
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
await _db.ConversationMessages.AddAsync(message, ct); await _db.ConversationMessages.AddAsync(message, ct);
public async Task<int> DeleteLegacyDietCommentaryArtifactsAsync(
Guid userId,
string promptPrefix,
CancellationToken ct)
{
var candidates = await _db.Conversations
.Include(conversation => conversation.Messages)
.Where(conversation =>
conversation.UserId == userId &&
conversation.Messages.Any(message =>
message.Role == MessageRole.User &&
message.Content.StartsWith(promptPrefix)))
.ToListAsync(ct);
var artifacts = candidates
.Where(conversation => DietCommentaryPolicy.IsLegacyArtifact(conversation.Messages))
.ToList();
if (artifacts.Count == 0) return 0;
_db.Conversations.RemoveRange(artifacts);
await _db.SaveChangesAsync(ct);
return artifacts.Count;
}
public void Delete(Conversation conversation) => public void Delete(Conversation conversation) =>
_db.Conversations.Remove(conversation); _db.Conversations.Remove(conversation);

View File

@@ -157,9 +157,9 @@ public sealed class PromptManager
"""; """;
private const string UnifiedPrompt = """ private const string UnifiedPrompt = """
AI AI
1. //// record_health_data 1. //// record_health_data
@@ -182,18 +182,10 @@ public sealed class PromptManager
- -
- - +
- record_health_data98%
- record_health_data116/89
-
-
- record_health_data
- -
- pendingConfirmation=true - pendingConfirmation=true
-
- ///
- 2-5
- -
- -
"""; """;
} }

View File

@@ -3,7 +3,12 @@ using Health.Infrastructure.Services;
namespace Health.Infrastructure.Auth; namespace Health.Infrastructure.Auth;
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService public sealed class AuthService(
AppDbContext db,
JwtProvider jwt,
SmsService sms,
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
) : IAuthService
{ {
private const string AdminPhone = "12345678910"; private const string AdminPhone = "12345678910";
private const string AdminSmsCode = "000000"; private const string AdminSmsCode = "000000";
@@ -11,6 +16,7 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
private readonly AppDbContext _db = db; private readonly AppDbContext _db = db;
private readonly JwtProvider _jwt = jwt; private readonly JwtProvider _jwt = jwt;
private readonly SmsService _sms = sms; private readonly SmsService _sms = sms;
private readonly AppleTokenValidator _appleValidator = appleValidator;
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct) public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
{ {
@@ -99,13 +105,64 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
{
// 1. 验证 Apple identityToken
string appleUserId;
try
{
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
}
catch (Exception ex)
{
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
}
// 2. 查找已有用户
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
if (existingUser != null)
{
existingUser.UpdatedAt = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new
{
tokens.accessToken, tokens.refreshToken,
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
});
}
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
var newUser = new User
{
Id = Guid.NewGuid(),
Phone = null,
AppleUserId = appleUserId,
Role = "User",
Name = command.Name,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_db.Users.Add(newUser);
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new
{
newTokens.accessToken, newTokens.refreshToken,
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
});
}
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) => private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed) _db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct); .OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role) private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
{ {
var access = _jwt.GenerateAccessToken(userId, phone, role); var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
var refresh = _jwt.GenerateRefreshToken(); var refresh = _jwt.GenerateRefreshToken();
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) }); _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
return (access, refresh); return (access, refresh);

View File

@@ -13,8 +13,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Health.Infrastructure.Data.Migrations namespace Health.Infrastructure.Data.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260713022401_AddPushAndDndPreferences")] [Migration("20260710052811_AddAppleSignIn")]
partial class AddPushAndDndPreferences partial class AddAppleSignIn
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -676,15 +676,6 @@ namespace Health.Infrastructure.Data.Migrations
b.Property<bool>("AbnormalAlert") b.Property<bool>("AbnormalAlert")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("DndEnabled")
.HasColumnType("boolean");
b.Property<int>("DndEndMinutes")
.HasColumnType("integer");
b.Property<int>("DndStartMinutes")
.HasColumnType("integer");
b.Property<bool>("DoctorReply") b.Property<bool>("DoctorReply")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -712,9 +703,6 @@ namespace Health.Infrastructure.Data.Migrations
b.Property<bool>("MedicationReminder") b.Property<bool>("MedicationReminder")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("PushEnabled")
.HasColumnType("boolean");
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -818,6 +806,9 @@ namespace Health.Infrastructure.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("AppleUserId")
.HasColumnType("text");
b.Property<string>("AvatarUrl") b.Property<string>("AvatarUrl")
.HasColumnType("text"); .HasColumnType("text");
@@ -837,7 +828,6 @@ namespace Health.Infrastructure.Data.Migrations
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Phone") b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Role") b.Property<string>("Role")
@@ -852,6 +842,9 @@ namespace Health.Infrastructure.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AppleUserId")
.IsUnique();
b.HasIndex("DoctorId"); b.HasIndex("DoctorId");
b.HasIndex("Phone") b.HasIndex("Phone")

View File

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

View File

@@ -1,62 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPushAndDndPreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "DndEnabled",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "DndEndMinutes",
table: "NotificationPreferences",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "DndStartMinutes",
table: "NotificationPreferences",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<bool>(
name: "PushEnabled",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DndEnabled",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "DndEndMinutes",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "DndStartMinutes",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "PushEnabled",
table: "NotificationPreferences");
}
}
}

View File

@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddHealthArchiveSurgeryStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SurgeryHistoryStatus",
table: "HealthArchives",
type: "character varying(8)",
maxLength: 8,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SurgeryHistoryStatus",
table: "HealthArchives");
}
}
}

View File

@@ -486,10 +486,6 @@ namespace Health.Infrastructure.Data.Migrations
b.Property<DateOnly?>("SurgeryDate") b.Property<DateOnly?>("SurgeryDate")
.HasColumnType("date"); .HasColumnType("date");
b.Property<string>("SurgeryHistoryStatus")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("SurgeryType") b.Property<string>("SurgeryType")
.HasColumnType("text"); .HasColumnType("text");
@@ -677,15 +673,6 @@ namespace Health.Infrastructure.Data.Migrations
b.Property<bool>("AbnormalAlert") b.Property<bool>("AbnormalAlert")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("DndEnabled")
.HasColumnType("boolean");
b.Property<int>("DndEndMinutes")
.HasColumnType("integer");
b.Property<int>("DndStartMinutes")
.HasColumnType("integer");
b.Property<bool>("DoctorReply") b.Property<bool>("DoctorReply")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -713,9 +700,6 @@ namespace Health.Infrastructure.Data.Migrations
b.Property<bool>("MedicationReminder") b.Property<bool>("MedicationReminder")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("PushEnabled")
.HasColumnType("boolean");
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -819,6 +803,9 @@ namespace Health.Infrastructure.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("AppleUserId")
.HasColumnType("text");
b.Property<string>("AvatarUrl") b.Property<string>("AvatarUrl")
.HasColumnType("text"); .HasColumnType("text");
@@ -838,7 +825,6 @@ namespace Health.Infrastructure.Data.Migrations
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Phone") b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Role") b.Property<string>("Role")
@@ -853,6 +839,9 @@ namespace Health.Infrastructure.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AppleUserId")
.IsUnique();
b.HasIndex("DoctorId"); b.HasIndex("DoctorId");
b.HasIndex("Phone") b.HasIndex("Phone")

View File

@@ -50,6 +50,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
builder.Entity<User>(e => builder.Entity<User>(e =>
{ {
e.HasIndex(u => u.Phone).IsUnique(); e.HasIndex(u => u.Phone).IsUnique();
e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User"); e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull); e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
}); });
@@ -170,7 +171,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
builder.Entity<HealthArchive>(e => builder.Entity<HealthArchive>(e =>
{ {
e.HasIndex(a => a.UserId).IsUnique(); e.HasIndex(a => a.UserId).IsUnique();
e.Property(a => a.SurgeryHistoryStatus).HasMaxLength(8);
}); });
builder.Entity<HealthArchiveSurgery>(e => builder.Entity<HealthArchiveSurgery>(e =>

View File

@@ -1,284 +0,0 @@
using Health.Application.Notifications;
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Infrastructure.Notifications;
public sealed class EfReminderCatchUpService(AppDbContext db) : IReminderCatchUpService
{
private readonly AppDbContext _db = db;
public async Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct)
{
var pref = await GetPreferenceAsync(userId, ct);
if (!pref.PushEnabled) return new ReminderCatchUpResult(0);
var beijingNow = utcNow.AddHours(8);
var today = DateOnly.FromDateTime(beijingNow);
var todayStartUtc = beijingNow.Date.AddHours(-8);
var todayEndUtc = todayStartUtc.AddDays(1);
var created = 0;
created += await CreateHealthRecordReminderAsync(userId, pref, beijingNow, todayStartUtc, todayEndUtc, ct);
created += await CreateMedicationRemindersAsync(userId, pref, beijingNow, today, todayStartUtc, todayEndUtc, ct);
created += await CreateExerciseRemindersAsync(userId, pref, beijingNow, today, ct);
created += await CreateFollowUpRemindersAsync(userId, pref, beijingNow, ct);
if (created > 0)
await _db.SaveChangesAsync(ct);
return new ReminderCatchUpResult(created);
}
private async Task<NotificationPreference> GetPreferenceAsync(Guid userId, CancellationToken ct)
{
var pref = await _db.NotificationPreferences.FirstOrDefaultAsync(x => x.UserId == userId, ct);
if (pref != null) return pref;
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
await _db.NotificationPreferences.AddAsync(pref, ct);
return pref;
}
private async Task<int> CreateHealthRecordReminderAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
DateTime todayStartUtc,
DateTime todayEndUtc,
CancellationToken ct)
{
if (!pref.HealthRecordReminder || IsInDnd(pref, beijingNow)) return 0;
var window = HealthRecordWindow(beijingNow);
if (window == null) return 0;
var recordedTypes = await _db.HealthRecords.AsNoTracking()
.Where(r => r.UserId == userId && r.RecordedAt >= todayStartUtc && r.RecordedAt < todayEndUtc)
.Select(r => r.MetricType)
.Distinct()
.ToListAsync(ct);
var missing = EnabledHealthMetrics(pref)
.Where(x => !recordedTypes.Contains(x.Type))
.ToList();
if (missing.Count == 0) return 0;
var date = DateOnly.FromDateTime(beijingNow);
var sourceId = StableGuid($"health_record|{userId:N}|{date:yyyyMMdd}|{window}");
return await AddNotificationIfMissingAsync(
userId,
sourceId,
"health_record_reminder",
window == "evening" ? "今日健康指标还未完成" : "记得录入今日健康指标",
$"建议录入:{string.Join("", missing.Select(x => x.Label))}",
"info",
"health",
null,
ct);
}
private async Task<int> CreateMedicationRemindersAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
DateOnly today,
DateTime todayStartUtc,
DateTime todayEndUtc,
CancellationToken ct)
{
if (!pref.MedicationReminder || IsInDnd(pref, beijingNow)) return 0;
var now = TimeOnly.FromDateTime(beijingNow);
var earliest = now.AddHours(-4);
var medications = await _db.Medications.AsNoTracking()
.Where(m => m.UserId == userId && m.IsActive)
.ToListAsync(ct);
var created = 0;
foreach (var medication in medications.Where(m => IsMedicationActiveOn(m, today) && ShouldDoseToday(m, today)))
{
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInTimeWindow(t, earliest, now)))
{
var logged = await _db.MedicationLogs.AsNoTracking().AnyAsync(l =>
l.UserId == userId &&
l.MedicationId == medication.Id &&
l.ScheduledTime == scheduledTime &&
l.CreatedAt >= todayStartUtc &&
l.CreatedAt < todayEndUtc, ct);
if (logged) continue;
var sourceId = StableGuid($"medication|{userId:N}|{medication.Id:N}|{today:yyyyMMdd}|{scheduledTime:HHmm}");
var details = string.Join(" ", new[] { medication.Dosage, scheduledTime.ToString("HH:mm") }
.Where(x => !string.IsNullOrWhiteSpace(x)));
created += await AddNotificationIfMissingAsync(
userId,
sourceId,
"MedicationReminder",
"用药时间到了",
$"请按计划服用{medication.Name}{(details.Length > 0 ? $"{details}" : "")},服用后记得打卡。",
"info",
"medication",
medication.Id.ToString(),
ct);
}
}
return created;
}
private async Task<int> CreateExerciseRemindersAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
DateOnly today,
CancellationToken ct)
{
if (IsInDnd(pref, beijingNow)) return 0;
var now = TimeOnly.FromDateTime(beijingNow);
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
.Include(x => x.Plan)
.Where(x => x.Plan.UserId == userId &&
x.ScheduledDate == today &&
!x.IsCompleted &&
!x.IsRestDay &&
x.Plan.ReminderTime <= now)
.ToListAsync(ct);
var created = 0;
foreach (var item in dueItems)
{
var sourceId = StableGuid($"exercise|{userId:N}|{item.Id:N}|{today:yyyyMMdd}");
created += await AddNotificationIfMissingAsync(
userId,
sourceId,
"ExerciseReminder",
"今日运动计划",
$"今天安排了{item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
"info",
"exercise",
item.PlanId.ToString(),
ct);
}
return created;
}
private async Task<int> CreateFollowUpRemindersAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
CancellationToken ct)
{
if (!pref.FollowUpReminder || IsInDnd(pref, beijingNow)) return 0;
var nowUtc = beijingNow.AddHours(-8);
var upcomingEndUtc = nowUtc.AddHours(24);
var followUps = await _db.FollowUps.AsNoTracking()
.Where(x => x.UserId == userId && x.ScheduledAt >= nowUtc && x.ScheduledAt <= upcomingEndUtc)
.ToListAsync(ct);
var created = 0;
foreach (var followUp in followUps)
{
var sourceId = StableGuid($"followup|{userId:N}|{followUp.Id:N}|24h");
created += await AddNotificationIfMissingAsync(
userId,
sourceId,
"follow_up_reminder",
"复查提醒",
$"{followUp.Title} 即将到期,请提前安排时间。",
"info",
"followup",
followUp.Id.ToString(),
ct);
}
return created;
}
private async Task<int> AddNotificationIfMissingAsync(
Guid userId,
Guid sourceId,
string type,
string title,
string message,
string severity,
string? actionType,
string? actionTargetId,
CancellationToken ct)
{
if (await _db.UserNotifications.AsNoTracking().AnyAsync(x => x.SourceId == sourceId, ct))
return 0;
await _db.UserNotifications.AddAsync(new UserNotification
{
Id = Guid.NewGuid(),
UserId = userId,
SourceId = sourceId,
Type = type,
Title = title,
Message = message,
Severity = severity,
ActionType = actionType,
ActionTargetId = actionTargetId,
CreatedAt = DateTime.UtcNow,
}, ct);
return 1;
}
private static string? HealthRecordWindow(DateTime beijingNow)
{
var minutes = beijingNow.Hour * 60 + beijingNow.Minute;
if (minutes is >= 7 * 60 and <= 10 * 60 + 30) return "morning";
if (minutes is >= 11 * 60 + 30 and <= 14 * 60) return "noon";
if (minutes is >= 18 * 60 and <= 21 * 60 + 30) return "evening";
return null;
}
private static IReadOnlyList<(HealthMetricType Type, string Label)> EnabledHealthMetrics(NotificationPreference pref)
{
var result = new List<(HealthMetricType, string)>();
if (pref.HealthRecordReminderBloodPressure) result.Add((HealthMetricType.BloodPressure, "血压"));
if (pref.HealthRecordReminderHeartRate) result.Add((HealthMetricType.HeartRate, "心率"));
if (pref.HealthRecordReminderGlucose) result.Add((HealthMetricType.Glucose, "血糖"));
if (pref.HealthRecordReminderSpO2) result.Add((HealthMetricType.SpO2, "血氧"));
if (pref.HealthRecordReminderWeight) result.Add((HealthMetricType.Weight, "体重"));
return result;
}
private static bool IsInDnd(NotificationPreference pref, DateTime beijingNow)
{
if (!pref.DndEnabled) return false;
var minuteOfDay = beijingNow.Hour * 60 + beijingNow.Minute;
return pref.DndStartMinutes <= pref.DndEndMinutes
? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes
: minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes;
}
private static bool IsMedicationActiveOn(Medication medication, DateOnly date) =>
(medication.StartDate == null || medication.StartDate <= date) &&
(medication.EndDate == null || medication.EndDate >= date);
private static bool ShouldDoseToday(Medication medication, DateOnly today)
{
if (medication.Frequency is MedicationFrequency.Daily
or MedicationFrequency.TwiceDaily
or MedicationFrequency.ThreeTimesDaily
or MedicationFrequency.AsNeeded)
return true;
var startDate = medication.StartDate ?? today;
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
if (medication.Frequency == MedicationFrequency.Weekly)
return today.DayOfWeek == startDate.DayOfWeek;
return true;
}
private static bool IsInTimeWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
end >= start ? value >= start && value <= end : value >= start || value <= end;
private static Guid StableGuid(string key)
{
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
return new Guid(bytes);
}
}

View File

@@ -15,12 +15,6 @@ public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificat
NotificationMessage message, NotificationMessage message,
CancellationToken ct) CancellationToken ct)
{ {
var pushEnabled = await _db.NotificationPreferences.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => (bool?)x.PushEnabled)
.FirstOrDefaultAsync(ct);
if (pushEnabled is false) return false;
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct)) if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
return false; return false;
@@ -73,7 +67,7 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts) x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters .ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed") .SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.LastError, "Notification task timed out and reached max retry count") .SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct); .SetProperty(x => x.UpdatedAt, now), ct);
} }
@@ -87,89 +81,59 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
if (candidate == null) return false; if (candidate == null) return false;
var task = await _db.NotificationOutbox var claimed = await _db.NotificationOutbox
.FirstOrDefaultAsync(x => x.Id == candidate.Id && x.Status == "Pending", ct); .Where(x => x.Id == candidate.Id && x.Status == "Pending")
if (task == null) return true; .ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
task.Status = "Processing"; .SetProperty(x => x.Attempts, x => x.Attempts + 1)
task.Attempts += 1; .SetProperty(x => x.UpdatedAt, now), ct);
task.UpdatedAt = now; if (claimed == 0) return true;
await _db.SaveChangesAsync(ct);
try try
{ {
var message = JsonSerializer.Deserialize<NotificationMessage>(task.Payload) var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
?? throw new InvalidOperationException("Notification payload is empty"); ?? throw new InvalidOperationException("通知消息内容为空");
var preference = await _db.NotificationPreferences.AsNoTracking() if (!await _db.UserNotifications.AsNoTracking()
.FirstOrDefaultAsync(x => x.UserId == task.UserId, ct); .AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
var deliver = preference == null || ShouldDeliver(preference, message.Type, DateTime.UtcNow.AddHours(8));
if (deliver && !await _db.UserNotifications.AsNoTracking()
.AnyAsync(x => x.SourceId == task.SourceTaskId, ct))
{ {
await _db.UserNotifications.AddAsync(new UserNotification await _db.UserNotifications.AddAsync(new UserNotification
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
UserId = task.UserId, UserId = candidate.UserId,
SourceId = task.SourceTaskId, SourceId = candidate.SourceTaskId,
Type = message.Type, Type = message.Type,
Title = message.Title, Title = message.Title,
Message = message.Message, Message = message.Message,
Severity = message.Severity, Severity = message.Severity,
ActionType = message.ActionType, ActionType = message.ActionType,
ActionTargetId = message.ActionTargetId, ActionTargetId = message.ActionTargetId,
CreatedAt = task.CreatedAt, CreatedAt = candidate.CreatedAt,
}, ct); }, ct);
// Persist the inbox item first. If completion fails, stale-task
// recovery can safely retry because SourceId is idempotent.
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
task.Status = "Completed"; await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
task.LastError = null; .ExecuteUpdateAsync(setters => setters
task.UpdatedAt = DateTime.UtcNow; .SetProperty(x => x.Status, "Completed")
await _db.SaveChangesAsync(ct); .SetProperty(x => x.LastError, (string?)null)
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
task.Status = task.Attempts >= MaxAttempts ? "Failed" : "Pending"; var attempts = candidate.Attempts + 1;
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5); await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
task.LastError = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message; .ExecuteUpdateAsync(setters => setters
task.UpdatedAt = DateTime.UtcNow; .SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
await _db.SaveChangesAsync(ct); .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);
} }
return true; return true;
} }
private static bool ShouldDeliver(NotificationPreference preference, string type, DateTime nowCst)
{
if (!preference.PushEnabled) return false;
var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute;
var inDnd = preference.DndEnabled && (preference.DndStartMinutes <= preference.DndEndMinutes
? minuteOfDay >= preference.DndStartMinutes && minuteOfDay < preference.DndEndMinutes
: minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes);
if (inDnd) return false;
return NormalizeType(type) switch
{
"medication_reminder" => preference.MedicationReminder,
"exercise_reminder" => true,
"follow_up_reminder" => preference.FollowUpReminder,
"doctor_reply" => preference.DoctorReply,
"abnormal_alert" => preference.AbnormalAlert,
"health_record_reminder" => preference.HealthRecordReminder,
"health_metric_alert" => preference.AbnormalAlert,
_ => true,
};
}
private static string NormalizeType(string type) => type switch
{
"MedicationReminder" => "medication_reminder",
"ExerciseReminder" => "exercise_reminder",
"HealthMetricAlert" => "health_metric_alert",
"FollowUpReminder" => "follow_up_reminder",
_ => type.ToLowerInvariant(),
};
} }

View File

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

View File

@@ -9,44 +9,6 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
public Task<User?> GetAsync(Guid userId, CancellationToken ct) => public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct); _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
public async Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
{
var fileUrls = new List<string>();
var userAvatar = await _db.Users
.Where(u => u.Id == userId)
.Select(u => u.AvatarUrl)
.FirstOrDefaultAsync(ct);
AddIfPresent(fileUrls, userAvatar);
var doctorProfile = await _db.DoctorProfiles
.Where(p => p.UserId == userId)
.Select(p => new { p.DoctorId, p.AvatarUrl })
.FirstOrDefaultAsync(ct);
AddIfPresent(fileUrls, doctorProfile?.AvatarUrl);
if (doctorProfile?.DoctorId != null)
{
var doctorAvatar = await _db.Doctors
.Where(d => d.Id == doctorProfile.DoctorId)
.Select(d => d.AvatarUrl)
.FirstOrDefaultAsync(ct);
AddIfPresent(fileUrls, doctorAvatar);
}
fileUrls.AddRange(await _db.Reports
.Where(r => r.UserId == userId && r.FileUrl != "")
.Select(r => r.FileUrl)
.ToListAsync(ct));
var metadata = await _db.ConversationMessages
.Where(m => m.Conversation!.UserId == userId && m.MetadataJson != null && m.MetadataJson != "")
.Select(m => m.MetadataJson!)
.ToListAsync(ct);
return new AccountFileReferences(fileUrls.Distinct().ToList(), metadata);
}
public Task SaveChangesAsync(CancellationToken ct) => public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct); _db.SaveChangesAsync(ct);
@@ -88,9 +50,4 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
} }
private static void AddIfPresent(List<string> values, string? value)
{
if (!string.IsNullOrWhiteSpace(value)) values.Add(value);
}
} }

View File

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

View File

@@ -1,66 +1,107 @@
using Health.Application.Notifications; using Health.Application.Notifications;
using Health.Domain.Enums;
namespace Health.WebApi.BackgroundServices; namespace Health.WebApi.BackgroundServices;
/// <summary> /// <summary>
/// Produces in-app catch-up reminders for health records. /// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
/// The actual reminder rules live in IReminderCatchUpService so foreground /// 对未录入指标且开关开启的用户推送提醒。
/// app checks and background scans stay consistent. /// 中国时区 UTC+8。
/// </summary> /// </summary>
public sealed class HealthRecordReminderService( public sealed class HealthRecordReminderService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<HealthRecordReminderService> logger) : BackgroundService ILogger<HealthRecordReminderService> logger) : BackgroundService
{ {
private static readonly int[] ReminderHoursCst = [9, 13, 19]; private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<HealthRecordReminderService> _logger = logger;
// 北京时间 9:00 和 12:00 触发
private static readonly int[] ReminderHoursCst = [9, 12];
private DateTime _lastFired = DateTime.MinValue; private DateTime _lastFired = DateTime.MinValue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
logger.LogInformation("Health record in-app reminder service started"); _logger.LogInformation("健康录入提醒服务已启动");
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
try try
{ {
var nowCst = DateTime.UtcNow.AddHours(8); var nowCst = DateTime.UtcNow.AddHours(8);
if (ReminderHoursCst.Contains(nowCst.Hour) && // 当前小时是否在触发列表,且今天该小时还没触发过
(_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour)) if (ReminderHoursCst.Contains(nowCst.Hour)
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
{ {
await CheckUsersAsync(DateTime.UtcNow, stoppingToken); await ScanAndPushAsync(nowCst, stoppingToken);
_lastFired = nowCst; _lastFired = nowCst;
} }
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
{ catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Health record in-app reminder scan failed");
}
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
} }
} }
private async Task CheckUsersAsync(DateTime utcNow, CancellationToken ct) private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
{ {
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var catchUp = scope.ServiceProvider.GetRequiredService<IReminderCatchUpService>(); var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
var userIds = await db.NotificationPreferences.AsNoTracking() var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
.Where(x => x.PushEnabled && x.HealthRecordReminder) var todayEnd = todayStart.AddDays(1);
.Select(x => x.UserId)
// 取所有启用了健康录入提醒的用户偏好
var prefs = await db.NotificationPreferences
.Where(p => p.HealthRecordReminder)
.ToListAsync(ct); .ToListAsync(ct);
var created = 0; foreach (var pref in prefs)
foreach (var userId in userIds)
{ {
var result = await catchUp.CheckDueAsync(userId, utcNow, ct); // 该用户当天已录入的指标
created += result.CreatedCount; var recordedTypes = await db.HealthRecords
} .Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
.Select(r => r.MetricType)
.Distinct()
.ToListAsync(ct);
if (created > 0) // 哪些指标启用开关但当天没录入
logger.LogInformation("Created {Count} in-app catch-up reminders", created); var missing = new List<(HealthMetricType type, bool enabled, string label)>
{
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
};
var missingLabels = missing
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
.Select(m => m.label)
.ToList();
if (missingLabels.Count == 0) continue;
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
var sourceId = MakeSourceId(pref.UserId, nowCst);
var summary = string.Join("、", missingLabels);
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
Type: "health_record_reminder",
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
Message: $"建议录入:{summary}",
Severity: "info",
ActionType: "health",
ActionTargetId: null), ct);
}
}
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
{
// 通过哈希构造稳定的 Guid保证同一用户同一时段不重复
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
return new Guid(bytes);
} }
} }

View File

@@ -321,12 +321,8 @@ public static class AiChatEndpoints
var userId = GetUserId(http); var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var deleted = await conversations.DeleteAsync(userId.Value, id, ct); await conversations.DeleteAsync(userId.Value, id, ct);
return deleted return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
: Results.Json(
new { code = 40401, data = (object?)null, message = "对话不存在" },
statusCode: StatusCodes.Status404NotFound);
}); });
// 一键清空当前用户的全部对话 // 一键清空当前用户的全部对话
@@ -339,49 +335,6 @@ public static class AiChatEndpoints
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
}); });
// 饮食分析页专用建议:不创建会话,不保存提示词或回复到历史记录。
app.MapPost("/api/ai/diet-commentary", async (
DietCommentaryRequest request,
HttpContext http,
DeepSeekClient llmClient,
IPatientContextService patientContexts,
CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null)
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
var foods = request.Foods?
.Where(food => !string.IsNullOrWhiteSpace(food.Name) &&
!string.IsNullOrWhiteSpace(food.Portion) &&
food.Calories > 0)
.Take(20)
.Select(food => new DietCommentaryFood(food.Name, food.Portion, food.Calories))
.ToList() ?? [];
if (foods.Count == 0)
return Results.Ok(new { code = 40001, data = (object?)null, message = "请先完善食物信息" });
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
var foodDescription = DietCommentaryPolicy.BuildFoodDescription(foods);
var response = await llmClient.ChatAsync(
[
new ChatMessage
{
Role = "system",
Content = DietCommentaryPolicy.SystemPrompt + "\n\n当前用户健康档案\n" + patientContext,
},
new ChatMessage { Role = "user", Content = "本餐食物:" + foodDescription },
],
maxTokens: 300,
temperature: 0.3f,
ct: ct);
var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
if (string.IsNullOrWhiteSpace(commentary))
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" });
return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
}).RequireAuthorization();
app.MapPost("/api/ai/analyze-food-image", async ( app.MapPost("/api/ai/analyze-food-image", async (
HttpRequest httpRequest, HttpRequest httpRequest,
HttpContext http, HttpContext http,
@@ -670,7 +623,7 @@ public static class AiChatEndpoints
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60); AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
break; break;
case "glucose": case "glucose":
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v > 6.1m || v < 3.9m); AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m);
break; break;
case "spo2": case "spo2":
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94); AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
@@ -865,6 +818,3 @@ public static class AiChatEndpoints
} }
} }
public sealed record DietCommentaryFoodRequest(string Name, string Portion, int Calories);
public sealed record DietCommentaryRequest(IReadOnlyList<DietCommentaryFoodRequest>? Foods);

View File

@@ -12,6 +12,8 @@ public static class AuthEndpoints
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct))); ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) => app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct))); ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) => app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
ToResult(await auth.RefreshAsync(request.RefreshToken, ct))); ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) => app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
@@ -28,4 +30,5 @@ public static class AuthEndpoints
public sealed record SendSmsRequest(string Phone); public sealed record SendSmsRequest(string Phone);
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId); public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
public sealed record LoginRequest(string Phone, string SmsCode); public sealed record LoginRequest(string Phone, string SmsCode);
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
public sealed record RefreshRequest(string RefreshToken); public sealed record RefreshRequest(string RefreshToken);

View File

@@ -26,10 +26,8 @@ public static class DietEndpoints
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) => group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var deleted = await diets.DeleteAsync(userId, id, ct); await diets.DeleteAsync(userId, id, ct);
return deleted return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
: Results.Ok(new { code = 40004, data = (object?)null, message = "饮食记录不存在" });
}); });
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) => group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>

View File

@@ -29,20 +29,6 @@ public static class DoctorEndpoints
return profile?.DoctorId; return profile?.DoctorId;
} }
public static (DateTime StartUtc, DateTime EndUtc) GetBeijingDayRange(DateTime utcNow)
{
var beijingNow = utcNow.ToUniversalTime().AddHours(8);
var startUtc = new DateTime(
beijingNow.Year,
beijingNow.Month,
beijingNow.Day,
0,
0,
0,
DateTimeKind.Utc).AddHours(-8);
return (startUtc, startUtc.AddDays(1));
}
public static void MapDoctorEndpoints(this WebApplication app) public static void MapDoctorEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("/api/doctor").RequireAuthorization(); var group = app.MapGroup("/api/doctor").RequireAuthorization();
@@ -70,7 +56,8 @@ public static class DoctorEndpoints
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId); var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed); var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor); var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
var (todayStart, todayEnd) = GetBeijingDayRange(DateTime.UtcNow); var todayStart = DateTime.UtcNow.Date;
var todayEnd = todayStart.AddDays(1);
var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming); var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
// 待办列表 // 待办列表
@@ -203,8 +190,7 @@ public static class DoctorEndpoints
user.HealthArchive.Allergies, user.HealthArchive.Allergies,
user.HealthArchive.DietRestrictions, user.HealthArchive.DietRestrictions,
user.HealthArchive.ChronicDiseases, user.HealthArchive.ChronicDiseases,
user.HealthArchive.FamilyHistory, user.HealthArchive.FamilyHistory
user.HealthArchive.SurgeryHistoryStatus
}, },
latestRecords, latestRecords,
trendRecords, trendRecords,

View File

@@ -57,10 +57,8 @@ public static class ExerciseEndpoints
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) => group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var deleted = await exercises.DeleteAsync(userId, id, ct); await exercises.DeleteAsync(userId, id, ct);
return deleted return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
: Results.Ok(new { code = 40004, data = (object?)null, message = "运动计划不存在" });
}); });
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) => group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>

View File

@@ -12,9 +12,8 @@ public static class FileEndpoints
{ {
var group = app.MapGroup("/api/files").RequireAuthorization(); var group = app.MapGroup("/api/files").RequireAuthorization();
group.MapPost("/upload", async (HttpContext http, CancellationToken ct) => group.MapPost("/upload", async (HttpRequest request, CancellationToken ct) =>
{ {
var request = http.Request;
if (!request.HasFormContentType) if (!request.HasFormContentType)
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" }); return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
@@ -24,12 +23,7 @@ public static class FileEndpoints
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" }); return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
var results = new List<object>(); var results = new List<object>();
var userDirectoryName = GetUserId(http).ToString("N"); var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
var uploadsDir = Path.Combine(
Directory.GetCurrentDirectory(),
"uploads",
"users",
userDirectoryName);
Directory.CreateDirectory(uploadsDir); Directory.CreateDirectory(uploadsDir);
foreach (var file in files) foreach (var file in files)
@@ -54,7 +48,7 @@ public static class FileEndpoints
id = fileId, id = fileId,
name = file.FileName, name = file.FileName,
size = file.Length, size = file.Length,
url = $"/uploads/users/{userDirectoryName}/{storedName}", url = $"/uploads/{storedName}",
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
}); });
} }
@@ -62,9 +56,4 @@ public static class FileEndpoints
return Results.Ok(new { code = 0, data = results, message = (string?)null }); return Results.Ok(new { code = 0, data = results, message = (string?)null });
}); });
} }
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
? id
: Guid.Empty;
} }

View File

@@ -34,20 +34,6 @@ public static class HealthEndpoints
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
}); });
group.MapPost("/batch", async (
IReadOnlyList<CreateHealthRecordRequest> requests,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{
var userId = GetUserId(http);
var ids = await healthRecords.CreateManyAsync(
userId,
requests.Select(request => request.ToServiceRequest()).ToList(),
ct);
return Results.Ok(new { code = 0, data = new { ids }, message = (string?)null });
});
group.MapPut("/{id:guid}", async ( group.MapPut("/{id:guid}", async (
Guid id, Guid id,
CreateHealthRecordRequest req, CreateHealthRecordRequest req,

View File

@@ -36,10 +36,8 @@ public static class MedicationEndpoints
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) => group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var deleted = await medications.DeleteAsync(userId, id, ct); await medications.DeleteAsync(userId, id, ct);
return deleted return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
: Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" });
}); });
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) => group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
@@ -77,10 +75,8 @@ public static class MedicationEndpoints
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) => group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var cancelled = await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct); await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
return cancelled return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
: Results.Ok(new { code = 40004, data = (object?)null, message = "未找到对应的服药打卡记录" });
}); });
} }
@@ -114,19 +110,14 @@ public static class MedicationEndpoints
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq)) if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
frequency = freq; frequency = freq;
var hasEndDate = root.TryGetProperty("endDate", out var ed);
var clearEndDate = hasEndDate &&
(ed.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(ed.GetString()));
return new MedicationPatchRequest( return new MedicationPatchRequest(
root.TryGetProperty("name", out var n) ? n.GetString() : null, root.TryGetProperty("name", out var n) ? n.GetString() : null,
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null, root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
frequency, frequency,
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null, root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null, root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
hasEndDate && !clearEndDate && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : 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, root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
clearEndDate);
} }
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName) private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)

View File

@@ -19,14 +19,6 @@ public static class NotificationEndpoints
return Results.Ok(new { code = 0, data = result, message = (string?)null }); return Results.Ok(new { code = 0, data = result, message = (string?)null });
}); });
group.MapPost("/check-due", async (HttpContext http, IReminderCatchUpService reminders, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var result = await reminders.CheckDueAsync(userId, DateTime.UtcNow, ct);
return Results.Ok(new { code = 0, data = result, message = (string?)null });
});
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) => group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
@@ -98,10 +90,6 @@ public static class NotificationEndpoints
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value; if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value; if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value; if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
if (body.PushEnabled.HasValue) pref.PushEnabled = body.PushEnabled.Value;
if (body.DndEnabled.HasValue) pref.DndEnabled = body.DndEnabled.Value;
if (body.DndStartMinutes is >= 0 and < 1440) pref.DndStartMinutes = body.DndStartMinutes.Value;
if (body.DndEndMinutes is >= 0 and < 1440) pref.DndEndMinutes = body.DndEndMinutes.Value;
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value; if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value; if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value; if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
@@ -112,7 +100,7 @@ public static class NotificationEndpoints
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00 // 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
if (body.HealthRecordReminder is true && pref.PushEnabled && pref.HealthRecordReminder) if (body.HealthRecordReminder is true && pref.HealthRecordReminder)
{ {
var nowCst = DateTime.UtcNow.AddHours(8); var nowCst = DateTime.UtcNow.AddHours(8);
var todayStart = nowCst.Date.AddHours(-8); var todayStart = nowCst.Date.AddHours(-8);
@@ -165,10 +153,6 @@ public static class NotificationEndpoints
followUpReminder = pref.FollowUpReminder, followUpReminder = pref.FollowUpReminder,
doctorReply = pref.DoctorReply, doctorReply = pref.DoctorReply,
abnormalAlert = pref.AbnormalAlert, abnormalAlert = pref.AbnormalAlert,
pushEnabled = pref.PushEnabled,
dndEnabled = pref.DndEnabled,
dndStartMinutes = pref.DndStartMinutes,
dndEndMinutes = pref.DndEndMinutes,
healthRecordReminder = pref.HealthRecordReminder, healthRecordReminder = pref.HealthRecordReminder,
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure, healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate, healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
@@ -183,10 +167,6 @@ public sealed record NotificationPrefsUpdateDto(
bool? FollowUpReminder, bool? FollowUpReminder,
bool? DoctorReply, bool? DoctorReply,
bool? AbnormalAlert, bool? AbnormalAlert,
bool? PushEnabled,
bool? DndEnabled,
int? DndStartMinutes,
int? DndEndMinutes,
bool? HealthRecordReminder, bool? HealthRecordReminder,
bool? HealthRecordReminderBloodPressure, bool? HealthRecordReminderBloodPressure,
bool? HealthRecordReminderHeartRate, bool? HealthRecordReminderHeartRate,

View File

@@ -49,8 +49,7 @@ public static class UserEndpoints
req.DietRestrictions, req.DietRestrictions,
req.ChronicDiseases, req.ChronicDiseases,
req.FamilyHistory, req.FamilyHistory,
surgeries, surgeries),
req.SurgeryHistoryStatus),
ct); ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
@@ -71,7 +70,6 @@ public sealed record UpdateArchiveRequest(
string? Diagnosis, string? SurgeryType, string? SurgeryDate, string? Diagnosis, string? SurgeryType, string? SurgeryDate,
List<string>? Allergies, List<string>? DietRestrictions, List<string>? Allergies, List<string>? DietRestrictions,
List<string>? ChronicDiseases, string? FamilyHistory, List<string>? ChronicDiseases, string? FamilyHistory,
List<UpdateArchiveSurgeryRequest>? Surgeries, List<UpdateArchiveSurgeryRequest>? Surgeries);
string? SurgeryHistoryStatus);
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date); public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);

View File

@@ -108,6 +108,7 @@ builder.Services.AddAuthorization();
// ---- 业务服务 ---- // ---- 业务服务 ----
builder.Services.AddSingleton<JwtProvider>(); builder.Services.AddSingleton<JwtProvider>();
builder.Services.AddSingleton<SmsService>(); builder.Services.AddSingleton<SmsService>();
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
builder.Services.AddSingleton<PromptManager>(); builder.Services.AddSingleton<PromptManager>();
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>(); builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>(); builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
@@ -142,7 +143,6 @@ builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>(
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>(); builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>(); builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>(); builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
builder.Services.AddScoped<IReminderCatchUpService, EfReminderCatchUpService>();
builder.Services.AddScoped<IReportService, ReportService>(); builder.Services.AddScoped<IReportService, ReportService>();
builder.Services.AddScoped<IReportRepository, EfReportRepository>(); builder.Services.AddScoped<IReportRepository, EfReportRepository>();
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>(); builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
@@ -150,8 +150,6 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>(); builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserRepository, EfUserRepository>(); builder.Services.AddScoped<IUserRepository, EfUserRepository>();
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM---- // ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM----
builder.Services.AddHttpClient<DeepSeekClient>(client => builder.Services.AddHttpClient<DeepSeekClient>(client =>

View File

@@ -1,7 +1,7 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Warning",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
} }

View File

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

View File

@@ -42,8 +42,7 @@ public sealed class ApplicationServiceTests
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var repository = new FakeHealthArchiveRepository(new HealthArchive var repository = new FakeHealthArchiveRepository(new HealthArchive
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(), UserId = userId,
UserId = userId,
}); });
var service = new HealthArchiveService(repository); var service = new HealthArchiveService(repository);
@@ -76,35 +75,14 @@ public sealed class ApplicationServiceTests
Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01); Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01);
} }
[Fact]
public async Task HealthRecords_BatchPersistsAllReadingsWithOneSave()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
var ids = await service.CreateManyAsync(
Guid.NewGuid(),
[
new HealthRecordUpsertRequest(HealthMetricType.BloodPressure, 116, 89, null, "mmHg", HealthRecordSource.DeviceSync, DateTime.UtcNow),
new HealthRecordUpsertRequest(HealthMetricType.HeartRate, null, null, 72, "bpm", HealthRecordSource.DeviceSync, DateTime.UtcNow),
],
CancellationToken.None);
Assert.Equal(2, ids.Count);
Assert.Equal(2, repository.Added.Count);
Assert.Equal(1, repository.SaveCount);
}
[Fact] [Fact]
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery() public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var repository = new FakeHealthArchiveRepository(new HealthArchive var repository = new FakeHealthArchiveRepository(new HealthArchive
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(), UserId = userId,
UserId = userId, SurgeryType = "Legacy surgery", SurgeryDate = new DateOnly(2010, 1, 2),
SurgeryType = "Legacy surgery",
SurgeryDate = new DateOnly(2010, 1, 2),
}); });
var service = new HealthArchiveService(repository); var service = new HealthArchiveService(repository);
@@ -132,71 +110,13 @@ public sealed class ApplicationServiceTests
Assert.False(result.Created); Assert.False(result.Created);
} }
[Fact]
public async Task Conversation_List_ReturnsAtMostThirtyRecentItems()
{
var userId = Guid.NewGuid();
var conversations = Enumerable.Range(0, 35)
.Select(index => new Conversation
{
Id = Guid.NewGuid(),
UserId = userId,
AgentType = AgentType.Unified,
UpdatedAt = DateTime.UtcNow.AddMinutes(-index),
})
.ToList();
var service = new AiConversationService(new ListConversationRepository(conversations));
var result = await service.ListAsync(userId, CancellationToken.None);
Assert.Equal(30, result.Count);
}
[Fact]
public void Conversation_Delete_ReportsWhetherARecordWasActuallyDeleted()
{
var method = typeof(IAiConversationService).GetMethod(nameof(IAiConversationService.DeleteAsync));
Assert.NotNull(method);
Assert.Equal(typeof(Task<bool>), method!.ReturnType);
}
[Fact]
public async Task Conversation_Open_ReactivatesTheSameOwnedConversation()
{
var userId = Guid.NewGuid();
var conversation = new Conversation
{
Id = Guid.NewGuid(),
UserId = userId,
AgentType = AgentType.Unified,
UpdatedAt = DateTime.UtcNow.AddDays(-1),
};
var service = new AiConversationService(new FakeConversationRepository(conversation));
var result = await service.OpenAsync(
userId,
conversation.Id,
AgentType.Unified,
"新的追问",
CancellationToken.None);
Assert.True(result.Found);
Assert.False(result.Created);
Assert.Equal(conversation.Id, result.ConversationId);
}
[Fact] [Fact]
public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp() public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp()
{ {
var date = new DateOnly(2026, 6, 19); var date = new DateOnly(2026, 6, 19);
var medication = new Medication var medication = new Medication
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(), IsActive = true, Name = "阿司匹林", StartDate = date.AddDays(-1), TimeOfDay = [new TimeOnly(8, 0)]
IsActive = true,
Name = "阿司匹林",
StartDate = date.AddDays(-1),
TimeOfDay = [new TimeOnly(8, 0)]
}; };
var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) }; var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) };
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 });
@@ -234,11 +154,7 @@ public sealed class ApplicationServiceTests
var repository = new FakeExerciseRepository(); var repository = new FakeExerciseRepository();
var plan = new ExercisePlan var plan = new ExercisePlan
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today, EndDate = today.AddDays(9), ReminderTime = new TimeOnly(19, 0),
UserId = Guid.NewGuid(),
StartDate = today,
EndDate = today.AddDays(9),
ReminderTime = new TimeOnly(19, 0),
}; };
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 });
@@ -259,19 +175,11 @@ public sealed class ApplicationServiceTests
var repository = new FakeExerciseRepository(); var repository = new FakeExerciseRepository();
var plan = new ExercisePlan var plan = new ExercisePlan
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today.AddDays(-1), EndDate = today, ReminderTime = new TimeOnly(19, 0),
UserId = Guid.NewGuid(),
StartDate = today.AddDays(-1),
EndDate = today,
ReminderTime = new TimeOnly(19, 0),
}; };
var item = new ExercisePlanItem var item = new ExercisePlanItem
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(), Plan = plan, ScheduledDate = today.AddDays(-1), ExerciseType = "散步", DurationMinutes = 30,
Plan = plan,
ScheduledDate = today.AddDays(-1),
ExerciseType = "散步",
DurationMinutes = 30,
}; };
plan.Items.Add(item); plan.Items.Add(item);
repository.SetPlan(plan); repository.SetPlan(plan);
@@ -291,8 +199,6 @@ public sealed class ApplicationServiceTests
private sealed class CapturingHealthRecordRepository : IHealthRecordRepository private sealed class CapturingHealthRecordRepository : IHealthRecordRepository
{ {
public DateTime? RecordedAfter { get; private set; } public DateTime? RecordedAfter { get; private set; }
public List<HealthRecord> Added { get; } = [];
public int SaveCount { get; private set; }
public Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) => public Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<HealthRecord>>([]); Task.FromResult<IReadOnlyList<HealthRecord>>([]);
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null); public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
@@ -302,9 +208,9 @@ public sealed class ApplicationServiceTests
RecordedAfter = recordedAfter; RecordedAfter = recordedAfter;
return Task.FromResult<IReadOnlyList<HealthRecord>>([]); return Task.FromResult<IReadOnlyList<HealthRecord>>([]);
} }
public Task AddAsync(HealthRecord record, CancellationToken ct) { Added.Add(record); return Task.CompletedTask; } public Task AddAsync(HealthRecord record, CancellationToken ct) => Task.CompletedTask;
public void Delete(HealthRecord record) { } public void Delete(HealthRecord record) { }
public Task SaveChangesAsync(CancellationToken ct) { SaveCount++; return Task.CompletedTask; } public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
} }
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
@@ -317,38 +223,10 @@ public sealed class ApplicationServiceTests
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]); public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask; public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask;
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask; public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
public Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0);
public void Delete(Conversation value) { } public void Delete(Conversation value) { }
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
} }
private sealed class ListConversationRepository(List<Conversation> conversations) : IAiConversationRepository
{
private readonly List<Conversation> _conversations = conversations;
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) =>
Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId && item.UserId == userId));
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) =>
Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId));
public Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<Conversation>>(_conversations
.Where(item => item.UserId == userId)
.OrderByDescending(item => item.UpdatedAt)
.ToList());
public Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
public Task AddConversationAsync(Conversation value, CancellationToken ct)
{
_conversations.Add(value);
return Task.CompletedTask;
}
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
public Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0);
public void Delete(Conversation value) => _conversations.Remove(value);
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
}
private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository
{ {
public Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot); public Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot);

View File

@@ -1,56 +0,0 @@
using Health.Application.AI;
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Tests;
public sealed class DietCommentaryTests
{
[Fact]
public void FoodDescription_ContainsOnlyStructuredFoodData()
{
var result = DietCommentaryPolicy.BuildFoodDescription(
[
new DietCommentaryFood("番茄炒蛋", "半盘", 220),
new DietCommentaryFood("米饭", "一碗", 260),
]);
Assert.Equal("番茄炒蛋半盘220千卡、米饭一碗260千卡", result);
Assert.DoesNotContain("请结合", result);
}
[Fact]
public void LegacyArtifact_RequiresEveryUserMessageToUseInternalPrefix()
{
var artifact = new Conversation
{
Messages =
[
new ConversationMessage
{
Role = MessageRole.User,
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
},
new ConversationMessage
{
Role = MessageRole.Assistant,
Content = "注意搭配蔬菜",
},
],
};
var normal = new Conversation
{
Messages =
[
new ConversationMessage
{
Role = MessageRole.User,
Content = "我想了解今天的饮食记录",
},
],
};
Assert.True(DietCommentaryPolicy.IsLegacyArtifact(artifact.Messages));
Assert.False(DietCommentaryPolicy.IsLegacyArtifact(normal.Messages));
}
}

View File

@@ -1,17 +0,0 @@
using Health.WebApi.Endpoints;
namespace Health.Tests;
public sealed class DoctorEndpointTests
{
[Fact]
public void BeijingDayRange_MapsEarlyUtcTimeToTheCorrectLocalDate()
{
var utcNow = new DateTime(2026, 7, 13, 17, 30, 0, DateTimeKind.Utc);
var (startUtc, endUtc) = DoctorEndpoints.GetBeijingDayRange(utcNow);
Assert.Equal(new DateTime(2026, 7, 13, 16, 0, 0, DateTimeKind.Utc), startUtc);
Assert.Equal(new DateTime(2026, 7, 14, 16, 0, 0, DateTimeKind.Utc), endUtc);
}
}

View File

@@ -184,7 +184,6 @@ public class EntityTests
DietRestrictions = ["低盐", "低脂"], DietRestrictions = ["低盐", "低脂"],
ChronicDiseases = ["高血压", "高血脂"], ChronicDiseases = ["高血压", "高血脂"],
FamilyHistory = "父亲冠心病", FamilyHistory = "父亲冠心病",
SurgeryHistoryStatus = "有",
}; };
db.HealthArchives.Add(archive); db.HealthArchives.Add(archive);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -194,7 +193,6 @@ public class EntityTests
Assert.Equal("冠心病", saved!.Diagnosis); Assert.Equal("冠心病", saved!.Diagnosis);
Assert.Equal(2, saved.DietRestrictions.Count); Assert.Equal(2, saved.DietRestrictions.Count);
Assert.Contains("低盐", saved.DietRestrictions); Assert.Contains("低盐", saved.DietRestrictions);
Assert.Equal("有", saved.SurgeryHistoryStatus);
} }
[Fact] [Fact]

View File

@@ -1,29 +0,0 @@
using Health.Application.Medications;
using Health.Domain.Enums;
namespace Health.Tests;
public sealed class MedicationScheduleTests
{
[Fact]
public void FutureDoseOutsideReminderWindow_RemainsVisibleAsScheduled()
{
var status = MedicationScheduleStatus.Resolve(
new TimeOnly(18, 0),
new TimeOnly(8, 0),
logStatus: null);
Assert.Equal("scheduled", status);
}
[Fact]
public void LoggedDose_UsesItsActualStatus()
{
var status = MedicationScheduleStatus.Resolve(
new TimeOnly(8, 0),
new TimeOnly(12, 0),
MedicationLogStatus.Taken);
Assert.Equal("taken", status);
}
}

View File

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

View File

@@ -1,5 +1,4 @@
using Health.Application.Medications; using Health.Application.Medications;
using Health.Application.AI;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums; using Health.Domain.Enums;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
@@ -14,50 +13,6 @@ namespace Health.Tests;
public sealed class PersistencePipelineTests public sealed class PersistencePipelineTests
{ {
[Fact]
public async Task ConversationCleanup_DeletesOnlyLegacyDietCommentaryArtifacts()
{
await using var db = CreateDbContext();
var userId = Guid.NewGuid();
var artifact = new Conversation
{
Id = Guid.NewGuid(),
UserId = userId,
AgentType = AgentType.Default,
};
artifact.Messages.Add(new ConversationMessage
{
Id = Guid.NewGuid(),
ConversationId = artifact.Id,
Role = MessageRole.User,
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
});
var normal = new Conversation
{
Id = Guid.NewGuid(),
UserId = userId,
AgentType = AgentType.Default,
};
normal.Messages.Add(new ConversationMessage
{
Id = Guid.NewGuid(),
ConversationId = normal.Id,
Role = MessageRole.User,
Content = "请分析我今天吃的米饭",
});
db.Conversations.AddRange(artifact, normal);
await db.SaveChangesAsync();
var repository = new EfAiConversationRepository(db);
var deleted = await repository.DeleteLegacyDietCommentaryArtifactsAsync(
userId,
DietCommentaryPolicy.LegacyPromptPrefix,
CancellationToken.None);
Assert.Equal(1, deleted);
Assert.Equal(normal.Id, Assert.Single(db.Conversations).Id);
}
[Fact] [Fact]
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner() public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
{ {
@@ -213,178 +168,6 @@ public sealed class PersistencePipelineTests
Assert.Empty(tasks); Assert.Empty(tasks);
} }
[Fact]
public async Task NotificationOutbox_PushDisabledDoesNotCreateInAppNotification()
{
await using var db = CreateDbContext();
var userId = Guid.NewGuid();
var sourceId = Guid.NewGuid();
db.NotificationPreferences.Add(new NotificationPreference
{
Id = Guid.NewGuid(),
UserId = userId,
PushEnabled = false,
MedicationReminder = true,
HealthRecordReminder = false,
});
db.NotificationOutbox.Add(new NotificationOutboxRecord
{
Id = Guid.NewGuid(),
UserId = userId,
SourceTaskId = sourceId,
Type = "MedicationReminder",
Payload = """
{"Type":"MedicationReminder","Title":"用药时间到了","Message":"请按计划服药","Severity":"info","ActionType":"medication","ActionTargetId":null}
""",
Status = "Pending",
AvailableAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var processor = new EfNotificationOutboxProcessor(db);
await processor.ProcessNextAsync(CancellationToken.None);
Assert.Empty(db.UserNotifications);
}
[Fact]
public async Task ReminderCatchUp_PushDisabledDoesNotCreateHealthRecordReminder()
{
await using var db = CreateDbContext();
var userId = Guid.NewGuid();
db.NotificationPreferences.Add(new NotificationPreference
{
Id = Guid.NewGuid(),
UserId = userId,
PushEnabled = false,
HealthRecordReminder = true,
HealthRecordReminderBloodPressure = true,
HealthRecordReminderHeartRate = false,
HealthRecordReminderGlucose = false,
HealthRecordReminderSpO2 = false,
HealthRecordReminderWeight = false,
});
await db.SaveChangesAsync();
var service = new EfReminderCatchUpService(db);
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc); // 13:00 Beijing
var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
var secondPass = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
Assert.Equal(0, created.CreatedCount);
Assert.Equal(0, secondPass.CreatedCount);
Assert.Empty(db.UserNotifications);
}
[Fact]
public async Task ReminderCatchUp_PushEnabledCreatesOnlySelectedMissingMetric()
{
await using var db = CreateDbContext();
var userId = Guid.NewGuid();
db.NotificationPreferences.Add(new NotificationPreference
{
Id = Guid.NewGuid(),
UserId = userId,
PushEnabled = true,
HealthRecordReminder = true,
HealthRecordReminderBloodPressure = true,
HealthRecordReminderHeartRate = false,
HealthRecordReminderGlucose = false,
HealthRecordReminderSpO2 = false,
HealthRecordReminderWeight = false,
});
await db.SaveChangesAsync();
var service = new EfReminderCatchUpService(db);
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc);
var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
Assert.Equal(1, result.CreatedCount);
var notification = Assert.Single(db.UserNotifications);
Assert.Contains("血压", notification.Message);
Assert.DoesNotContain("心率", notification.Message);
}
[Fact]
public async Task ReminderCatchUp_DndStillPreventsReminder()
{
await using var db = CreateDbContext();
var userId = Guid.NewGuid();
db.NotificationPreferences.Add(new NotificationPreference
{
Id = Guid.NewGuid(),
UserId = userId,
PushEnabled = true,
HealthRecordReminder = true,
HealthRecordReminderBloodPressure = true,
DndEnabled = true,
DndStartMinutes = 12 * 60,
DndEndMinutes = 14 * 60,
});
await db.SaveChangesAsync();
var service = new EfReminderCatchUpService(db);
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc);
var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
Assert.Equal(0, result.CreatedCount);
Assert.Empty(db.UserNotifications);
}
[Fact]
public async Task ReminderCatchUp_CreatesMedicationAndExerciseRemindersForDueItems()
{
await using var db = CreateDbContext();
var userId = Guid.NewGuid();
var date = new DateOnly(2026, 7, 13);
db.NotificationPreferences.Add(new NotificationPreference
{
Id = Guid.NewGuid(),
UserId = userId,
PushEnabled = true,
MedicationReminder = true,
HealthRecordReminder = false,
});
db.Medications.Add(new Medication
{
Id = Guid.NewGuid(),
UserId = userId,
Name = "测试药物",
Dosage = "1片",
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
StartDate = date,
IsActive = true,
});
var plan = new ExercisePlan
{
Id = Guid.NewGuid(),
UserId = userId,
StartDate = date,
EndDate = date,
ReminderTime = new TimeOnly(9, 0),
};
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(),
ScheduledDate = date,
ExerciseType = "散步",
DurationMinutes = 30,
});
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync();
var service = new EfReminderCatchUpService(db);
var nowUtc = new DateTime(2026, 7, 13, 2, 0, 0, DateTimeKind.Utc); // 10:00 Beijing
var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
Assert.Equal(2, created.CreatedCount);
Assert.Contains(db.UserNotifications, x => x.Type == "MedicationReminder");
Assert.Contains(db.UserNotifications, x => x.Type == "ExerciseReminder");
}
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new() private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),

View File

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

View File

@@ -0,0 +1,688 @@
# 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 返回的 identityTokenJWT查找或创建 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 调用(预期返回 40005token 无效)**
```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;
```

View File

@@ -1,47 +0,0 @@
# 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`.

View File

@@ -1,47 +0,0 @@
# 通知设置改造设计
## 目标
让通知设置展示真实的服务端状态,保存失败时明确提示并恢复原值;将五个健康指标开关收纳到底部选择弹窗中,同时修复通知总开关未真正参与后端投递判断的问题。
## 页面结构
主页面保持四个区域:通知总开关、通知类型、健康录入提醒、免打扰时段。
健康录入提醒区域只展示:
- “每日健康录入提醒”总开关。
- “提醒指标”入口,副标题展示当前已选指标摘要。
点击“提醒指标”后打开底部弹窗。弹窗使用复选框选择血压、心率、血糖、血氧和体重,至少保留一项,点击“保存”后一次性提交全部指标设置。
## 状态与保存
前端使用明确的通知偏好状态对象,不再直接使用无类型的 Map。状态区分首次加载、单项保存、指标批量保存和错误信息。
- 首次加载成功后才展示设置内容。
- 首次加载失败展示错误页和重试按钮,不使用默认值冒充服务端数据。
- 普通开关修改时只锁定当前操作,后端成功后采用响应中的完整偏好更新页面。
- 保存失败恢复原值并显示错误提示。
- 指标弹窗先维护本地草稿,点击保存时一次提交五个字段,避免连续请求和部分成功。
- 免打扰时间采用相同的真实保存与失败恢复规则。
## 后端行为
保留现有 `/api/notification-prefs` GET 和 PUT 接口。PUT 继续支持部分字段更新并返回保存后的完整偏好。
通知发送管线必须优先检查 `PushEnabled`。总开关关闭时,不生成或投递任何用户通知;健康录入、用药、随访、医生回复和异常提醒继续读取各自开关,免打扰规则继续生效。
## 错误处理
- 网络或服务错误显示后端可读信息,无法解析时使用统一提示。
- 保存过程中禁止同一设置重复提交。
- 指标未选择任何一项时,弹窗内提示至少选择一项,不发送请求。
- 后端返回失败时不显示成功状态。
## 测试
- Flutter 单元测试覆盖偏好解析、指标摘要、至少选择一项和失败回滚。
- Flutter 组件测试覆盖加载失败、指标弹窗批量选择和保存中禁用。
- 后端测试覆盖 `PushEnabled=false` 时所有通知类型均不投递,以及各分类开关和免打扰继续生效。
- 运行 `flutter analyze`、Flutter 全量测试和相关 .NET 测试。

View File

@@ -1,79 +0,0 @@
# 健康 App 前端 UI 设计系统
这份规范用于后续逐页精修。首页是品牌化例外,保持当前紫色背景和已有结构;以下规则主要约束普通二级页面。
## 1. 页面层级
- 标题栏:纯白背景,深色标题和返回图标。
- 页面画布:`#F7F8FA`,只负责建立内容边界,不作为卡片颜色。
- 主要内容组:纯白背景,区域间保留 1216 px 的画布间距。
- 列表行:同一内容组内连续排列,用文字区域浅分隔线区分,不为每一行单独套卡片。
- 重点对象报告、设备、AI 分析和关键概览可以使用独立白色卡片。
## 2. 颜色角色
- 品牌紫:通用选中、焦点、品牌按钮描边和少量重点操作。
- 模块色:只用于统一样式的图标、极浅图标底、图表曲线、小标签和状态识别。
- 中性色:页面画布、分隔线、正文层级和不可用状态。
- 语义色:绿色表示成功/正常,黄色表示提醒/等待,红色表示错误/危险,蓝色表示信息/进行中。
- 禁止模块色用于整页背景、大面积头部、大卡片底色和普通新增按钮。
## 3. 间距
- 页面左右边距16 px。
- 页面顶部内容间距12 px。
- 内容组间距1216 px。
- 列表图标与文字12 px。
- 列表行垂直内边距:根据内容使用 12 或 16 px不通过缩小字体制造紧凑感。
## 4. 圆角与阴影
- 所有圆角使用 `AppRadius`,不得在页面里随意新增数值。
- 连续内容组默认使用 16 px 圆角。
- 输入框和小型操作使用 1014 px 圆角。
- 普通内容组默认无阴影、无描边。
- 浮动操作可以使用一层轻微中性阴影;同一组件不同时使用明显边框和重阴影。
## 5. 字体层级
- 页面标题1920 px粗体颜色 `textPrimary`
- 区域标题18 px粗体。
- 列表主标题1617 px中粗体。
- 正文和补充说明1315 px保证对比度不使用过浅小字。
- 数值、单位、份量和热量在同一行时保持基线、字号和字重协调。
- 选中状态不能改变文字大小、字重或位置。
## 6. 图标
- 全 App 优先使用同一套 Material 线性 `outlined/rounded` 图标,不混用大面积实心图标。
- 普通列表图标底座统一为 40×40 px、10 px 圆角,图标统一为 22 px。
- 不同模块可以改变图标和极浅底色,但尺寸、线条感觉、底座形状和对齐必须一致。
- 无底座的小型操作图标统一为 2024 px并保证至少 44 px 点击区域。
## 7. 按钮
- 通用新增:白底、紫色渐变细描边、深紫色线性加号;所有模块共用同一组件。
- 页面主要操作:优先使用白底紫色品牌描边;是否使用实心按钮由逐页任务重要性决定。
- 次要操作:白底或极浅灰底、深色文字。
- 危险操作:白底红字;只有确认删除阶段显示红色危险面。
- 新增、上传、绑定和记录属于通用操作,不跟随模块颜色。
## 8. 列表与滑动操作
- 列表行连续排列,分隔线从文字区域开始,不穿过左侧图标。
- 整行可点击,附属按钮不能缩小主点击区域。
- 删除背景默认完全隐藏,向左打开删除操作后才出现。
- 删除失败时保留真实列表数据,不提前移除前端项目。
## 9. 弹窗、表单和状态
- 弹窗、底部面板统一白色,不跟随模块色。
- 输入框焦点继续使用品牌紫,错误提示使用语义红。
- 空状态说明原因并提供一个明确下一步;加载、空、失败和未开放必须能被用户区分。
- 暂未启用的问诊和随访显示明确准备状态,不伪装成可用功能。
## 10. 逐页精修原则
- 每次只精修一个真实页面或一组完全同构页面。
- 先看真机截图,再确定该页的信息层级、内容组、操作和模块色用量。
- 不因建立设计系统而批量重排所有页面;公共组件只提供一致基础,页面布局仍按实际任务设计。

View File

@@ -5,6 +5,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// 加载正式签名配置
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = java.util.Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
android { android {
namespace = "com.datalumina.YYA" namespace = "com.datalumina.YYA"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -20,21 +27,28 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.datalumina.YYA" applicationId = "com.datalumina.YYA"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
// 正式签名配置
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. signingConfig = signingConfigs.getByName("release")
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
} }
} }
} }

View File

@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" /> <uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<application <application

12
health_app/appinfo Normal file
View File

@@ -0,0 +1,12 @@
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

View File

@@ -20,7 +20,5 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict> </dict>
</plist> </plist>

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"

43
health_app/ios/Podfile Normal file
View File

@@ -0,0 +1,43 @@
# 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

View File

@@ -0,0 +1,22 @@
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

View File

@@ -11,9 +11,12 @@
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */; };
D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -42,12 +45,18 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
6331745AE8BC5EE4BCD290B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
65BAE750C7D318CC27513494 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -55,19 +64,46 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
ECE998D63CAE2AC9FF401968 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
5B6028AD445E1E5F91C5C300 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = { 97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
04069A07808CEF12713E8A9C /* Pods */ = {
isa = PBXGroup;
children = (
ECE998D63CAE2AC9FF401968 /* Pods-Runner.debug.xcconfig */,
6331745AE8BC5EE4BCD290B9 /* Pods-Runner.release.xcconfig */,
65BAE750C7D318CC27513494 /* Pods-Runner.profile.xcconfig */,
5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */,
93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */,
C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = { 331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -76,9 +112,19 @@
path = RunnerTests; path = RunnerTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
789F6ED1EF63DAEA6E803F1D /* Frameworks */ = {
isa = PBXGroup;
children = (
2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */,
C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -94,6 +140,8 @@
97C146F01CF9000F007C117D /* Runner */, 97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */, 331C8082294A63A400263BE5 /* RunnerTests */,
04069A07808CEF12713E8A9C /* Pods */,
789F6ED1EF63DAEA6E803F1D /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -128,8 +176,10 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = ( buildPhases = (
C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */, 331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */, 331C807F294A63A400263BE5 /* Resources */,
5B6028AD445E1E5F91C5C300 /* Frameworks */,
); );
buildRules = ( buildRules = (
); );
@@ -145,18 +195,23 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
); );
name = Runner; name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner; productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@@ -190,6 +245,9 @@
Base, Base,
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@@ -222,6 +280,28 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@@ -253,6 +333,45 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
}; };
B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -348,8 +467,7 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
@@ -361,14 +479,16 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = KBM2RZ74VR;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp; PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -378,13 +498,14 @@
}; };
331C8088294A63A400263BE5 /* Debug */ = { 331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -395,13 +516,14 @@
}; };
331C8089294A63A400263BE5 /* Release */ = { 331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -410,13 +532,14 @@
}; };
331C808A294A63A400263BE5 /* Profile */ = { 331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -475,7 +598,6 @@
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Debug; name = Debug;
@@ -525,8 +647,7 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -540,14 +661,16 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = KBM2RZ74VR;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp; PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -562,14 +685,16 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = KBM2RZ74VR;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp; PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -611,6 +736,20 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 97C146E61CF9000F007C117D /* Project object */; rootObject = 97C146E61CF9000F007C117D /* Project object */;
} }

View File

@@ -0,0 +1,59 @@
{
"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
}

View File

@@ -1,10 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1510" LastUpgradeVersion = "1510"
version = "1.3"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -52,9 +70,9 @@
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"

View File

@@ -4,4 +4,7 @@
<FileRef <FileRef
location = "group:Runner.xcodeproj"> location = "group:Runner.xcodeproj">
</FileRef> </FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace> </Workspace>

View File

@@ -0,0 +1,59 @@
{
"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
}

View File

@@ -2,12 +2,15 @@ import Flutter
import UIKit import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
} }

View File

@@ -2,10 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Health App</string> <string>小脉健康</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -13,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>health_app</string> <string>小脉健康</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
@@ -24,6 +26,39 @@
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要使用蓝牙连接血压计</string>
<key>NSCameraUsageDescription</key>
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要保存图片到相册</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以选取饮食照片、体检报告或个人头像</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -41,13 +76,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要使用蓝牙连接血压计</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -0,0 +1,10 @@
<?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>

View File

@@ -48,8 +48,6 @@ final appReadyProvider = Provider<bool>((ref) {
final auth = ref.watch(authProvider); final auth = ref.watch(authProvider);
if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash
if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页 if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页
final currentRoute = ref.watch(routeStackProvider).last.name;
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
final role = auth.user?.role ?? 'User'; final role = auth.user?.role ?? 'User';
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡 if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死) // 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
@@ -106,15 +104,10 @@ class _RootNavigator extends ConsumerWidget {
final stack = ref.watch(routeStackProvider); final stack = ref.watch(routeStackProvider);
final current = stack.last; final current = stack.last;
final authState = ref.watch(authProvider); final authState = ref.watch(authProvider);
final isPublicStaticText = current.name == 'staticText';
// 登录后自动跳转(在下一帧完成,无闪烁) // 登录后自动跳转(在下一帧完成,无闪烁)
if (authState.isLoggedIn && current.name == 'login') { if (authState.isLoggedIn && current.name == 'login') {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
ref.invalidate(latestHealthProvider);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.invalidate(currentExercisePlanProvider);
final role = authState.user?.role ?? 'User'; final role = authState.user?.role ?? 'User';
if (role == 'Admin') { if (role == 'Admin') {
goRoute(ref, 'adminHome'); goRoute(ref, 'adminHome');
@@ -127,8 +120,7 @@ class _RootNavigator extends ConsumerWidget {
} }
if (!authState.isLoading && if (!authState.isLoading &&
!authState.isLoggedIn && !authState.isLoggedIn &&
current.name != 'login' && current.name != 'login') {
!isPublicStaticText) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
goRoute(ref, 'login'); goRoute(ref, 'login');
}); });

View File

@@ -3,10 +3,10 @@ import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'local_database.dart'; import 'local_database.dart';
/// API 基础地址可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 /// API 基础地址(生产模式)。本地开发可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment( const String baseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'http://10.4.237.12:5000', defaultValue: 'https://erpapi.datalumina.cn/xiaomai',
); );
class ApiException implements Exception { class ApiException implements Exception {

View File

@@ -20,30 +20,30 @@ class AppColors {
static const Color meadowAccent = Color(0xFFBAE6FD); static const Color meadowAccent = Color(0xFFBAE6FD);
static const Color sageAccent = Color(0xFFE9D5FF); static const Color sageAccent = Color(0xFFE9D5FF);
static const Color health = Color(0xFF34D399); static const Color health = Color(0xFF3B82F6);
static const Color healthLight = Color(0xFFF0FDF7); static const Color healthLight = Color(0xFFEFF6FF);
static const Color healthBorder = Color(0xFFC8F7E1); static const Color healthBorder = Color(0xFFBFDBFE);
static const Color medication = Color(0xFF00B8D9); static const Color medication = Color(0xFF8B5CF6);
static const Color medicationLight = Color(0xFFEAFBFF); static const Color medicationLight = Color(0xFFF5F3FF);
static const Color medicationBorder = Color(0xFFB8F0FA); static const Color medicationBorder = Color(0xFFDDD6FE);
static const Color exercise = Color(0xFF4F7DD9); static const Color exercise = Color(0xFF10B981);
static const Color exerciseLight = Color(0xFFEFF4FF); static const Color exerciseLight = Color(0xFFECFDF5);
static const Color exerciseBorder = Color(0xFFD5E2FF); static const Color exerciseBorder = Color(0xFFA7F3D0);
static const Color report = Color(0xFF6366F1); static const Color report = Color(0xFF2563EB);
static const Color reportLight = Color(0xFFEFFBFF); static const Color reportLight = Color(0xFFEEF2FF);
static const Color reportBorder = Color(0xFFBAE6FD); static const Color reportBorder = Color(0xFFC7D2FE);
static const Color diet = Color(0xFFF7971E); static const Color diet = Color(0xFFF97316);
static const Color dietLight = Color(0xFFFFF8DB); static const Color dietLight = Color(0xFFFFF7ED);
static const Color dietBorder = Color(0xFFFFE58A); static const Color dietBorder = Color(0xFFFED7AA);
static const Color device = Color(0xFF2563EB); static const Color device = Color(0xFF06B6D4);
static const Color deviceLight = Color(0xFFEFF6FF); static const Color deviceLight = Color(0xFFECFEFF);
static const Color deviceBorder = Color(0xFFBFDBFE); static const Color deviceBorder = Color(0xFFA5F3FC);
static const Color notification = Color(0xFF7C5CFF); static const Color notification = Color(0xFF7C5CFF);
static const Color notificationLight = Color(0xFFF5F3FF); static const Color notificationLight = Color(0xFFF5F3FF);
static const Color notificationBorder = Color(0xFFDDD6FE); static const Color notificationBorder = Color(0xFFDDD6FE);
static const Color doctor = Color(0xFFFFAFBD); static const Color doctor = Color(0xFF0F766E);
static const Color doctorLight = Color(0xFFFFF3EF); static const Color doctorLight = Color(0xFFF0FDFA);
static const Color doctorBorder = Color(0xFFFFD7CB); static const Color doctorBorder = Color(0xFF99F6E4);
static const Color calendar = Color(0xFF6366F1); static const Color calendar = Color(0xFF6366F1);
static const Color calendarLight = Color(0xFFEEF2FF); static const Color calendarLight = Color(0xFFEEF2FF);
static const Color calendarBorder = Color(0xFFC7D2FE); static const Color calendarBorder = Color(0xFFC7D2FE);
@@ -51,7 +51,7 @@ class AppColors {
static const Color followupLight = Color(0xFFFDF2F8); static const Color followupLight = Color(0xFFFDF2F8);
static const Color followupBorder = Color(0xFFFBCFE8); static const Color followupBorder = Color(0xFFFBCFE8);
static const Color background = Color(0xFFF7F8FA); static const Color background = Color(0xFFF8FAFF);
static const Color backgroundSoft = Color(0xFFFFFFFF); static const Color backgroundSoft = Color(0xFFFFFFFF);
static const Color cardBackground = Color(0xFFFFFFFF); static const Color cardBackground = Color(0xFFFFFFFF);
static const Color cardInner = Color(0xFFF1F5F9); static const Color cardInner = Color(0xFFF1F5F9);
@@ -70,13 +70,10 @@ class AppColors {
static const Color success = Color(0xFF16A34A); static const Color success = Color(0xFF16A34A);
static const Color successLight = Color(0xFFEAF8EF); static const Color successLight = Color(0xFFEAF8EF);
static const Color successText = Color(0xFF15803D);
static const Color error = Color(0xFFEF4444); static const Color error = Color(0xFFEF4444);
static const Color errorLight = Color(0xFFFEECEF); static const Color errorLight = Color(0xFFFEECEF);
static const Color errorText = Color(0xFFDC2626);
static const Color warning = Color(0xFFF59E0B); static const Color warning = Color(0xFFF59E0B);
static const Color warningLight = Color(0xFFFFF7E6); static const Color warningLight = Color(0xFFFFF7E6);
static const Color warningText = Color(0xFFB45309);
static const Color infoLight = Color(0xFFEFF5FF); static const Color infoLight = Color(0xFFEFF5FF);
static const LinearGradient primaryGradient = LinearGradient( static const LinearGradient primaryGradient = LinearGradient(
@@ -160,45 +157,45 @@ class AppColors {
); );
static const LinearGradient doctorGradient = LinearGradient( static const LinearGradient doctorGradient = LinearGradient(
begin: Alignment.centerLeft, begin: Alignment.topLeft,
end: Alignment.centerRight, end: Alignment.bottomRight,
colors: [Color(0xFFFFC3A0), Color(0xFFFFAFBD)], colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
); );
static const LinearGradient healthGradient = LinearGradient( static const LinearGradient healthGradient = LinearGradient(
begin: Alignment.centerLeft, begin: Alignment.topLeft,
end: Alignment.centerRight, end: Alignment.bottomRight,
colors: [Color(0xFF6EE7B7), Color(0xFF67E8F9)], colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
); );
static const LinearGradient medicationGradient = LinearGradient( static const LinearGradient medicationGradient = LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)], colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)],
); );
static const LinearGradient exerciseGradient = LinearGradient( static const LinearGradient exerciseGradient = LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)], colors: [Color(0xFF34D399), Color(0xFF059669)],
); );
static const LinearGradient reportGradient = LinearGradient( static const LinearGradient reportGradient = LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFF38BDF8), Color(0xFF818CF8)], colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
); );
static const LinearGradient dietGradient = LinearGradient( static const LinearGradient dietGradient = LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFFF7971E), Color(0xFFFFD200)], colors: [Color(0xFFFB923C), Color(0xFFF97316)],
); );
static const LinearGradient deviceGradient = LinearGradient( static const LinearGradient deviceGradient = LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)], colors: [Color(0xFF22D3EE), Color(0xFF0891B2)],
); );
static const LinearGradient notificationGradient = LinearGradient( static const LinearGradient notificationGradient = LinearGradient(

View File

@@ -168,3 +168,4 @@ class AppTextStyles {
height: 1.25, height: 1.25,
); );
} }

View File

@@ -40,8 +40,6 @@ class AppModuleVisual {
class AppModuleVisuals { class AppModuleVisuals {
AppModuleVisuals._(); AppModuleVisuals._();
static const healthOverviewIcon = Icons.trending_up;
static const ai = AppModuleVisual( static const ai = AppModuleVisual(
module: AppModule.ai, module: AppModule.ai,
label: 'AI', label: 'AI',
@@ -75,7 +73,7 @@ class AppModuleVisuals {
static const exercise = AppModuleVisual( static const exercise = AppModuleVisual(
module: AppModule.exercise, module: AppModule.exercise,
label: '运动', label: '运动',
icon: Icons.directions_run_outlined, icon: LucideIcons.footprints,
color: AppColors.exercise, color: AppColors.exercise,
lightColor: AppColors.exerciseLight, lightColor: AppColors.exerciseLight,
borderColor: AppColors.exerciseBorder, borderColor: AppColors.exerciseBorder,

View File

@@ -15,9 +15,7 @@ import '../pages/settings/notification_prefs_page.dart';
import '../pages/notifications/notification_center_page.dart'; import '../pages/notifications/notification_center_page.dart';
import '../pages/history/conversation_history_page.dart'; import '../pages/history/conversation_history_page.dart';
import '../pages/profile/profile_page.dart'; import '../pages/profile/profile_page.dart';
import '../pages/profile/profile_edit_page.dart';
import '../pages/diet/diet_capture_page.dart'; import '../pages/diet/diet_capture_page.dart';
import '../pages/exercise/exercise_plan_page.dart';
import '../pages/device/device_scan_page.dart'; import '../pages/device/device_scan_page.dart';
import '../pages/device/device_management_page.dart'; import '../pages/device/device_management_page.dart';
import '../pages/remaining_pages.dart'; import '../pages/remaining_pages.dart';
@@ -69,9 +67,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'medCheckIn': case 'medCheckIn':
return MedicationCheckInPage(medId: params['id']); return MedicationCheckInPage(medId: params['id']);
case 'medicationEdit': case 'medicationEdit':
return MedicationEditPage(id: params['id']); return const MedicationEditPage();
case 'reports': case 'reports':
return ReportListPage(openUploadOnEnter: params['openUpload'] == 'true'); return const ReportListPage();
case 'aiAnalysis': case 'aiAnalysis':
final id = _requiredParam(params, 'id'); final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : AiAnalysisPage(id: id); return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
@@ -79,12 +77,11 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return ReportOriginalPage( return ReportOriginalPage(
url: params['url'] ?? '', url: params['url'] ?? '',
title: params['title'] ?? '原始报告', title: params['title'] ?? '原始报告',
fileType: params['fileType'] ?? 'Image',
); );
case 'exercisePlan': case 'exercisePlan':
return const EnterpriseExercisePlanPage(); return const ExercisePlanPage();
case 'exercisePlanDetail': case 'exercisePlanDetail':
return const EnterpriseExercisePlanPage(); return const ExercisePlanPage();
case 'exerciseCreate': case 'exerciseCreate':
return const ExercisePlanCreatePage(); return const ExercisePlanCreatePage();
case 'dietRecords': case 'dietRecords':
@@ -93,8 +90,6 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const DietCapturePage(); return const DietCapturePage();
case 'profile': case 'profile':
return const ProfilePage(); return const ProfilePage();
case 'profileEdit':
return const ProfileEditPage();
case 'doctorProfile': case 'doctorProfile':
return const DoctorProfileEditPage(); return const DoctorProfileEditPage();
case 'doctorSettings': case 'doctorSettings':

View File

@@ -45,7 +45,8 @@ Future<DateTime?> showAppDatePicker(
final monthCtrl = FixedExtentScrollController(initialItem: m - 1); final monthCtrl = FixedExtentScrollController(initialItem: m - 1);
final dayCtrl = FixedExtentScrollController(initialItem: d - 1); final dayCtrl = FixedExtentScrollController(initialItem: d - 1);
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day; int daysInMonth(int year, int month) =>
DateTime(year, month + 1, 0).day;
void clampDay() { void clampDay() {
final maxDay = daysInMonth(y, m); final maxDay = daysInMonth(y, m);
@@ -69,22 +70,12 @@ Future<DateTime?> showAppDatePicker(
children: [ children: [
CupertinoButton( CupertinoButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: const Text( child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
'取消',
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
), ),
CupertinoButton( CupertinoButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: const Text( child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
'确定',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
onPressed: () { onPressed: () {
clampDay(); clampDay();
Navigator.pop(ctx, DateTime(y, m, d)); Navigator.pop(ctx, DateTime(y, m, d));
@@ -96,68 +87,37 @@ Future<DateTime?> showAppDatePicker(
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(child: _wrapPicker(CupertinoPicker(
child: _wrapPicker( selectionOverlay: null,
CupertinoPicker( scrollController: yearCtrl,
selectionOverlay: null, itemExtent: 40,
scrollController: yearCtrl, onSelectedItemChanged: (i) {
itemExtent: 40, y = minDate.year + i;
onSelectedItemChanged: (i) { clampDay();
y = minDate.year + i; final maxDay = daysInMonth(y, m);
clampDay(); dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
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))],
}, ))),
children: [ Expanded(child: _wrapPicker(CupertinoPicker(
for (var i = minDate.year; i <= maxDate.year; i++) selectionOverlay: null,
Center(child: Text('$i', style: _pickerItemStyle)), scrollController: monthCtrl,
], itemExtent: 40,
), onSelectedItemChanged: (i) {
), m = i + 1;
), clampDay();
Expanded( final maxDay = daysInMonth(y, m);
child: _wrapPicker( dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
CupertinoPicker( },
selectionOverlay: null, children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
scrollController: monthCtrl, ))),
itemExtent: 40, Expanded(child: _wrapPicker(CupertinoPicker(
onSelectedItemChanged: (i) { selectionOverlay: null,
m = i + 1; scrollController: dayCtrl,
clampDay(); itemExtent: 40,
final maxDay = daysInMonth(y, m); onSelectedItemChanged: (i) => d = i + 1,
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble()); children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
}, ))),
children: [
for (var i = 1; i <= 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,
),
),
],
),
),
),
], ],
), ),
), ),
@@ -193,24 +153,13 @@ Future<TimeOfDay?> showAppTimePicker(
children: [ children: [
CupertinoButton( CupertinoButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: const Text( child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
'取消',
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
), ),
CupertinoButton( CupertinoButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: const Text( child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
'确定', onPressed: () => Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
onPressed: () =>
Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)),
), ),
], ],
), ),
@@ -219,46 +168,20 @@ Future<TimeOfDay?> showAppTimePicker(
child: Row( child: Row(
children: [ children: [
const Spacer(flex: 1), const Spacer(flex: 1),
Expanded( Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
flex: 2, selectionOverlay: null,
child: _wrapPicker( scrollController: hourCtrl,
CupertinoPicker( itemExtent: 40,
selectionOverlay: null, onSelectedItemChanged: (i) => hour = i,
scrollController: hourCtrl, children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
itemExtent: 40, ))),
onSelectedItemChanged: (i) => hour = i, Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
children: [ selectionOverlay: null,
for (var i = 0; i <= 23; i++) scrollController: minuteCtrl,
Center( itemExtent: 40,
child: Text( onSelectedItemChanged: (i) => minute = i,
i.toString().padLeft(2, '0'), children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
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), const Spacer(flex: 1),
], ],
), ),
@@ -288,7 +211,8 @@ Future<DateTime?> showAppDateTimePicker(
final hourCtrl = FixedExtentScrollController(initialItem: h); final hourCtrl = FixedExtentScrollController(initialItem: h);
final minuteCtrl = FixedExtentScrollController(initialItem: min); final minuteCtrl = FixedExtentScrollController(initialItem: min);
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day; int daysInMonth(int year, int month) =>
DateTime(year, month + 1, 0).day;
void clampDay() { void clampDay() {
final maxDay = daysInMonth(y, m); final maxDay = daysInMonth(y, m);
@@ -312,22 +236,12 @@ Future<DateTime?> showAppDateTimePicker(
children: [ children: [
CupertinoButton( CupertinoButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: const Text( child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
'取消',
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
), ),
CupertinoButton( CupertinoButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: const Text( child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
'确定',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
onPressed: () { onPressed: () {
clampDay(); clampDay();
Navigator.pop(ctx, DateTime(y, m, d, h, min)); Navigator.pop(ctx, DateTime(y, m, d, h, min));
@@ -339,102 +253,41 @@ Future<DateTime?> showAppDateTimePicker(
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(child: _wrapPicker(CupertinoPicker(
child: _wrapPicker( selectionOverlay: null,
CupertinoPicker( scrollController: yearCtrl,
selectionOverlay: null, itemExtent: 40,
scrollController: yearCtrl, onSelectedItemChanged: (i) { y = minDate.year + i; clampDay(); },
itemExtent: 40, children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
onSelectedItemChanged: (i) { ))),
y = minDate.year + i; Expanded(child: _wrapPicker(CupertinoPicker(
clampDay(); selectionOverlay: null,
}, scrollController: monthCtrl,
children: [ itemExtent: 40,
for (var i = minDate.year; i <= maxDate.year; i++) onSelectedItemChanged: (i) { m = i + 1; clampDay(); },
Center(child: Text('$i', style: _pickerItemStyle)), 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,
Expanded( itemExtent: 40,
child: _wrapPicker( onSelectedItemChanged: (i) => d = i + 1,
CupertinoPicker( children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
selectionOverlay: null, ))),
scrollController: monthCtrl, Expanded(child: _wrapPicker(CupertinoPicker(
itemExtent: 40, selectionOverlay: null,
onSelectedItemChanged: (i) { scrollController: hourCtrl,
m = i + 1; itemExtent: 40,
clampDay(); onSelectedItemChanged: (i) => h = i,
}, children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
children: [ ))),
for (var i = 1; i <= 12; i++) Expanded(child: _wrapPicker(CupertinoPicker(
Center( selectionOverlay: null,
child: Text( scrollController: minuteCtrl,
i.toString().padLeft(2, '0'), itemExtent: 40,
style: _pickerItemStyle, 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: 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,
),
),
],
),
),
),
], ],
), ),
), ),
@@ -469,47 +322,28 @@ Future<int?> showAppCountPicker(
children: [ children: [
CupertinoButton( CupertinoButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: const Text( child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
'取消',
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
), ),
CupertinoButton( CupertinoButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: const Text( child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
'确定',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
onPressed: () => Navigator.pop(ctx, selected), onPressed: () => Navigator.pop(ctx, selected),
), ),
], ],
), ),
), ),
Expanded( Expanded(
child: _wrapPicker( child: _wrapPicker(CupertinoPicker(
CupertinoPicker( selectionOverlay: null,
selectionOverlay: null, scrollController: FixedExtentScrollController(initialItem: selected - min),
scrollController: FixedExtentScrollController( itemExtent: 40,
initialItem: selected - min, onSelectedItemChanged: (i) => selected = i + min,
), children: [
itemExtent: 40, for (var i = min; i <= max; i++)
onSelectedItemChanged: (i) => selected = i + min, Center(child: Text(label.isEmpty ? '$i' : '$i$label', style: _pickerItemStyle)),
children: [ ],
for (var i = min; i <= max; i++) )),
Center(
child: Text(
label.isEmpty ? '$i' : '$i$label',
style: _pickerItemStyle,
),
),
],
),
),
), ),
], ],
), ),
@@ -528,16 +362,7 @@ class AppBackground extends StatelessWidget {
final content = safeArea ? SafeArea(child: child) : child; final content = safeArea ? SafeArea(child: child) : child;
// 全局浅灰白背景,已弃用紫色渐变。 // 全局浅灰白背景,已弃用紫色渐变。
return DecoratedBox( return ColoredBox(color: const Color(0xFFF6F8FC), child: content);
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)],
),
),
child: content,
);
} }
} }
@@ -561,13 +386,10 @@ class GradientScaffold extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
// 二级页面用浅中性画布承托白色内容组;首页拥有自己的独立背景实现 // 内部页面统一使用浅灰白底,对话流首页因直接使用 AppBackground 仍保留紫色渐变
backgroundColor: AppColors.background, backgroundColor: const Color(0xFFF6F8FC),
appBar: appBar, appBar: appBar,
body: ColoredBox( body: body,
color: AppColors.background,
child: SizedBox.expand(child: body ?? const SizedBox.shrink()),
),
floatingActionButton: floatingActionButton, floatingActionButton: floatingActionButton,
drawer: drawer, drawer: drawer,
bottomNavigationBar: bottomNavigationBar, bottomNavigationBar: bottomNavigationBar,
@@ -663,22 +485,22 @@ class AppTheme {
surface: surface, surface: surface,
brightness: Brightness.light, brightness: Brightness.light,
), ),
scaffoldBackgroundColor: AppColors.background, scaffoldBackgroundColor: Colors.transparent,
splashColor: primary.withValues(alpha: 0.06), splashColor: primary.withValues(alpha: 0.06),
highlightColor: Colors.transparent, highlightColor: Colors.transparent,
hoverColor: primaryLight.withValues(alpha: 0.55), hoverColor: primaryLight.withValues(alpha: 0.55),
fontFamily: null, fontFamily: null,
appBarTheme: const AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: Colors.white, backgroundColor: Colors.white.withValues(alpha: 0.9),
foregroundColor: text, foregroundColor: text,
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
titleTextStyle: TextStyle( titleTextStyle: const TextStyle(
fontSize: 20, fontSize: 17,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w700,
color: text, color: text,
), ),
toolbarHeight: 52, toolbarHeight: 52,

Some files were not shown because too many files have changed in this diff Show More