45 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
MingNian
d82e006cf4 refactor: 死代码清理 + 趋势图重构 + 侧边栏交互优化 + 智能体卡片去延迟
- 死代码清理: 删除 AppCard/AppMenuItem/AppTabChip/AppButtons 等 4 个未使用 Widget 文件; 清理 common_widgets/enterprise_widgets/app_status_badge 中未使用 class; 移除 HealthService 等未使用方法; 后端移除 ExerciseService.CreateFromItemsAsync/HealthArchiveService.GetOrCreateAsync/MedicationAgentHandler 死分支/ChatRequest DTO
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格; X 轴日+月份; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层; 选中点变实心
- 侧边栏: 对话记录改单选删除(灰底高亮); 操作栏浮层修复触摸问题; 常用功能/健康仪表盘间距收紧; _Panel 去外框
- 智能体: 欢迎卡片去掉 400ms 延迟; 胶囊去阴影
- 其他: api_client IP 适配; 多页面 UI 微调; 新增 chat_provider/prelaunch_guardrails 测试
2026-07-09 15:04:02 +08:00
MingNian
1c020b8ae5 feat: UI 系统中心化 + 趋势图重构 + 法律文档 H5 上线
- UI 系统: 新增 design_tokens / module_visuals / app_buttons / app_status_badge / app_toast / ai_content 六个共享模块; 28 个页面接入, SnackBar 全部替换为 AppToast
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格线; X 轴日+月份分隔; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层, 选中点变实心
- 法律文档 H5: 后端 wwwroot 托管隐私政策/服务协议/关于/个人信息收集清单/第三方 SDK 清单 + 索引页; Program.cs 启用 UseDefaultFiles + UseStaticFiles
- 其他: auth_provider 登录流程调整; api_client IP 适配; 主页智能体栏间距优化
2026-07-08 21:25:07 +08:00
MingNian
7a93237069 feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构
- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService
- 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放
- UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面
- 配置: api_client baseUrl 适配当前 WiFi IP
2026-07-06 12:44:59 +08:00
MingNian
4507083f3f refactor: 重构蓝牙设备页与新增设备页的扫描-连接-录入流程
- 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID)
- 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿
- 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据
- 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送)
- 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect)
- 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认
- 顺手更新本机开发 IP 与清理过时设计文档
2026-06-28 22:53:35 +08:00
MingNian
a748c316f2 chore: 清理废弃代码和 lint 警告
- 删除旧的 _chip 方法(服药次数改用滚轮后遗留)
- 移除多余的 ! 非空断言
2026-06-26 19:32:28 +08:00
MingNian
fa2cc994fa feat: 血压计联动心率记录 + 侧边栏左滑手势
- 血压计测量含脉搏时自动同步为 HeartRate 记录
- 首页 Scaffold 增加左侧滑手势打开侧边栏
2026-06-26 16:55:13 +08:00
MingNian
df37076c46 Polish health app UI 2026-06-26 14:37:17 +08:00
MingNian
86c7957144 style: 重构登录页视觉布局
- 使用品牌抽象背景和角落装饰素材
- 调整登录/注册卡片布局、品牌信息和表单层级
- 保留验证码、医生选择、协议勾选和登录注册流程
2026-06-25 22:19:10 +08:00
MingNian
18df2f3285 style: 统一患者端页面视觉并补齐入口跳转
- 调整全局颜色基调,提高灰色文字和边框对比度
- 新增企业级页面头部、统计块和卡片通用组件
- 优化用药管理、报告管理、个人信息、通知中心页面布局
- 新增多张页面插画和通用 UI 装饰素材
- 运动计划列表更紧凑,并新增运动计划详情路由
- 补齐医生端协议入口和原始报告查看入口
- 移除设置页未实现的字体大小和清除缓存入口
2026-06-25 22:13:42 +08:00
MingNian
53ce19e8c3 feat: 健康录入提醒 + 多指标异常 + 用药漏服 + UI 优化
后端
- NotificationPreference 增加健康录入提醒总开关和 5 子项(血压/心率/血糖/血氧/体重)
- EF 迁移 AddHealthRecordReminder
- 新增 GET/PUT /api/notification-prefs
- 新增 HealthRecordReminderService 每天 9 点/12 点扫描未录入用户推送提醒

前端
- 通知偏好对接真实后端 API
- 设置页新增「健康录入提醒」总开关+5 子项
- 通知中心右上角加齿轮跳设置页
- 今日健康卡片:4 项异常检测+多项合并;用药只显漏服并按时间窗口区分文案
- 侧边栏头像改圆形白底灰图标,去掉紫色品牌渐变
- 整体背景统一浅灰白 #F6F8FC,去掉浅紫渐变
- 设置页简化(去掉分组/彩色图标方块)
- 健康档案保存按钮移到右上角,TextButton 全局默认黑色
- API baseUrl 跟随网络环境
2026-06-24 22:11:21 +08:00
MingNian
7ff429e071 feat: 接入FastGPT知识库RAG检索,AI回答前先查医学资料
- 新增 FastGptKnowledgeClient:调用 searchTest 接口检索知识库
- ai_chat_endpoints: 调 DeepSeek 之前先检索,把相关片段拼到 system prompt
- 5个智能体(default/consultation/diet/report/unified)启用 RAG
- 3个纯工具调用智能体(health/medication/exercise)保持原样
- .env: 新增 FastGPT 配置(API Key、datasetId、相似度等)
- api_client: 更新默认 baseUrl 适配新网络环境
2026-06-22 20:50:32 +08:00
MingNian
73d99d56f6 fix: 趋势页恢复体重 + 图标换Material Icons + VLM禁用思考模式
- 健康概览页恢复体重指标的标签/单位/加载/筛选/录入
- 历史记录图标从emoji改为Material Icons
- VLM请求显式设置enable_thinking=false,禁用混合思考加速识图
2026-06-22 16:45:51 +08:00
MingNian
80540e2191 fix: 健康档案保存按钮移至右上角 + 5页面补充返回按钮 + API地址修复
- 健康档案: 保存按钮移到AppBar右侧,删除底部按钮和绿色成功SnackBar
- 5个缺失页面补充左上角返回按钮: 健康概览/编辑用药/报告管理/报告详情/报告解读
- api_client.dart: 默认baseUrl改为192.168.1.29 (支持WiFi直连)
2026-06-22 15:58:59 +08:00
MingNian
6ce010e46a feat: AI回复格式约束 + 前端清洗器增强
- prompt: 禁止**粗体*斜体等内联标记、HTML标签、表格,标题仅限###
- 前端_cleanAiText: 移除$N占位符、HTML标签、多余#标题降级
- 确认卡片/结构化消息不受影响
2026-06-22 14:14:42 +08:00
MingNian
431b72d49a feat: Cupertino滚轮选择器 + 品牌更名 + UI全面优化
- 日期/时间/次数选择器统一改为底部弹出Cupertino滚轮,无单位标签,双横线选区
- App更名为"小脉健康",副标题"血管病患者的AI健康管理助手"
- 健康仪表盘及侧边栏移除体重指标,侧边栏背景改为蓝白渐变
- 健康档案按钮移入功能入口网格,与其他入口样式统一
- 记数据智能体配色改为绿色渐变(#A1FFCE→#69DB8F)
- 隐私协议/关于页从Text改为MarkdownBody正确渲染
- 运动计划/饮食记录卡片添加边框和阴影
- 服药次数从chip改为滚轮选择器
- 日期显示格式统一为空格分隔(1999 01 01)
- 健康档案页背景改为纯白
2026-06-22 13:59:37 +08:00
MingNian
a479e5e95d style: 今日健康卡片顶部标题改用健康档案的紫蓝左右渐变 2026-06-21 23:37:18 +08:00
MingNian
a7fb6e5ff3 fix: 启动页遮住登录/首页初始态,消除深色模式黑闪
- 新增 SplashPage + appReadyProvider/_BootGate:启动闸门将启动页覆盖在最上层,
  直到 auth 判定完成且今日健康卡数据就绪,遮住登录页闪现与首页对话流初始态
- auth 初始状态改为 isLoading=true,无 token 时再置 false
- values-night/styles.xml 改为亮色白底,消除深色模式下 Flutter 初始化期间的黑色窗口背景
- 带 8 秒安全兜底,避免离线时卡在启动页
2026-06-21 23:32:02 +08:00
MingNian
415c7ca082 fix: 请求体解析失败返回 400 而非 500
ExceptionMiddleware 捕获 BadHttpRequestException(非法 JSON/编码错误/请求体过大等),
按其携带的状态码返回(通常 400),避免把客户端请求格式问题误报为服务器内部错误。
2026-06-21 21:15:02 +08:00
MingNian
13714d9ed8 feat: 应用内通知系统 + 结构化手术史/用药等相关改动
- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker
- 通知中心页面及前端通知服务接入
- 健康指标异常、用药/运动提醒等事件统一产出站内通知
- 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整
- AppDbContext 注册通知相关实体
2026-06-21 21:06:29 +08:00
MingNian
b57d0d16f4 feat: 引入 EF Core Migrations + 后台任务/校验修复 + 前端四态
后端
- 改用 EF Core Migrations(InitialCreate),Program.cs 用 MigrateAsync + AUTO_MIGRATE 开关 + 显式 MigrationsAssembly;移除 EnsureCreated、删除手写 DatabaseSchemaMigrator
- 修复后台任务永久卡 Processing:三个队列对超时且达上限的任务原子标记 Failed
- 修复报告重试失效:基础设施异常向上抛激活 RetryAsync,停机取消不计失败
- 修复 AI 用药天数 off-by-one(结束日为包含式)
- AI 报告日志不再记录 VLM 健康正文
- 新增统一输入校验(ValidationException + 中间件映射 400):血压/心率/血糖/血氧/体重范围、药名/日期/服药时间去重、饮食热量与评分、运动时长上限
- 删除健康数据 AI 录入的影子直写路径,统一走 Service 校验

前端
- 新增 AppErrorState / AppFutureView,用药列表、服药打卡、运动计划、随访列表改为 加载/失败/空/数据 四态,失败可重试

瘦身
- 删除过时的 DataSeeder / DevDataSeeder 及调用
- 删除依赖实时服务的 ai_agent_tests 集成测试
2026-06-21 21:04:40 +08:00
MingNian
aa44d6f0f0 refactor: 清理 AI 客户端死代码并统一错误处理
- 删除未使用的 OpenAiCompatibleClient,DTO 抽离到 ai_contracts.cs
- 新增 AiHttpResponseGuard 统一 LLM/VLM 的 HTTP 错误处理:
  错误响应带响应体(截断 2000 字符)、空响应与无效 JSON 显式抛 InvalidDataException
- 空 catch 补全精确异常类型(IOException/UnauthorizedAccessException/JsonException)
- 修正 Program.cs 数据库初始化注释
- 新增 AI 客户端错误处理单元测试
2026-06-20 22:06:31 +08:00
MingNian
4d213b5a44 feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
2026-06-20 20:41:42 +08:00
MingNian
c610417e29 fix: 胶囊高度42px + 功能卡片白底 + 面板绿粉渐变 + 间距调整 2026-06-17 17:50:27 +08:00
MingNian
21e67014aa fix: 健康档案按钮背景色换紫蓝渐变 2026-06-17 17:38:20 +08:00
MingNian
fbaed0cf1d fix: 健康仪表盘单位下移 + 档案按钮浅紫 + AI问诊胶囊改名 + 删除账号功能 2026-06-17 17:35:47 +08:00
MingNian
0f2a9c1c1a fix: 欢迎卡片图标去白圆点 + 按钮渐变图标 + 多处微调 2026-06-17 11:22:02 +08:00
MingNian
4f1ad82345 fix: 欢迎卡片UI优化 + 蓝牙录入按钮 + 多处修复
- 欢迎卡片:去掉白框圆角错位、图片满宽、按钮去箭头改圆角居中
- 渐变actionOutlineGradient改紫蓝双色循环
- 新增健康Agent"蓝牙录入"按钮→蓝牙设备页
- "查看健康情况"改名"健康概览"
- 饮食编辑框删除bug修复(控制器缓存)
- 启动脚本加adb reverse自动转发
- 登录闪屏修复
2026-06-17 10:58:59 +08:00
MingNian
9279b7e283 fix: 食物识别编辑框删除bug + VLM相关代码回滚
- 修复饮食识别结果编辑框一次只能删一个字的bug(控制器缓存)
- 回滚VLM图片压缩代码到原始状态
- 保持原有显示逻辑不变
2026-06-16 17:08:32 +08:00
MingNian
c70d5d4be6 fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复
- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者
- 注册页重构:去掉角色选择+审核码,改为选医生+填姓名
- 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立
- Doctor/DoctorProfile/User实体新增关联字段
- JSON循环引用修复(IgnoreCycles)
- /api/doctors改为公开接口
- 登录闪屏修复+原生Android启动页
- 输入框全局白底+灰框
- 蓝牙重连同步修复
- Web端(doctor_web+health_app/web)全部删除
- 全局UI改版:白底企业风,新配色和组件系统
- 新品牌图标和启动图
2026-06-16 15:16:44 +08:00
MingNian
b635e9d25f Update app branding assets 2026-06-15 18:13:52 +08:00
MingNian
01cd132476 feat: 医生端完整实现 + 双角色登录 + 健康日历 + 饮食记录重做 + 配色系统重构
后端:
- User表加Role字段 + DoctorProfile表 + 废弃旧Doctor数据
- JWT加Role claim + 注册/登录拆分(/api/auth/register + /api/auth/login)
- 医生端点全量加JWT鉴权+Role校验(15个端点)
- 日历端点改造(用药/运动/随访按日期+星期匹配)
- 饮食记录PUT端点 + 健康记录DELETE端点
- 用户Profile端点返回Role

前端:
- 登录页重做(角色选择卡片 用户紫/医生蓝 + 审核码6666 + 注册返登录)
- App路由分流(userRole→患者端/医生端)
- 医生端: 工作台Dashboard + 患者管理(搜索分页) + 问诊/报告/随访CRUD
- 报告审核: AI预分析+指标表格+严重程度4级+模板多选+评语
- 随访编辑: 患者选择器+日期时间+打通患者端
- 健康日历: 可点击日历+当日安排列表+用药/运动/随访颜色标记
- 饮食记录: 热量趋势7/30天+日历(一周)+当日胶囊详情+侧滑编辑删除
- 健康档案: 日期选择器+手术可添加多条+白底渐变保存按钮
- 配色系统: 清理25个未使用色+新增doctorBlue/drawerGradient/pageGrey
- 患者端侧边栏: 新渐变+白底按钮+紫色汉堡图标
- AI对话: 用户气泡白底黑字+药管家改蓝色
- App启动闪屏: 防登录页闪烁

文档: color_design_system.md(20页面完整配色设计+图标底色对照表)
2026-06-14 23:28:14 +08:00
MingNian
d102205b18 feat: 蓝牙血压计BLE数据同步 + 设备管理页重构 + 健康记录滑动删除 + 医生端合并设计文档
- BLE: 修复Omron设备Indication模式连接(先订阅后配CCCD + forceIndications) + 直连重连
- BLE: 设备断连实时检测(connectionState流→Provider→UI)
- 修复: 血压数据上报后端枚举不匹配(Device→DeviceSync)导致500
- 新增: DELETE /api/health-records/{id} 后端接口
- 设备管理页: 白色主题重设计 + 直连重连 + 实时状态 + Toast通知
- 设备扫描页: 白色主题 + 扫描动画居中 + 已连接等待页面
- 健康记录: 左滑删除(Dismissible) + 同时清理_allRecords和_filtered
- 导航: 修复自定义路由栈下Navigator.pop黑屏bug
- 文档: 医生端合并App完整设计文档(已确认版)
2026-06-11 22:11:02 +08:00
MingNian
66168e3261 docs: 更新页面设计文档+需求文档至当前实现状态
- 页面设计文档:新增色彩系统/智能体配色/圆角规范/已实现15个页面详情/交互特性/后端接口表
- 需求文档:新增已实现/待实现功能清单
2026-06-11 11:29:09 +08:00
MingNian
7baf0d513f fix: 登录页白底+渐变边框/胶囊加大/底部提示/今日健康优化/发送按钮蓝紫渐变
- 登录页:输入框白底+渐变紫蓝外框,去掉focus内框;登录按钮白底紫字;协议勾选白底
- 首页胶囊:适老化加大(内边距14x8/字号15/图标17/圆角18)
- 欢迎卡片底部提示:各智能体专属引导文案,字号加大颜色加深
- 今日健康卡片:健康指标改一行总结(异常提醒/正常显示);用药显示具体药品名+状态
- 发送按钮:蓝→紫渐变
- 设置页菜单:白色卡片+圆角16+轻阴影
- IP改localhost:adb reverse解决IP变动问题
- 服药打卡快捷按钮:改为跳转打卡页,不再一键全打
2026-06-11 11:17:13 +08:00
MingNian
f6dd14be3b feat: 全面UI改造 — 报告/饮食/运动/用药/侧边栏/对话流/胶囊
- 报告模块:重写AI解读页,统一白色卡片+三栏布局(指标→解读→医生审核);加删除功能+后端删接口;VLM自动识别报告类型生成中文标题;去五颜六色
- 饮食分析:全面重设计,暖橙主题配色,图片自适应,餐次emoji选择器,去紫色
- 运动确认卡片:修复duration_minutes JSON类型转换,支持中文数字识别(三十分钟/半小时→30);主区域只显示运动名,字段行改为横向排列
- 流式输出:简化为最基础逐字追加,去buffer+Timer+淡入动画
- 欢迎卡片:每智能体独立渐变色(青/橙/蓝/紫/绿/粉),加卡片入场滑入+淡入动画(600ms)
- 确认卡片:头部去紫色背景改浅灰白,字段行去图标改纯信息横排,编辑框去双重边框
- 用药管理:胶囊改TabBar,添加按钮改右下FAB;打卡按钮改对号圆圈形式;药丸图标白底
- 侧边栏:加VIP服务/保险栏+图标,标题字体放大;服务包去查看更多+去VIP金色标签
- 胶囊:首页智能体胶囊白底深色字; side胶囊加阴影
- 对话流:reverse=false,今日健康出现在顶部; 对话页input bar匹配主页面样式
- 其他:个人资料去紫色+去多余入口;设置页白底图标;蓝牙页加返回按钮;报告管理改名
- 后端:加DELETE /api/reports/{id}; 修复manage_exercise数据类型转换;AI提示优化中文数字
2026-06-10 23:46:45 +08:00
MingNian
63d2092c24 feat: UI全面改造 + 后端多项修复
【后端修复】
- 删除diet/consultation agent假工具声明
- 修复DayOfWeek实体注释
- 修复Vision API content序列化
- cleanup_service级联删除修复
- 用药提醒时区偏差修复
- 统一DateTime处理(UtcNow+8)
- 新增UTC DateTime JSON转换器

【前端UI重构】
- 配色体系全面更新(#8B5CF6淡紫+#F0ECFF背景)
- 登录页重设计
- 首页重设计(透明顶栏、渐变背景、胶囊输入区)
- 聊天卡片加白蓝边框、渐变标题
- 侧边栏重构(渐变背景、合并顶部、删除底部设置)
- 确认卡片可编辑字段恢复
- 所有子页面加返回按钮
- catch异常加日志
- 删除后refresh provider缓存
2026-06-10 18:27:38 +08:00
MingNian
3964cf2bcb feat: 蓝牙血压计基础架构 + UI配色体系升级
- 新增 AppColors 配色体系(紫蓝渐变 + 超大圆角 + 漂浮卡片)
- 新增 OmronBleService(BLE 扫描/连接/SFLOAT 协议解析)
- 新增 BpReading 数据模型 + 血压数据自动同步后端
- 新增 DeviceScanPage(血压计扫描绑定页)+ DeviceBindPage(已绑定管理页)
- 侧边栏新增"蓝牙设备"入口,ProfilePage 移除设备管理
- Android/iOS BLE 权限配置(BLUETOOTH_SCAN/CONNECT)
- AppTheme 升级:新阴影系统、新语义色、圆角放大
2026-06-09 18:48:58 +08:00
MingNian
f01fc9268d feat: UI清爽紫蓝风全面改造 + 对话卡片重构 + 配色体系升级
- 新增 AppColors 统一配色方案(清爽紫蓝,参考蚂蚁阿福风格)
- 新增 common_widgets(GradientBorderButton / CardActionButton / IconBox)
- AgentWelcomeCard 美化:渐变 header + AI标签 + 核心功能区 + 快捷操作区
- DataConfirmCard 统一:三合一(健康/药品/运动),可编辑字段列表
- 删除死代码:medicationConfirm/dietAnalysis/reportAnalysis/quickOptions 卡片
- 删除死路由:doctors/profileEdit/editProfile
- AI 对话去除 Markdown 符号(_stripMd),统一行间距 1.5
- 逐字淡入动画修复:key 改用 stableId 避免重复触发
- 健康概览时间修复:前端发送 UTC 避免时区歧义
- PG 数据目录迁入项目 backend/pgdata/,加入 .gitignore
- 新增欧姆龙血压计实施方案文档
2026-06-09 17:04:11 +08:00
MingNian
5f8964e03f fix: 绿色主题回滚为淡紫清新风
朝露绿翻车,换回柔和紫(#7C6FF7) + 淡紫白底(#FAF9FF)
2026-06-08 18:08:22 +08:00
MingNian
58af5f6d5b feat: UI 清新风全面改造 — 朝露主题 + shadcn_ui + 全局字号放大
- 全新"朝露"主题:深青绿主色(#2D6A4F),暖白底,完整设计Token
- 引入 shadcn_ui 组件库,MaterialApp 注入 ShadTheme
- 新增 4 个公共组件:AppCard、AppMenuItem、AppEmptyState、AppTabChip
- 全面消除硬编码颜色,统一使用 AppTheme Token
- 全局字号 +3~4pt,图标等比放大 +3px
- home/medication/profile/settings/login 等核心页面重写
- 路由和功能逻辑零改动
2026-06-08 18:06:18 +08:00
321 changed files with 40347 additions and 16815 deletions

12
.gitignore vendored
View File

@@ -4,10 +4,17 @@
*.key
*.pfx
# Android release keystore
health_app/android/**/*.jks
health_app/android/key.properties
# .NET build outputs
backend/**/bin/
backend/**/obj/
# PostgreSQL local data
backend/pgdata/
# Flutter build outputs
health_app/build/
health_app/.dart_tool/
@@ -34,9 +41,14 @@ health_app/ios/.symlinks/
# Uploads & logs
backend/src/Health.WebApi/uploads/
backend/src/Health.WebApi/data-protection-keys/
*.log
*.txt
*.jpg
*.png
!health_app/assets/branding/*.png
!health_app/android/app/src/main/res/drawable/launch_brand.png
!health_app/android/app/src/main/res/drawable-v21/launch_brand.png
# Claude
.claude/

1
PC Normal file
View File

@@ -0,0 +1 @@
Phone - forwarding OK

13
backend/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.8",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -0,0 +1,66 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.AI;
public sealed record AiConversationDto(
Guid Id,
string AgentType,
string? Title,
string? Summary,
int MessageCount,
DateTime CreatedAt,
DateTime UpdatedAt);
public sealed record AiConversationMessageDto(
Guid Id,
string Role,
string Content,
string? Intent,
string? MetadataJson,
DateTime CreatedAt);
public sealed record OpenConversationResult(
bool Found,
bool Created,
Guid ConversationId);
public interface IAiConversationService
{
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
}
public interface IAiConversationRepository
{
Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct);
Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct);
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
void Delete(Conversation conversation);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IPatientContextService
{
Task<string> BuildAsync(Guid userId, CancellationToken ct);
}
public sealed record AiConfirmedWriteResult(int Code, object? Data, string? Message);
public interface IAiToolExecutionService
{
Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct);
Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct);
}

View File

@@ -0,0 +1,152 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.AI;
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
{
private const int HistoryConversationLimit = 7;
private readonly IAiConversationRepository _conversations = conversations;
public async Task<OpenConversationResult> OpenAsync(
Guid userId,
Guid? conversationId,
AgentType agentType,
string firstMessage,
CancellationToken ct)
{
if (conversationId.HasValue)
{
var existing = await _conversations.GetOwnedAsync(userId, conversationId.Value, ct);
return existing == null
? new OpenConversationResult(false, false, conversationId.Value)
: new OpenConversationResult(true, false, existing.Id);
}
var conversation = new Conversation
{
Id = Guid.NewGuid(),
UserId = userId,
AgentType = agentType,
Title = firstMessage.Length > 30 ? firstMessage[..30] : firstMessage,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _conversations.AddConversationAsync(conversation, ct);
await _conversations.SaveChangesAsync(ct);
await PruneHistoryAsync(userId, ct);
return new OpenConversationResult(true, true, conversation.Id);
}
public async Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, metadataJson, ct);
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson: null, ct);
public async Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson, ct);
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
{
var messages = await _conversations.GetRecentMessagesAsync(conversationId, limit, ct);
return messages.OrderBy(m => m.CreatedAt).Select(ToMessageDto).ToList();
}
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
{
var conversations = await _conversations.ListAsync(userId, ct);
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
}
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
if (conversation == null) return [];
var messages = await _conversations.GetMessagesAsync(conversationId, ct);
return messages.Select(ToMessageDto).ToList();
}
public async Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct)
{
var conversation = await _conversations.GetAsync(conversationId, ct)
?? throw new InvalidOperationException("会话不存在");
var normalized = summary.Trim();
if (string.IsNullOrWhiteSpace(normalized)) return;
conversation.Summary = normalized.Length > 24 ? normalized[..24] : normalized;
await _conversations.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
if (conversation == null) return;
_conversations.Delete(conversation);
await _conversations.SaveChangesAsync(ct);
}
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
{
var list = await _conversations.ListAsync(userId, ct);
foreach (var conv in list)
{
_conversations.Delete(conv);
}
if (list.Count > 0)
await _conversations.SaveChangesAsync(ct);
return list.Count;
}
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, string? metadataJson, CancellationToken ct)
{
var conversation = await _conversations.GetAsync(conversationId, ct)
?? throw new InvalidOperationException("会话不存在");
await _conversations.AddMessageAsync(new ConversationMessage
{
Id = Guid.NewGuid(),
ConversationId = conversationId,
Role = role,
Content = content,
MetadataJson = metadataJson,
CreatedAt = DateTime.UtcNow,
}, ct);
conversation.MessageCount++;
conversation.UpdatedAt = DateTime.UtcNow;
if (updateSummary)
conversation.Summary = content.Length > 100 ? content[..100] : content;
await _conversations.SaveChangesAsync(ct);
await PruneHistoryAsync(conversation.UserId, ct);
}
private async Task PruneHistoryAsync(Guid userId, CancellationToken ct)
{
var list = await _conversations.ListAsync(userId, ct);
var expired = list.Skip(HistoryConversationLimit).ToList();
if (expired.Count == 0) return;
foreach (var conversation in expired)
_conversations.Delete(conversation);
await _conversations.SaveChangesAsync(ct);
}
private static AiConversationDto ToDto(Conversation conversation) => new(
conversation.Id,
conversation.AgentType.ToString(),
conversation.Title,
conversation.Summary,
conversation.MessageCount,
conversation.CreatedAt,
conversation.UpdatedAt);
private static AiConversationMessageDto ToMessageDto(ConversationMessage message) => new(
message.Id,
message.Role.ToString(),
message.Content,
message.Intent,
message.MetadataJson,
message.CreatedAt);
}

View File

@@ -0,0 +1,15 @@
namespace Health.Application.AI;
public sealed record PendingAiWriteCommand(
Guid Id,
Guid UserId,
string ToolName,
string Arguments,
DateTime ExpiresAt);
public interface IAiWriteConfirmationStore
{
Task<PendingAiWriteCommand> CreateAsync(Guid userId, string toolName, string arguments, TimeSpan lifetime, CancellationToken ct);
Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct);
Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct);
}

View File

@@ -0,0 +1,20 @@
namespace Health.Application.AI;
/// <summary>
/// 用户消息附带的图片/PDF 解析结果。
/// 既用作 LLM 上下文拼装,也持久化到 ConversationMessage.MetadataJson。
/// </summary>
public sealed record AttachmentContext(
string Kind, // "image" 或 "pdf"
string? Category, // 仅图片food / report / wound / drug / chart / other
string? FileName, // PDF 文件原名
string Summary, // 简短摘要,用于持久化和历史回顾
string LlmContent); // 完整文本,拼到当前轮 LLM user content 前
public interface IAttachmentContextBuilder
{
/// <summary>
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
/// </summary>
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
}

View File

@@ -0,0 +1,68 @@
using System.Text;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
namespace Health.Application.AI;
public sealed class PatientContextService(
IHealthArchiveService archives,
IHealthRecordService healthRecords,
IMedicationService medications) : IPatientContextService
{
private readonly IHealthArchiveService _archives = archives;
private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IMedicationService _medications = medications;
public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
{
var archive = await _archives.GetAsync(userId, ct);
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
var activeMedications = await _medications.ListActiveAsync(userId, ct);
var sb = new StringBuilder();
if (archive != null)
{
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
if (archive.Surgeries.Count > 0)
sb.AppendLine($"手术: {string.Join("", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
else if (!string.IsNullOrEmpty(archive.SurgeryType))
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
if (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}");
if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}");
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
}
if (activeMedications.Count > 0)
{
sb.AppendLine("当前用药:");
foreach (var medication in activeMedications.Take(20))
{
var times = medication.TimeOfDay.Count > 0
? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm")))
: "未设置时间";
sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}");
}
}
if (recentRecords.Count > 0)
{
sb.AppendLine("近期健康数据:");
foreach (var record in recentRecords)
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})");
}
return sb.ToString();
}
private static string RecordValue(HealthRecordDto record) => record.Type switch
{
"BloodPressure" => $"{record.Systolic}/{record.Diastolic}",
"HeartRate" => $"{record.Value}次/分",
"Glucose" => $"{record.Value}",
"SpO2" => $"{record.Value}%",
"Weight" => $"{record.Value}kg",
_ => "—"
};
}

View File

@@ -0,0 +1,15 @@
namespace Health.Application.Admin;
public sealed record AdminResult(int Code, object? Data, string? Message = null);
public sealed record AddDoctorCommand(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection);
public sealed record UpdateDoctorCommand(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection);
public interface IAdminService
{
Task<AdminResult> ListDoctorsAsync(CancellationToken ct);
Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct);
Task<AdminResult> UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct);
Task<AdminResult> ToggleDoctorAsync(Guid id, CancellationToken ct);
Task<AdminResult> DeleteDoctorAsync(Guid id, CancellationToken ct);
Task<AdminResult> ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct);
}

View File

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

View File

@@ -0,0 +1,19 @@
using Health.Domain.Entities;
namespace Health.Application.Calendars;
public sealed record CalendarDataSnapshot(
IReadOnlyList<Medication> Medications,
IReadOnlyList<ExercisePlan> ExercisePlans,
IReadOnlyList<FollowUp> FollowUps);
public interface ICalendarService
{
Task<IReadOnlyList<object>> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct);
Task<object> GetDayAsync(Guid userId, DateOnly date, CancellationToken ct);
}
public interface ICalendarRepository
{
Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct);
}

View File

@@ -0,0 +1,92 @@
namespace Health.Application.Calendars;
public sealed class CalendarService(ICalendarRepository calendar) : ICalendarService
{
private readonly ICalendarRepository _calendar = calendar;
public async Task<IReadOnlyList<object>> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct)
{
var start = new DateOnly(year, month, 1);
var end = start.AddMonths(1);
var snapshot = await _calendar.GetSnapshotAsync(userId, start, end, ct);
var result = new List<object>();
for (var date = start; date < end; date = date.AddDays(1))
{
var entries = BuildEntries(snapshot, date);
if (entries.Count == 0) continue;
result.Add(new
{
date = date.ToString("yyyy-MM-dd"),
events = entries.Select(entry => entry.Type).Distinct().ToList(),
details = entries.Select(entry => entry.Value).ToList()
});
}
return result;
}
public async Task<object> GetDayAsync(Guid userId, DateOnly date, CancellationToken ct)
{
var snapshot = await _calendar.GetSnapshotAsync(userId, date, date.AddDays(1), ct);
var medications = snapshot.Medications
.Where(m => IsMedicationActiveOn(m, date))
.Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() })
.ToList();
var exercises = snapshot.ExercisePlans
.SelectMany(p => p.Items)
.Where(i => i.ScheduledDate == date && !i.IsRestDay)
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
.ToList();
var followUps = snapshot.FollowUps
.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
.ToList();
return new { medications, exercises, followUps };
}
private static List<CalendarEntry> BuildEntries(CalendarDataSnapshot snapshot, DateOnly date)
{
var entries = new List<CalendarEntry>();
foreach (var medication in snapshot.Medications.Where(m => IsMedicationActiveOn(m, date)))
{
entries.Add(new CalendarEntry("medication", new
{
type = "medication",
name = medication.Name,
dosage = medication.Dosage,
timeOfDay = medication.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList()
}));
}
foreach (var plan in snapshot.ExercisePlans)
{
foreach (var item in plan.Items.Where(i => i.ScheduledDate == date && !i.IsRestDay))
entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate }));
}
foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date))
{
entries.Add(new CalendarEntry("followup", new
{
type = "followup",
title = followUp.Title,
doctorName = followUp.DoctorName,
department = followUp.Department,
status = followUp.Status.ToString()
}));
}
return entries;
}
private static bool IsMedicationActiveOn(Health.Domain.Entities.Medication medication, DateOnly date) =>
medication.IsActive
&& medication.TimeOfDay.Count > 0
&& (medication.StartDate == null || medication.StartDate <= date)
&& (medication.EndDate == null || medication.EndDate >= date);
private sealed record CalendarEntry(string Type, object Value);
}

View File

@@ -0,0 +1,51 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Diets;
public sealed record DietFoodItemInput(
string Name,
string? Portion,
int? Calories,
int SortOrder);
public sealed record DietRecordCreateRequest(
MealType MealType,
int? TotalCalories,
int? HealthScore,
DateOnly RecordedAt,
IReadOnlyList<DietFoodItemInput> FoodItems);
public sealed record DietRecordPatchRequest(
int? TotalCalories,
int? HealthScore);
public sealed record DietFoodItemDto(
string Name,
string? Portion,
int? Calories);
public sealed record DietRecordDto(
Guid Id,
string MealType,
int? TotalCalories,
int? HealthScore,
DateOnly RecordedAt,
IReadOnlyList<DietFoodItemDto> FoodItems);
public interface IDietService
{
Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct);
}
public interface IDietRepository
{
Task<IReadOnlyList<DietRecord>> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct);
Task<DietRecord?> GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct);
Task AddAsync(DietRecord record, CancellationToken ct);
void Delete(DietRecord record);
Task SaveChangesAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,27 @@
namespace Health.Application.Diets;
public sealed record DietImageUploadFile(string FileName, long Length, Stream Content);
public sealed record DietImageAnalysisJob(Guid Id, IReadOnlyList<string> FilePaths);
public sealed record DietImageAnalysisResult(bool Success, int Code, string? Data, string? Message);
public interface IDietImageAnalysisCoordinator
{
Task<DietImageAnalysisResult> AnalyzeAsync(IReadOnlyList<DietImageUploadFile> files, CancellationToken ct);
}
public interface IDietImageAnalysisQueue
{
Task<Guid> EnqueueAsync(IReadOnlyList<string> filePaths, CancellationToken ct);
Task RecoverStaleAsync(CancellationToken ct);
Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct);
Task<DietImageAnalysisResult> WaitForResultAsync(Guid jobId, CancellationToken ct);
Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct);
Task<bool> RetryAsync(Guid jobId, string error, CancellationToken ct);
}
public interface IDietImageAnalyzer
{
Task<DietImageAnalysisResult> AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct);
}

View File

@@ -0,0 +1,103 @@
using Health.Domain;
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Diets;
public sealed class DietService(IDietRepository diets) : IDietService
{
private const int MaxCalories = 20000; // 单条记录/单项热量上限kcal
private readonly IDietRepository _diets = diets;
public async Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct)
{
var recordedAt = DateOnly.TryParse(date, out var d) ? d : (DateOnly?)null;
var parsedMealType = Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt) ? mt : (MealType?)null;
var records = await _diets.ListAsync(userId, recordedAt, parsedMealType, ct);
return records.Select(ToDto).ToList();
}
public async Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct)
{
ValidateCalories(request.TotalCalories, "总热量");
ValidateScore(request.HealthScore);
foreach (var item in request.FoodItems)
{
if (string.IsNullOrWhiteSpace(item.Name))
throw new ValidationException("食物名称不能为空");
ValidateCalories(item.Calories, $"食物「{item.Name.Trim()}」热量");
}
var record = new DietRecord
{
Id = Guid.NewGuid(),
UserId = userId,
MealType = request.MealType,
TotalCalories = request.TotalCalories,
HealthScore = request.HealthScore,
RecordedAt = request.RecordedAt,
CreatedAt = DateTime.UtcNow,
};
foreach (var item in request.FoodItems)
{
record.FoodItems.Add(new DietFoodItem
{
Id = Guid.NewGuid(),
Name = item.Name.Trim(),
Portion = item.Portion,
Calories = item.Calories,
SortOrder = item.SortOrder,
});
}
await _diets.AddAsync(record, ct);
await _diets.SaveChangesAsync(ct);
return record.Id;
}
public async Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct)
{
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
if (record == null) return false;
_diets.Delete(record);
await _diets.SaveChangesAsync(ct);
return true;
}
public async Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct)
{
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
if (record == null) return false;
ValidateCalories(request.TotalCalories, "总热量");
ValidateScore(request.HealthScore);
if (request.TotalCalories.HasValue) record.TotalCalories = request.TotalCalories.Value;
if (request.HealthScore.HasValue) record.HealthScore = request.HealthScore.Value;
await _diets.SaveChangesAsync(ct);
return true;
}
private static void ValidateCalories(int? calories, string field)
{
if (calories.HasValue && (calories.Value < 0 || calories.Value > MaxCalories))
throw new ValidationException($"{field}应在 0~{MaxCalories} kcal 之间,当前值 {calories.Value} 不合理");
}
private static void ValidateScore(int? score)
{
if (score.HasValue && (score.Value < 0 || score.Value > 100))
throw new ValidationException($"健康评分应在 0~100 之间,当前值 {score.Value} 不合理");
}
private static DietRecordDto ToDto(DietRecord record) => new(
record.Id,
record.MealType.ToString(),
record.TotalCalories,
record.HealthScore,
record.RecordedAt,
record.FoodItems.OrderBy(f => f.SortOrder).Select(f => new DietFoodItemDto(f.Name, f.Portion, f.Calories)).ToList());
}

View File

@@ -0,0 +1,67 @@
using Health.Domain.Entities;
namespace Health.Application.Exercises;
public sealed record ExercisePlanCreateRequest(
DateOnly StartDate,
DateOnly EndDate,
string? ExerciseType,
int DurationMinutes,
TimeOnly? ReminderTime = null);
public sealed record ExercisePlanItemDto(
Guid Id,
DateOnly ScheduledDate,
string ExerciseType,
int DurationMinutes,
bool IsCompleted,
DateTime? CompletedAt,
bool IsRestDay);
public sealed record ExercisePlanDto(
Guid Id,
DateOnly StartDate,
DateOnly EndDate,
TimeOnly ReminderTime,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<ExercisePlanItemDto> Items);
public sealed record ExercisePlanSummaryDto(
Guid Id,
DateOnly StartDate,
DateOnly EndDate,
TimeOnly ReminderTime,
DateTime CreatedAt,
int TotalDays,
int CompletedDays,
IReadOnlyList<ExercisePlanItemDto> Items);
public interface IExerciseService
{
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct);
}
public interface IExerciseRepository
{
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
Task AddAsync(ExercisePlan plan, CancellationToken ct);
void Delete(ExercisePlan plan);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IExerciseReminderProducer
{
Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct);
}

View File

@@ -0,0 +1,134 @@
using Health.Domain;
using Health.Domain.Entities;
namespace Health.Application.Exercises;
public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService
{
private const int MaxDurationMinutes = 1440; // 单次运动时长上限 24 小时
private static readonly TimeOnly DefaultReminderTime = new(19, 0);
private readonly IExerciseRepository _exercises = exercises;
public async Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct)
{
var today = BeijingToday();
var plans = await _exercises.ListActiveOnAsync(userId, today, ct);
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.ScheduledDate == today));
return plan == null ? null : ToDto(plan);
}
public async Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct)
{
ValidateDuration(request.DurationMinutes);
var startDate = request.StartDate;
var endDate = request.EndDate < startDate ? startDate : request.EndDate;
if (endDate.DayNumber - startDate.DayNumber > 365)
endDate = startDate.AddDays(365);
var exerciseType = string.IsNullOrWhiteSpace(request.ExerciseType) ? "运动" : request.ExerciseType.Trim();
var duration = request.DurationMinutes > 0 ? request.DurationMinutes : 30;
var plan = NewPlan(userId, startDate, endDate, request.ReminderTime);
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType,
DurationMinutes = duration, IsRestDay = false,
});
}
await SaveNewAsync(plan, ct);
return plan.Id;
}
// 时长上限校验:下限交由各方法兜底为 30这里只拦截不合理的超大值
private static void ValidateDuration(int durationMinutes)
{
if (durationMinutes > MaxDurationMinutes)
throw new ValidationException($"单次运动时长不能超过 {MaxDurationMinutes} 分钟24 小时),当前值 {durationMinutes} 不合理");
}
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
{
var plans = await _exercises.ListAsync(userId, 20, ct);
return plans.Select(p => new ExercisePlanSummaryDto(
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt,
p.Items.Count, p.Items.Count(i => i.IsCompleted),
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList();
}
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
{
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
return plan == null ? null : ToDto(plan);
}
public async Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct)
{
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
if (plan == null) return false;
_exercises.Delete(plan);
await _exercises.SaveChangesAsync(ct);
return true;
}
public async Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
{
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return null;
if (item.ScheduledDate != BeijingToday())
throw new ValidationException("只能打卡今天的运动任务");
if (item.IsRestDay)
throw new ValidationException("休息日无需打卡");
item.IsCompleted = !item.IsCompleted;
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
item.Plan.UpdatedAt = DateTime.UtcNow;
await _exercises.SaveChangesAsync(ct);
return item.IsCompleted;
}
public async Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
{
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return false;
if (item.ScheduledDate != BeijingToday())
throw new ValidationException("只能打卡今天的运动任务");
if (item.IsRestDay)
throw new ValidationException("休息日无需打卡");
item.IsCompleted = true;
item.CompletedAt = DateTime.UtcNow;
item.Plan.UpdatedAt = DateTime.UtcNow;
await _exercises.SaveChangesAsync(ct);
return true;
}
public async Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct)
{
var plan = await _exercises.GetLatestAsync(userId, ct);
return plan == null ? null : ToDto(plan);
}
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
{
await _exercises.AddAsync(plan, ct);
await _exercises.SaveChangesAsync(ct);
}
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
{
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate,
ReminderTime = reminderTime ?? DefaultReminderTime,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
item.IsCompleted, item.CompletedAt, item.IsRestDay);
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
}

View File

@@ -0,0 +1,49 @@
using Health.Domain.Entities;
namespace Health.Application.HealthArchives;
public sealed record HealthArchiveSurgeryDto(
Guid Id,
string Type,
DateOnly? Date);
public sealed record HealthArchiveSurgeryInput(
string Type,
DateOnly? Date);
public sealed record HealthArchiveDto(
Guid Id,
Guid UserId,
string? Diagnosis,
string? SurgeryType,
DateOnly? SurgeryDate,
IReadOnlyList<HealthArchiveSurgeryDto> Surgeries,
IReadOnlyList<string> Allergies,
IReadOnlyList<string> DietRestrictions,
IReadOnlyList<string> ChronicDiseases,
string? FamilyHistory,
DateTime UpdatedAt);
public sealed record HealthArchiveUpdateRequest(
string? Diagnosis,
string? SurgeryType,
DateOnly? SurgeryDate,
IReadOnlyList<string>? Allergies,
IReadOnlyList<string>? DietRestrictions,
IReadOnlyList<string>? ChronicDiseases,
string? FamilyHistory,
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
public interface IHealthArchiveService
{
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct);
}
public interface IHealthArchiveRepository
{
Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct);
Task AddAsync(HealthArchive archive, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,171 @@
using Health.Domain.Entities;
namespace Health.Application.HealthArchives;
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
{
private readonly IHealthArchiveRepository _archives = archives;
public async Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct)
{
var archive = await _archives.GetByUserIdAsync(userId, ct);
return archive == null ? null : ToDto(archive);
}
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
{
var archive = await GetOrCreateEntityAsync(userId, ct);
Apply(archive, request);
archive.UpdatedAt = DateTime.UtcNow;
await _archives.SaveChangesAsync(ct);
return ToDto(archive);
}
public async Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct)
{
if (action == "query")
{
var archive = await GetAsync(userId, ct);
return archive == null ? new { found = false } : ToAiResult(archive);
}
var current = await GetOrCreateEntityAsync(userId, ct);
var scopedRequest = action switch
{
"update_diagnosis" => request with
{
SurgeryType = null, SurgeryDate = null, Allergies = null,
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null,
Surgeries = null
},
"update_surgery" => request with
{
Diagnosis = null, Allergies = null, DietRestrictions = null,
ChronicDiseases = null, FamilyHistory = null
},
"update_allergies" => new HealthArchiveUpdateRequest(null, null, null, request.Allergies, null, null, null),
"update_chronic_diseases" => new HealthArchiveUpdateRequest(null, null, null, null, null, request.ChronicDiseases, null),
"update_diet_restrictions" => new HealthArchiveUpdateRequest(null, null, null, null, request.DietRestrictions, null, null),
_ => null
};
if (scopedRequest == null)
return new { success = false, message = $"未知档案操作: {action}" };
Apply(current, scopedRequest);
current.UpdatedAt = DateTime.UtcNow;
await _archives.SaveChangesAsync(ct);
return new { success = true };
}
private async Task<HealthArchive> GetOrCreateEntityAsync(Guid userId, CancellationToken ct)
{
var archive = await _archives.GetByUserIdAsync(userId, ct);
if (archive != null) return archive;
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId, UpdatedAt = DateTime.UtcNow };
await _archives.AddAsync(archive, ct);
return archive;
}
private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request)
{
if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis;
if (request.Surgeries != null)
{
archive.Surgeries.Clear();
var items = request.Surgeries
.Where(s => !string.IsNullOrWhiteSpace(s.Type))
.Select((s, index) => new HealthArchiveSurgery
{
Id = Guid.NewGuid(),
HealthArchiveId = archive.Id,
Type = s.Type.Trim(),
Date = s.Date,
SortOrder = index,
})
.ToList();
foreach (var item in items) archive.Surgeries.Add(item);
SyncLegacySurgeryFields(archive);
}
else if (request.SurgeryType != null)
{
SeedLegacySurgeryIfNeeded(archive);
var type = request.SurgeryType.Trim();
if (type.Length > 0 && !archive.Surgeries.Any(s =>
s.Type == type && s.Date == request.SurgeryDate))
{
archive.Surgeries.Add(new HealthArchiveSurgery
{
Id = Guid.NewGuid(),
HealthArchiveId = archive.Id,
Type = type,
Date = request.SurgeryDate,
SortOrder = archive.Surgeries.Count,
});
}
SyncLegacySurgeryFields(archive);
}
if (request.Allergies != null) archive.Allergies = request.Allergies.ToList();
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
}
private static object ToAiResult(HealthArchiveDto archive) => new
{
found = true,
archive.Diagnosis,
archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
surgeries = archive.Surgeries.Select(s => new { s.Type, date = s.Date?.ToString("yyyy-MM-dd") }),
archive.Allergies,
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
};
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
archive.Id,
archive.UserId,
archive.Diagnosis,
archive.SurgeryType,
archive.SurgeryDate,
GetSurgeries(archive),
archive.Allergies,
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
archive.UpdatedAt);
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
{
var surgeries = archive.Surgeries
.OrderBy(s => s.SortOrder)
.Select(s => new HealthArchiveSurgeryDto(s.Id, s.Type, s.Date))
.ToList();
if (surgeries.Count == 0 && !string.IsNullOrWhiteSpace(archive.SurgeryType))
surgeries.Add(new HealthArchiveSurgeryDto(Guid.Empty, archive.SurgeryType, archive.SurgeryDate));
return surgeries;
}
private static void SyncLegacySurgeryFields(HealthArchive archive)
{
var first = archive.Surgeries.OrderBy(s => s.SortOrder).FirstOrDefault();
archive.SurgeryType = first?.Type;
archive.SurgeryDate = first?.Date;
}
private static void SeedLegacySurgeryIfNeeded(HealthArchive archive)
{
if (archive.Surgeries.Count > 0 || string.IsNullOrWhiteSpace(archive.SurgeryType)) return;
archive.Surgeries.Add(new HealthArchiveSurgery
{
Id = Guid.NewGuid(),
HealthArchiveId = archive.Id,
Type = archive.SurgeryType,
Date = archive.SurgeryDate,
SortOrder = 0,
});
}
}

View File

@@ -0,0 +1,45 @@
using Health.Domain.Enums;
using Health.Domain.Entities;
namespace Health.Application.HealthRecords;
public sealed record HealthRecordUpsertRequest(
HealthMetricType Type,
int? Systolic,
int? Diastolic,
decimal? Value,
string? Unit,
HealthRecordSource Source,
DateTime? RecordedAt);
public sealed record HealthRecordDto(
Guid Id,
string Type,
int? Systolic,
int? Diastolic,
decimal? Value,
string? Unit,
string Source,
bool IsAbnormal,
DateTime RecordedAt);
public interface IHealthRecordService
{
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct);
}
public interface IHealthRecordRepository
{
Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct);
Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct);
Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct);
Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct);
Task AddAsync(HealthRecord record, CancellationToken ct);
void Delete(HealthRecord record);
Task SaveChangesAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,61 @@
using Health.Domain;
using Health.Domain.Enums;
namespace Health.Application.HealthRecords;
public static class HealthRecordRules
{
public static bool CheckAbnormal(HealthRecordUpsertRequest request) => request.Type switch
{
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m,
HealthMetricType.SpO2 => request.Value <= 94,
HealthMetricType.Weight => false,
_ => false
};
/// <summary>
/// 合法性校验——拦截物理上不可能的数值(负数、超量程等),不合法即抛 ValidationException。
/// 注意:这与 CheckAbnormal医学异常预警是两回事前者拦脏数据后者只是标记偏离正常范围。
/// </summary>
public static void Validate(HealthRecordUpsertRequest request)
{
switch (request.Type)
{
case HealthMetricType.BloodPressure:
var sys = Required(request.Systolic, "收缩压");
var dia = Required(request.Diastolic, "舒张压");
InRange(sys, 40, 300, "收缩压", "mmHg");
InRange(dia, 20, 250, "舒张压", "mmHg");
if (sys <= dia)
throw new ValidationException("收缩压必须大于舒张压");
break;
case HealthMetricType.HeartRate:
InRange(Required(request.Value, "心率"), 20m, 300m, "心率", "次/分");
break;
case HealthMetricType.Glucose:
InRange(Required(request.Value, "血糖"), 0.5m, 60m, "血糖", "mmol/L");
break;
case HealthMetricType.SpO2:
InRange(Required(request.Value, "血氧"), 50m, 100m, "血氧", "%");
break;
case HealthMetricType.Weight:
InRange(Required(request.Value, "体重"), 1m, 500m, "体重", "kg");
break;
}
}
private static int Required(int? value, string field) =>
value ?? throw new ValidationException($"{field}不能为空");
private static decimal Required(decimal? value, string field) =>
value ?? throw new ValidationException($"{field}不能为空");
private static void InRange(decimal value, decimal min, decimal max, string field, string unit)
{
if (value < min || value > max)
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
}
}

View File

@@ -0,0 +1,166 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Application.Notifications;
using System.Security.Cryptography;
using System.Text;
namespace Health.Application.HealthRecords;
public sealed class HealthRecordService(
IHealthRecordRepository records,
IUserNotificationProducer? notifications = null) : IHealthRecordService
{
private readonly IHealthRecordRepository _records = records;
private readonly IUserNotificationProducer? _notifications = notifications;
public async Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct)
{
HealthMetricType? metricType = null;
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var parsed))
metricType = parsed;
var recordedAfter = days.HasValue ? DateTime.UtcNow.AddDays(-days.Value) : (DateTime?)null;
var result = await _records.ListAsync(userId, metricType, recordedAfter, 100, ct);
return result.Select(ToDto).ToList();
}
public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct)
{
HealthRecordRules.Validate(request);
var record = 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 ?? DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
};
await _records.AddAsync(record, ct);
await _records.SaveChangesAsync(ct);
await EnqueueAbnormalNotificationAsync(record, ct);
return record.Id;
}
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
{
var record = await _records.GetOwnedAsync(userId, id, ct);
if (record == null) return false;
HealthRecordRules.Validate(request);
record.MetricType = request.Type;
record.Systolic = request.Systolic;
record.Diastolic = request.Diastolic;
record.Value = request.Value;
record.Unit = request.Unit;
record.Source = request.Source;
record.RecordedAt = request.RecordedAt ?? record.RecordedAt;
record.IsAbnormal = HealthRecordRules.CheckAbnormal(request);
await _records.SaveChangesAsync(ct);
await EnqueueAbnormalNotificationAsync(record, ct);
return true;
}
public async Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct)
{
var record = await _records.GetOwnedAsync(userId, id, ct);
if (record == null) return false;
_records.Delete(record);
await _records.SaveChangesAsync(ct);
return true;
}
public async Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct)
{
var types = new[]
{
HealthMetricType.BloodPressure,
HealthMetricType.HeartRate,
HealthMetricType.Glucose,
HealthMetricType.SpO2,
HealthMetricType.Weight
};
var result = new Dictionary<string, object?>();
foreach (var type in types)
{
var latest = await _records.GetLatestByTypeAsync(userId, type, ct);
result[type.ToString()] = latest == null ? null : new
{
latest.Systolic,
latest.Diastolic,
latest.Value,
latest.Unit,
latest.RecordedAt
};
}
return result;
}
public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct)
{
var days = period switch { 7 => 7, 30 => 30, 90 => 90, 365 => 365, _ => 7 };
var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct);
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
}
public static HealthRecordDto ToDto(HealthRecord record) => new(
record.Id,
record.MetricType.ToString(),
record.Systolic,
record.Diastolic,
record.Value,
record.Unit,
record.Source.ToString(),
record.IsAbnormal,
record.RecordedAt);
private async Task EnqueueAbnormalNotificationAsync(HealthRecord record, CancellationToken ct)
{
if (!record.IsAbnormal || _notifications == null) return;
var (title, message, severity, target) = record.MetricType switch
{
HealthMetricType.BloodPressure when record.Systolic >= 140 || record.Diastolic >= 90 =>
("血压偏高提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg高于参考范围。建议休息后复测如伴明显不适请及时就医。", "warning", "blood_pressure"),
HealthMetricType.BloodPressure =>
("血压偏低提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg低于参考范围。请留意头晕、乏力等不适必要时及时就医。", "warning", "blood_pressure"),
HealthMetricType.HeartRate when record.Value > 100 =>
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
HealthMetricType.HeartRate =>
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
HealthMetricType.Glucose when record.Value >= 7.0m =>
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
HealthMetricType.Glucose =>
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L低于参考范围。请及时关注身体状况。", "critical", "glucose"),
HealthMetricType.SpO2 =>
("血氧偏低提醒", $"本次血氧为 {record.Value:0.#}%,低于参考范围。建议立即复测;如持续偏低或伴呼吸不适,请及时就医。", "critical", "spo2"),
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
};
var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
await _notifications.EnqueueAsync(
record.UserId,
sourceId,
new NotificationMessage("HealthMetricAlert", title, message, severity, "health", target),
ct);
}
private static Guid DeterministicGuid(string value)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return new Guid(hash.AsSpan(0, 16));
}
}

View File

@@ -0,0 +1,16 @@
namespace Health.Application.Maintenance;
public sealed record MaintenanceCleanupResult(
int Conversations,
int VerificationCodes,
int BackgroundTasks);
public interface IMaintenanceService
{
Task<MaintenanceCleanupResult> CleanupAsync(DateTime utcNow, CancellationToken ct);
}
public interface IMaintenanceRepository
{
Task<MaintenanceCleanupResult> CleanupAsync(DateTime conversationCutoff, DateTime taskCutoff, DateTime utcNow, CancellationToken ct);
}

View File

@@ -0,0 +1,9 @@
namespace Health.Application.Maintenance;
public sealed class MaintenanceService(IMaintenanceRepository repository) : IMaintenanceService
{
private readonly IMaintenanceRepository _repository = repository;
public Task<MaintenanceCleanupResult> CleanupAsync(DateTime utcNow, CancellationToken ct) =>
_repository.CleanupAsync(utcNow.AddDays(-30), utcNow.AddDays(-30), utcNow, ct);
}

View File

@@ -0,0 +1,105 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Medications;
public sealed record MedicationUpsertRequest(
string Name,
string? Dosage,
MedicationFrequency Frequency,
IReadOnlyList<TimeOnly> TimeOfDay,
DateOnly? StartDate,
DateOnly? EndDate,
MedicationSource Source,
string? Notes);
public sealed record MedicationPatchRequest(
string? Name,
string? Dosage,
MedicationFrequency? Frequency,
IReadOnlyList<TimeOnly>? TimeOfDay,
DateOnly? StartDate,
DateOnly? EndDate,
string? Notes);
public sealed record MedicationDto(
Guid Id,
string Name,
string? Dosage,
string Frequency,
IReadOnlyList<TimeOnly> TimeOfDay,
DateOnly? StartDate,
DateOnly? EndDate,
bool IsActive,
string? Notes,
string Source,
DateTime CreatedAt,
DateTime UpdatedAt,
bool TodayTaken);
public sealed record MedicationReminderDto(
Guid Id,
string Name,
string? Dosage,
string ScheduledTime,
string Status);
public sealed record MedicationReminderTask(
Guid TaskId,
Guid UserId,
Guid MedicationId,
string Name,
string? Dosage,
DateOnly ScheduledDate,
TimeOnly ScheduledTime);
public interface IMedicationService
{
Task<IReadOnlyList<MedicationDto>> ListAsync(Guid userId, string? filter, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct);
Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct);
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
}
public interface IMedicationRepository
{
Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct);
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
Task AddMedicationAsync(Medication medication, CancellationToken ct);
Task AddLogAsync(MedicationLog log, CancellationToken ct);
void DeleteMedication(Medication medication);
void DeleteLog(MedicationLog log);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IMedicationReminderScanner
{
Task<IReadOnlyList<MedicationReminderTask>> ScanAsync(DateTime utcNow, CancellationToken ct);
}
public interface IMedicationReminderQueue
{
Task<bool> EnqueueAsync(MedicationReminderTask task, CancellationToken ct);
Task RecoverStaleAsync(CancellationToken ct);
Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct);
Task CompleteAsync(Guid taskId, CancellationToken ct);
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
}
public interface IMedicationReminderDispatcher
{
Task DispatchAsync(MedicationReminderTask task, CancellationToken ct);
}

View File

@@ -0,0 +1,72 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Medications;
public sealed class MedicationReminderScanner(IMedicationRepository medications) : IMedicationReminderScanner
{
private readonly IMedicationRepository _medications = medications;
public async Task<IReadOnlyList<MedicationReminderTask>> ScanAsync(DateTime utcNow, CancellationToken ct)
{
var beijingNow = utcNow.AddHours(8);
var date = DateOnly.FromDateTime(beijingNow);
var time = TimeOnly.FromDateTime(beijingNow);
var windowStart = time.AddMinutes(-5);
var todayStartUtc = beijingNow.Date.AddHours(-8);
var todayEndUtc = todayStartUtc.AddDays(1);
var active = await _medications.ListActiveForReminderScanAsync(ct);
var tasks = new List<MedicationReminderTask>();
foreach (var medication in active)
{
if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue;
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time)))
{
if (await _medications.DoseLogExistsAsync(
medication.UserId,
medication.Id,
scheduledTime,
todayStartUtc,
todayEndUtc,
ct))
continue;
tasks.Add(new MedicationReminderTask(
Guid.NewGuid(),
medication.UserId,
medication.Id,
medication.Name,
medication.Dosage,
date,
scheduledTime));
}
}
return tasks;
}
private static bool IsActiveOn(Medication medication, DateOnly date) =>
(medication.StartDate == null || medication.StartDate <= date)
&& (medication.EndDate == null || medication.EndDate >= date);
private static bool IsInWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
end >= start ? value >= start && value <= end : value >= start || value <= end;
private static bool GetDosesForToday(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;
}
}

View File

@@ -0,0 +1,246 @@
using Health.Domain;
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Medications;
public sealed class MedicationService(IMedicationRepository medications) : IMedicationService
{
private readonly IMedicationRepository _medications = medications;
public async Task<IReadOnlyList<MedicationDto>> ListAsync(Guid userId, string? filter, CancellationToken ct)
{
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var meds = await _medications.ListAsync(userId, filter, ct);
return meds.Select(m => ToDto(m, HasTakenToday(m, todayStartUtc, todayEndUtc))).ToList();
}
public async Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct)
{
var name = ValidateName(request.Name);
ValidateDateRange(request.StartDate, request.EndDate);
var times = NormalizeTimes(request.TimeOfDay);
if (times.Count == 0) times = [new TimeOnly(8, 0)];
var med = new Medication
{
Id = Guid.NewGuid(),
UserId = userId,
Name = name,
Dosage = request.Dosage,
Frequency = request.Frequency,
TimeOfDay = times,
StartDate = request.StartDate,
EndDate = request.EndDate,
Source = request.Source,
Notes = request.Notes,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _medications.AddMedicationAsync(med, ct);
await _medications.SaveChangesAsync(ct);
return med.Id;
}
public async Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct)
{
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
if (med == null) return false;
if (request.Name != null) med.Name = ValidateName(request.Name);
if (request.Dosage != null) med.Dosage = request.Dosage;
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
ValidateDateRange(med.StartDate, med.EndDate);
if (request.Notes != null) med.Notes = request.Notes;
med.UpdatedAt = DateTime.UtcNow;
await _medications.SaveChangesAsync(ct);
return true;
}
// ── 输入校验 ──
private static string ValidateName(string? name)
{
var trimmed = name?.Trim();
if (string.IsNullOrEmpty(trimmed))
throw new ValidationException("药品名称不能为空");
if (trimmed.Length > 200)
throw new ValidationException("药品名称过长(不超过 200 字)");
return trimmed;
}
private static void ValidateDateRange(DateOnly? startDate, DateOnly? endDate)
{
if (startDate.HasValue && endDate.HasValue && endDate.Value < startDate.Value)
throw new ValidationException("结束日期不能早于开始日期");
}
// 同一时间点去重并排序;空集合交由调用方/实体默认处理
private static List<TimeOnly> NormalizeTimes(IReadOnlyList<TimeOnly> times) =>
times.Distinct().OrderBy(t => t).ToList();
public async Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct)
{
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
if (med == null) return false;
_medications.DeleteMedication(med);
await _medications.SaveChangesAsync(ct);
return true;
}
public async Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct)
{
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct);
if (existing != null)
{
_medications.DeleteLog(existing);
await _medications.SaveChangesAsync(ct);
return false;
}
await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct);
return true;
}
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
{
var beijingNow = DateTime.UtcNow.AddHours(8);
var now = TimeOnly.FromDateTime(beijingNow);
var today = DateOnly.FromDateTime(beijingNow);
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var meds = await _medications.ListActiveForRemindersAsync(userId, today, ct);
var logs = await _medications.ListLogsInWindowAsync(userId, todayStartUtc, todayEndUtc, ct);
var reminders = new List<MedicationReminderDto>();
foreach (var med in meds)
{
if (!GetDosesForToday(med, today)) continue;
foreach (var scheduledTime in med.TimeOfDay)
{
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
string status;
if (log != null)
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
else if (scheduledTime <= now.AddMinutes(-30))
status = "overdue";
else if (scheduledTime <= now.AddHours(3))
status = "upcoming";
else
continue;
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
}
}
return reminders
.OrderBy(r => r.Status == "overdue" ? 0 : r.Status == "upcoming" ? 1 : 2)
.ToList();
}
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
{
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
if (existing) return false;
await AddLogAsync(userId, medicationId, status, scheduledTime, ct);
return true;
}
public async Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct)
{
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var log = await _medications.GetDoseLogAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
if (log == null) return false;
_medications.DeleteLog(log);
await _medications.SaveChangesAsync(ct);
return true;
}
public Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) =>
ListAsync(userId, "active", ct);
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct)
{
var result = await ConfirmDoseAsync(
userId,
medicationId,
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
MedicationLogStatus.Taken,
ct);
return result == true;
}
private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct)
{
await _medications.AddLogAsync(new MedicationLog
{
Id = Guid.NewGuid(),
MedicationId = medicationId,
UserId = userId,
Status = status,
ScheduledTime = scheduledTime,
ConfirmedAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
}, ct);
await _medications.SaveChangesAsync(ct);
}
private static MedicationDto ToDto(Medication med, bool todayTaken) => new(
med.Id,
med.Name,
med.Dosage,
med.Frequency.ToString(),
med.TimeOfDay,
med.StartDate,
med.EndDate,
med.IsActive,
med.Notes,
med.Source.ToString(),
med.CreatedAt,
med.UpdatedAt,
todayTaken);
private static bool HasTakenToday(Medication med, DateTime todayStartUtc, DateTime todayEndUtc) =>
med.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken);
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
{
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
var todayStartUtc = beijingToday.AddHours(-8);
return (todayStartUtc, todayStartUtc.AddDays(1));
}
private static bool GetDosesForToday(Medication med, DateOnly today)
{
if (med.Frequency is MedicationFrequency.Daily
or MedicationFrequency.TwiceDaily
or MedicationFrequency.ThreeTimesDaily
or MedicationFrequency.AsNeeded)
return true;
var startDate = med.StartDate ?? today;
if (med.Frequency == MedicationFrequency.EveryOtherDay)
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
if (med.Frequency == MedicationFrequency.Weekly)
return today.DayOfWeek == startDate.DayOfWeek;
return true;
}
}

View File

@@ -0,0 +1,60 @@
namespace Health.Application.Notifications;
public sealed record InAppNotificationDto(
Guid Id,
string Type,
string Title,
string Message,
string Severity,
string? ActionType,
string? ActionTargetId,
bool IsRead,
DateTime CreatedAt);
public sealed record NotificationMessage(
string Type,
string Title,
string Message,
string Severity,
string? ActionType,
string? ActionTargetId);
public interface IInAppNotificationService
{
Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<InAppNotificationDto>> GetHistoryAsync(Guid userId, CancellationToken ct);
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct);
}
public interface IInAppNotificationRepository
{
Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<InAppNotificationRecord>> GetHistoryAsync(Guid userId, CancellationToken ct);
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct);
}
public sealed record InAppNotificationRecord(
Guid Id,
string Type,
string Title,
string Message,
string Severity,
string? ActionType,
string? ActionTargetId,
bool IsRead,
DateTime CreatedAt);
public interface IUserNotificationProducer
{
Task<bool> EnqueueAsync(Guid userId, Guid sourceId, NotificationMessage message, CancellationToken ct);
}
public interface INotificationOutboxProcessor
{
Task RecoverStaleAsync(CancellationToken ct);
Task<bool> ProcessNextAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,31 @@
namespace Health.Application.Notifications;
public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService
{
private readonly IInAppNotificationRepository _notifications = notifications;
public async Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct)
{
var records = await _notifications.GetPendingAsync(userId, ct);
return records.Select(ToDto).ToList();
}
public async Task<IReadOnlyList<InAppNotificationDto>> GetHistoryAsync(Guid userId, CancellationToken ct)
{
var records = await _notifications.GetHistoryAsync(userId, ct);
return records.Select(ToDto).ToList();
}
public Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
_notifications.AcknowledgeAsync(userId, notificationId, ct);
public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
_notifications.MarkAllReadAsync(userId, ct);
public Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
_notifications.DeleteAsync(userId, notificationId, ct);
private static InAppNotificationDto ToDto(InAppNotificationRecord record) => new(
record.Id, record.Type, record.Title, record.Message, record.Severity,
record.ActionType, record.ActionTargetId, record.IsRead, record.CreatedAt);
}

View File

@@ -0,0 +1,83 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Reports;
public sealed record ReportUploadFile(
string FileName,
long Length,
Stream Content);
public sealed record ReportDto(
Guid Id,
Guid UserId,
string FileUrl,
string FileType,
string Category,
string Status,
string AiStatus,
string ReviewStatus,
string? Severity,
string? AiSummary,
string? AiIndicators,
string? DoctorComment,
string? DoctorRecommendation,
string? DoctorName,
DateTime? ReviewedAt,
DateTime CreatedAt);
public sealed record ReportUploadResult(
bool Success,
int Code,
string? Message,
ReportDto? Report);
public sealed record ReportAnalysisJob(
Guid TaskId,
Guid ReportId,
string FilePath);
public sealed record StoredReportFile(
string FileUrl,
string FilePath);
public interface IReportService
{
Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct);
Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct);
Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct);
Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct);
Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct);
}
public interface IReportAnalysisQueue
{
Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default);
Task RecoverStaleAsync(CancellationToken ct);
Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct);
Task CompleteAsync(Guid taskId, CancellationToken ct);
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
}
public interface IReportAnalysisService
{
Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct);
}
public interface IReportRepository
{
Task<IReadOnlyList<Report>> ListAsync(Guid userId, CancellationToken ct);
Task<Report?> GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct);
Task<Report?> GetByIdAsync(Guid reportId, CancellationToken ct);
Task AddAsync(Report report, CancellationToken ct);
void Delete(Report report);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IReportFileStorage
{
Task<StoredReportFile> SaveAsync(ReportUploadFile file, string extension, CancellationToken ct);
string GetLocalFilePath(string fileUrl);
bool Exists(string filePath);
void Delete(string filePath);
}

View File

@@ -0,0 +1,122 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Reports;
public sealed class ReportService(
IReportRepository reports,
IReportFileStorage fileStorage,
IReportAnalysisQueue queue) : IReportService
{
private const long MaxReportFileBytes = 20 * 1024 * 1024;
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".webp", ".pdf"
};
private readonly IReportRepository _reports = reports;
private readonly IReportFileStorage _fileStorage = fileStorage;
private readonly IReportAnalysisQueue _queue = queue;
public async Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct)
{
var result = await _reports.ListAsync(userId, ct);
return result.Select(ToDto).ToList();
}
public async Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct)
{
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
return report == null ? null : ToDto(report);
}
public async Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct)
{
if (file.Length <= 0)
return Fail(400, "未上传文件");
if (file.Length > MaxReportFileBytes)
return Fail(400, "报告文件不能超过 20MB请压缩后重新上传");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return Fail(400, "目前支持上传 JPG、PNG、WEBP 图片或 PDF 报告");
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
var report = new Report
{
Id = Guid.NewGuid(),
UserId = userId,
FileUrl = storedFile.FileUrl,
FileType = ext == ".pdf" ? ReportFileType.Pdf : ReportFileType.Image,
Category = ReportCategory.Other,
Status = ReportStatus.Analyzing,
AiSummary = null,
AiIndicators = null,
CreatedAt = DateTime.UtcNow,
};
await _reports.AddAsync(report, ct);
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, storedFile.FilePath), ct);
return new ReportUploadResult(true, 0, "报告已上传AI 正在分析中", ToDto(report));
}
public async Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct)
{
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
if (report == null) return false;
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
if (_fileStorage.Exists(filePath))
_fileStorage.Delete(filePath);
_reports.Delete(report);
await _reports.SaveChangesAsync(ct);
return true;
}
public async Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct)
{
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
if (report == null) return false;
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
if (!_fileStorage.Exists(filePath)) return false;
report.Status = ReportStatus.Analyzing;
report.AiSummary = null;
report.AiIndicators = null;
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, filePath), ct);
return true;
}
public static ReportDto ToDto(Report report) => new(
report.Id,
report.UserId,
report.FileUrl,
report.FileType.ToString(),
report.Category.ToString(),
report.Status.ToString(),
ToAiStatus(report),
report.Status == ReportStatus.DoctorReviewed ? "Reviewed" : "Pending",
report.Severity,
report.AiSummary,
report.AiIndicators,
report.DoctorComment,
report.DoctorRecommendation,
report.DoctorName,
report.ReviewedAt,
report.CreatedAt);
private static string ToAiStatus(Report report) => report.Status switch
{
ReportStatus.Analyzing => "Analyzing",
ReportStatus.AnalysisFailed => "Failed",
_ when string.IsNullOrWhiteSpace(report.AiSummary) => "Analyzing",
_ => "Succeeded"
};
private static ReportUploadResult Fail(int code, string message) =>
new(false, code, message, null);
}

View File

@@ -0,0 +1,31 @@
using Health.Domain.Entities;
namespace Health.Application.Users;
public sealed record UserProfileDto(
Guid Id,
string? Phone, // Apple 用户无手机号
string Role,
string? Name,
string? Gender,
string? BirthDate,
string? AvatarUrl);
public sealed record UserProfileUpdateRequest(
string? Name,
string? Gender,
DateOnly? BirthDate);
public interface IUserService
{
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct);
Task DeleteAccountAsync(Guid userId, CancellationToken ct);
}
public interface IUserRepository
{
Task<User?> GetAsync(Guid userId, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
}

View File

@@ -0,0 +1,35 @@
namespace Health.Application.Users;
public sealed class UserService(IUserRepository users) : IUserService
{
private readonly IUserRepository _users = users;
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
{
var user = await _users.GetAsync(userId, ct);
return user == null ? null : new UserProfileDto(
user.Id,
user.Phone,
user.Role,
user.Name,
user.Gender,
user.BirthDate?.ToString("yyyy-MM-dd"),
user.AvatarUrl);
}
public async Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct)
{
var user = await _users.GetAsync(userId, ct);
if (user == null) return false;
if (request.Name != null) user.Name = request.Name;
if (request.Gender != null) user.Gender = request.Gender;
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
user.UpdatedAt = DateTime.UtcNow;
await _users.SaveChangesAsync(ct);
return true;
}
public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
_users.DeleteAccountDataAsync(userId, ct);
}

View File

@@ -44,6 +44,8 @@ public sealed class Doctor
public string Name { get; set; } = string.Empty;
public string? Title { get; set; } // 主任医师/副主任医师
public string? Department { get; set; } // 心血管内科/营养科
public string? Phone { get; set; } // 医生手机号
public string? ProfessionalDirection { get; set; } // 专业方向
public string? AvatarUrl { get; set; }
public string? Introduction { get; set; }
public bool IsActive { get; set; } = true;

View File

@@ -0,0 +1,23 @@
namespace Health.Domain.Entities;
/// <summary>
/// 医生扩展信息
/// </summary>
public sealed class DoctorProfile
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid? DoctorId { get; set; } // FK → Doctor 实体
public string? Name { get; set; }
public Doctor? Doctor { get; set; } // 导航属性
public string? Title { get; set; }
public string? Department { get; set; }
public string? Hospital { get; set; }
public string? AvatarUrl { get; set; }
public bool IsOnline { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
}

View File

@@ -7,7 +7,9 @@ public sealed class ExercisePlan
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public DateOnly WeekStartDate { get; set; } // 本周一
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public TimeOnly ReminderTime { get; set; } = new(19, 0);
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -22,7 +24,7 @@ public sealed class ExercisePlanItem
{
public Guid Id { get; set; }
public Guid PlanId { get; set; }
public int DayOfWeek { get; set; } // 0=周一, 6=周日
public DateOnly ScheduledDate { get; set; }
public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳
public int DurationMinutes { get; set; }
public bool IsCompleted { get; set; }

View File

@@ -38,6 +38,18 @@ public sealed class HealthArchive
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
public ICollection<HealthArchiveSurgery> Surgeries { get; set; } = [];
}
public sealed class HealthArchiveSurgery
{
public Guid Id { get; set; }
public Guid HealthArchiveId { get; set; }
public string Type { get; set; } = string.Empty;
public DateOnly? Date { get; set; }
public int SortOrder { get; set; }
public HealthArchive HealthArchive { get; set; } = null!;
}
/// <summary>
@@ -77,6 +89,13 @@ public sealed class NotificationPreference
public bool FollowUpReminder { get; set; } = true;
public bool DoctorReply { get; set; } = true;
public bool AbnormalAlert { get; set; } = true;
// 健康录入提醒——总开关 + 5 子项
public bool HealthRecordReminder { get; set; } = true;
public bool HealthRecordReminderBloodPressure { get; set; } = true;
public bool HealthRecordReminderHeartRate { get; set; } = true;
public bool HealthRecordReminderGlucose { get; set; } = true;
public bool HealthRecordReminderSpO2 { get; set; } = true;
public bool HealthRecordReminderWeight { get; set; } = true;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
@@ -97,3 +116,20 @@ public sealed class DeviceToken
public User User { get; set; } = null!;
}
public sealed class UserNotification
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid SourceId { get; set; }
public string Type { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Severity { get; set; } = "info";
public string? ActionType { get; set; }
public string? ActionTargetId { get; set; }
public bool IsRead { get; set; }
public DateTime? ReadAt { get; set; }
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -1,13 +1,17 @@
namespace Health.Domain.Entities;
/// <summary>
/// 用户(患者
/// 用户(支持用户/医生双角色
/// </summary>
public sealed class User
{
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 Guid? DoctorId { get; set; } // 患者选择的医生
public string? Name { get; set; }
public Doctor? Doctor { get; set; } // 导航属性
public string? Gender { get; set; }
public DateOnly? BirthDate { get; set; }
public string? AvatarUrl { get; set; }
@@ -26,4 +30,5 @@ public sealed class User
public ICollection<DeviceToken> DeviceTokens { get; set; } = [];
public HealthArchive? HealthArchive { get; set; }
public NotificationPreference? NotificationPreference { get; set; }
public DoctorProfile? DoctorProfile { get; set; }
}

View File

@@ -71,6 +71,8 @@ public enum MedicationFrequency
/// </summary>
public enum ReportStatus
{
Analyzing, // AI 分析中
AnalysisFailed, // AI 分析失败
PendingDoctor, // 待医生确认
DoctorReviewed // 医生已确认
}

View File

@@ -0,0 +1,7 @@
namespace Health.Domain;
/// <summary>
/// 业务输入校验失败异常——由 Application 层规则抛出,
/// 中间件统一映射为 400 / code 40001message 可安全展示给用户。
/// </summary>
public sealed class ValidationException(string message) : Exception(message);

View File

@@ -1,3 +1,6 @@
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
@@ -21,43 +24,38 @@ public static class CommonAgentHandler
public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(
string toolName,
JsonElement args,
Guid userId,
IHealthRecordService healthRecords,
IHealthArchiveService archives,
CancellationToken ct = default)
{
return toolName switch
{
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, args),
"check_archive" => await ExecuteCheckArchive(db, userId),
"query_health_records" => await ExecuteQueryHealthRecords(healthRecords, userId, args, ct),
"check_archive" => await ExecuteCheckArchive(archives, userId, ct),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args)
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
var query = db.HealthRecords.Where(r => r.UserId == userId);
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
query = query.Where(r => r.MetricType == mt);
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
{
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
}).ToListAsync();
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
return new { count = records.Count, records };
}
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId)
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
{
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId);
var archive = await archives.GetAsync(userId, ct);
if (archive == null) return new { found = false };
return new
{
found = true, archive.Diagnosis, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries,
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
};
}
@@ -85,49 +83,26 @@ public static class CommonAgentHandler
}
};
public static async Task<object> ExecuteManageArchive(AppDbContext db, Guid userId, JsonElement args)
public static Task<object> ExecuteManageArchive(
IHealthArchiveService archives,
Guid userId,
JsonElement args,
CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
var archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId);
if (archive == null)
{
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
db.HealthArchives.Add(archive);
var request = new HealthArchiveUpdateRequest(
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
ReadStringArray(args, "allergies"),
ReadStringArray(args, "diet_restrictions"),
ReadStringArray(args, "chronic_diseases"),
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
return archives.ExecuteAiActionAsync(userId, action, request, ct);
}
switch (action)
{
case "update_diagnosis":
archive.Diagnosis = args.TryGetProperty("diagnosis", out var d) ? d.GetString() : archive.Diagnosis;
break;
case "update_surgery":
archive.SurgeryType = args.TryGetProperty("surgery_type", out var st) ? st.GetString() : archive.SurgeryType;
if (args.TryGetProperty("surgery_date", out var sd))
archive.SurgeryDate = DateOnly.TryParse(sd.GetString(), out var date) ? date : archive.SurgeryDate;
break;
case "update_allergies":
if (args.TryGetProperty("allergies", out var al) && al.ValueKind == JsonValueKind.Array)
archive.Allergies = [.. al.EnumerateArray().Select(x => x.GetString()!)];
break;
case "update_chronic_diseases":
if (args.TryGetProperty("chronic_diseases", out var cd) && cd.ValueKind == JsonValueKind.Array)
archive.ChronicDiseases = [.. cd.EnumerateArray().Select(x => x.GetString()!)];
break;
case "update_diet_restrictions":
if (args.TryGetProperty("diet_restrictions", out var dr) && dr.ValueKind == JsonValueKind.Array)
archive.DietRestrictions = [.. dr.EnumerateArray().Select(x => x.GetString()!)];
break;
default: // query
return new
{
found = true, archive.Diagnosis, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
};
}
archive.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
}
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
: null;
}

View File

@@ -1,27 +1,10 @@
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// AI 问诊 Agent 工具处理器——转医生
/// 问诊 Agent 工具处理器(问诊转医生走专门界面,此处只保留档案和记录查询)
/// </summary>
public static class ConsultationAgentHandler
{
public static readonly ToolDefinition RequestDoctorTool = new()
{
Function = new()
{
Name = "request_doctor", Description = "请求转接真人医生",
Parameters = new { type = "object", properties = new { reason = new { type = "string" }, urgency_level = new { type = "string" } } }
}
};
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool];
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool, RequestDoctorTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" or "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
}

View File

@@ -1,23 +1,10 @@
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 饮食 Agent 工具处理器——食物估算
/// 饮食 Agent 工具处理器(饮食拍照走专门界面,此处只保留档案查询)
/// </summary>
public static class DietAgentHandler
{
public static readonly ToolDefinition EstimateFoodTool = new()
{
Function = new() { Name = "estimate_food_text", Description = "根据文字描述估算食物份量和热量", Parameters = new { type = "object", properties = new { text = new { type = "string" } }, required = new[] { "text" } } }
};
public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool];
public static List<ToolDefinition> Tools => [EstimateFoodTool, CommonAgentHandler.CheckArchiveTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
}

View File

@@ -1,85 +1,83 @@
using Health.Application.Exercises;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 运动计划 Agent 工具处理器
/// </summary>
public static class ExerciseAgentHandler
{
public static readonly ToolDefinition ManageExerciseTool = new()
{
Function = new()
{
Name = "manage_exercise", Description = "运动计划管理(创建/查询/打卡)",
Parameters = new { type = "object", properties = new {
Name = "manage_exercise",
Description = "运动计划管理(创建/查询/打卡)",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "create/query/checkin" },
week_start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
items = new { type = "array", description = "每天的运动项目", items = new { type = "object", properties = new {
day_of_week = new { type = "integer", description = "0=周日 1=周一...6=周六" },
exercise_type = new { type = "string", description = "运动类型,如散步/慢跑/游泳/太极" },
duration_minutes = new { type = "integer", description = "时长(分钟)" },
is_rest_day = new { type = "boolean", description = "是否休息日" }
}}}
}, required = new[] { "action" } }
}
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd,默认今天" },
duration_days = new { type = "integer", description = "连续运动天数如一周为7天" },
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
duration_minutes = new { type = "integer", description = "每天运动时长,单位分钟" },
reminder_time = new { type = "string", description = "每天提醒时间 HH:mm默认19:00" },
item_id = new { type = "string", description = "打卡条目 ID" },
},
required = new[] { "action" },
},
},
};
public static List<ToolDefinition> Tools => [ManageExerciseTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(
string toolName,
JsonElement args,
AppDbContext db,
Guid userId,
IExerciseService? exercises = null,
CancellationToken ct = default)
{
return toolName switch
if (toolName != "manage_exercise" || exercises == null)
return new { success = false, message = $"未知工具: {toolName}" };
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
if (action == "create")
{
"manage_exercise" => await ExecuteManageExercise(db, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
? parsedStart
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var days = args.TryGetProperty("duration_days", out var daysValue) && daysValue.TryGetInt32(out var parsedDays)
? Math.Clamp(parsedDays, 1, 366)
: 7;
var exerciseType = args.TryGetProperty("exercise_type", out var typeValue) ? typeValue.GetString() : "散步";
var minutes = args.TryGetProperty("duration_minutes", out var minutesValue) && minutesValue.TryGetInt32(out var parsedMinutes) ? parsedMinutes : 30;
var reminder = args.TryGetProperty("reminder_time", out var reminderValue) && TimeOnly.TryParse(reminderValue.GetString(), out var parsedReminder)
? parsedReminder
: new TimeOnly(19, 0);
var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(
startDate, startDate.AddDays(days - 1), exerciseType, minutes, reminder), ct);
return new { success = true, plan_id = planId, exercise_type = exerciseType, duration_minutes = minutes, day_count = days, start_date = startDate, end_date = startDate.AddDays(days - 1), reminder_time = reminder };
}
if (action == "checkin")
{
var itemId = args.TryGetProperty("item_id", out var itemValue) && itemValue.TryGetGuid(out var parsedId) ? parsedId : Guid.Empty;
var success = await exercises.CheckInAsync(userId, itemId, ct);
return success ? new { success = true } : new { success = false, message = "条目不存在" };
}
var plan = await exercises.GetLatestAsync(userId, ct);
return plan == null
? new { found = false }
: new
{
found = true,
plan_id = plan.Id,
plan.StartDate,
plan.EndDate,
plan.ReminderTime,
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
};
}
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
switch (action)
{
case "create":
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.Now);
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
if (args.TryGetProperty("items", out var items))
{
foreach (var item in items.EnumerateArray())
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
});
}
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync();
var firstItem = plan.Items.FirstOrDefault();
return new {
success = true, plan_id = plan.Id,
exercise_type = firstItem?.ExerciseType ?? "运动",
duration_minutes = firstItem?.DurationMinutes ?? 30,
day_count = plan.Items.Count,
};
case "checkin":
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
exerciseItem.IsCompleted = true;
exerciseItem.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
default:
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
if (existingPlan == null) return new { found = false };
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
}
}
}

View File

@@ -1,3 +1,5 @@
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
@@ -16,70 +18,83 @@ public static class HealthDataAgentHandler
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
{
return toolName switch
{
"record_health_data" => await ExecuteRecordHealthData(db, userId, args),
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId),
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var record = new HealthRecord
{
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
};
switch (type)
{
case "blood_pressure":
record.MetricType = HealthMetricType.BloodPressure;
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
record.Unit = "mmHg";
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
break;
case "heart_rate":
record.MetricType = HealthMetricType.HeartRate;
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
record.Unit = "次/分";
record.IsAbnormal = record.Value > 100 || record.Value < 60;
break;
case "glucose":
record.MetricType = HealthMetricType.Glucose;
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
record.Unit = "mmol/L";
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
break;
case "spo2":
record.MetricType = HealthMetricType.SpO2;
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
record.Unit = "%";
record.IsAbnormal = record.Value <= 94;
break;
case "weight":
record.MetricType = HealthMetricType.Weight;
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
record.Unit = "kg";
record.IsAbnormal = false;
break;
default:
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null)
return new { success = false, message = $"未知指标类型: {type}" };
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt)
? dt
: DateTime.UtcNow;
var request = new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
recordedAt);
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
return new
{
success = true,
record_id = id,
type = metricType.Value.ToString(),
value = valStr,
unit,
isAbnormal = HealthRecordRules.CheckAbnormal(request)
};
}
db.HealthRecords.Add(record);
await db.SaveChangesAsync();
var valStr = record.MetricType switch
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
{
HealthMetricType.BloodPressure => $"{record.Systolic}/{record.Diastolic}",
_ => record.Value?.ToString() ?? ""
return type switch
{
"blood_pressure" => (
HealthMetricType.BloodPressure,
null,
"mmHg",
args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null,
args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null),
"heart_rate" => (
HealthMetricType.HeartRate,
args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null,
"次/分",
null,
null),
"glucose" => (
HealthMetricType.Glucose,
args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null,
"mmol/L",
null,
null),
"spo2" => (
HealthMetricType.SpO2,
args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null,
"%",
null,
null),
"weight" => (
HealthMetricType.Weight,
args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null,
"kg",
null,
null),
_ => (null, null, "", null, null)
};
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
}
}

View File

@@ -1,7 +1,9 @@
using Health.Application.Medications;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 药管家 Agent 工具处理器——用药管理
/// 药管家 Agent 工具处理器
/// </summary>
public static class MedicationAgentHandler
{
@@ -9,100 +11,128 @@ public static class MedicationAgentHandler
{
Function = new()
{
Name = "manage_medication", Description = "用药管理(创建/查询/确认服药)",
Parameters = new { type = "object", properties = new {
Name = "manage_medication",
Description = "用药管理(创建/查询/确认服药)",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "create/query/confirm" },
medication_id = new { type = "string", description = "药品 ID确认服药时使用" },
name = new { type = "string", description = "药品名称" },
dosage = new { type = "string", description = "剂量,如 100mg" },
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
duration_days = new { type = "integer", description = "服用天数" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
}, required = new[] { "action" } }
},
required = new[] { "action" }
}
}
};
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(
string toolName,
JsonElement args,
Guid userId,
IMedicationService medications,
CancellationToken ct = default)
{
return toolName switch
{
"manage_medication" => await ExecuteManageMedication(db, userId, args),
"check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId),
_ => new { success = false, message = $"未知工具: {toolName}" }
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
_ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
};
}
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
return action switch
{
"create" => await CreateMedication(db, userId, args),
"query" => await QueryMedications(db, userId),
"confirm" => await ConfirmMedication(db, userId, args),
"create" => await CreateMedication(medications, userId, args, ct),
"query" => await QueryMedications(medications, userId, ct),
"confirm" => await ConfirmMedication(medications, userId, args, ct),
_ => new { success = false, message = $"未知操作: {action}" }
};
}
private static async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args)
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
var frequency = Enum.TryParse<MedicationFrequency>(frequencyStr, out var fr) ? fr : MedicationFrequency.Daily;
List<TimeOnly> times = [];
if (args.TryGetProperty("time_of_day", out var tod) && tod.ValueKind == JsonValueKind.Array)
{
foreach (var t in tod.EnumerateArray())
{
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
}
}
var frequency = ReadFrequency(args);
var times = ReadTimes(args);
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
var startDate = ReadStartDate(args);
// 结束日为包含式IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
var endDate = durationDays > 0 ? startDate.AddDays(durationDays - 1) : (DateOnly?)null;
var med = new Medication
{
Id = Guid.NewGuid(), UserId = userId,
Name = name, Dosage = dosage, Frequency = frequency,
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
Source = MedicationSource.AiEntry, IsActive = true,
StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.Now),
EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.Now).AddDays(durationDays) : null,
};
db.Medications.Add(med);
await db.SaveChangesAsync();
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
name,
dosage,
frequency,
times.Count > 0 ? times : [new TimeOnly(8, 0)],
startDate,
endDate,
MedicationSource.AiEntry,
null), ct);
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
return new {
success = true, medication_id = med.Id,
name = med.Name, dosage = med.Dosage,
frequency = med.Frequency.ToString(), time = timeLabels,
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
return new
{
success = true,
medication_id = medicationId,
name,
dosage,
frequency = frequency.ToString(),
time = timeLabels,
start_date = startDate.ToString("yyyy-MM-dd"),
duration_days = durationDays,
};
}
private static async Task<object> QueryMedications(AppDbContext db, Guid userId)
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
{
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
return new { count = meds.Count, medications = meds };
var meds = await medications.ListActiveAsync(userId, ct);
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) };
}
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
db.MedicationLogs.Add(new MedicationLog
var success = await medications.ConfirmNowAsync(userId, medId, ct);
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
}
private static MedicationFrequency ReadFrequency(JsonElement args)
{
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return new { success = true };
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
}
private static DateOnly ReadStartDate(JsonElement args)
{
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
return DateOnly.TryParse(startDateStr, out var parsedDate)
? parsedDate
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
}
private static List<TimeOnly> ReadTimes(JsonElement args)
{
List<TimeOnly> times = [];
if (!args.TryGetProperty("time_of_day", out var tod) || tod.ValueKind != JsonValueKind.Array)
return times;
foreach (var t in tod.EnumerateArray())
{
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
}
return times;
}
}

View File

@@ -12,15 +12,6 @@ public static class ReportAgentHandler
public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient)
{
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
@@ -28,13 +19,13 @@ public static class ReportAgentHandler
return new { success = false, message = "缺少报告图片" };
var prompt = """
JSON格式返回
JSON格式返回
{
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
"indicators": [
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
],
"summary": "初步分析摘要",
"summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生",
"needsDoctorReview": true/false
}
JSON

View File

@@ -0,0 +1,78 @@
using System.Text.Json;
using Health.Application.AI;
using Health.Application.Exercises;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
using Health.Infrastructure.AI.AgentHandlers;
namespace Health.Infrastructure.AI;
public sealed class AiToolExecutionService(
AppDbContext db,
VisionClient visionClient,
IHealthArchiveService healthArchives,
IHealthRecordService healthRecords,
IExerciseService exercises,
IMedicationService medications,
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
{
private readonly AppDbContext _db = db;
private readonly VisionClient _visionClient = visionClient;
private readonly IHealthArchiveService _healthArchives = healthArchives;
private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IExerciseService _exercises = exercises;
private readonly IMedicationService _medications = medications;
private readonly IAiWriteConfirmationStore _confirmations = confirmations;
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
{
using var json = JsonDocument.Parse(arguments);
var root = json.RootElement;
return await (toolName switch
{
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
});
}
public async Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var command = await _confirmations.TakeAsync(commandId, userId, ct);
if (command == null)
return new AiConfirmedWriteResult(40004, null, "确认请求不存在、已执行或已过期");
try
{
var result = await ExecuteAsync(command.ToolName, command.Arguments, userId, ct);
if (!Succeeded(result, out var error))
{
await transaction.RollbackAsync(ct);
return new AiConfirmedWriteResult(40001, result, error ?? "写入失败,请检查内容后重试");
}
await _confirmations.CompleteAsync(command, ct);
await transaction.CommitAsync(ct);
return new AiConfirmedWriteResult(0, result, null);
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
return new AiConfirmedWriteResult(50001, null, $"写入失败: {ex.Message}");
}
}
private static bool Succeeded(object result, out string? error)
{
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
var root = json.RootElement;
error = root.TryGetProperty("message", out var message) ? message.GetString() : null;
return !root.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.False;
}
}

View File

@@ -0,0 +1,179 @@
using System.Text.Json;
using Health.Application.AI;
using Microsoft.Extensions.Logging;
using UglyToad.PdfPig;
namespace Health.Infrastructure.AI;
public sealed class AttachmentContextBuilder(
VisionClient vision,
ILogger<AttachmentContextBuilder> logger) : IAttachmentContextBuilder
{
private const int MaxPdfChars = 6000;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
private readonly VisionClient _vision = vision;
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(imageUrl))
return await BuildImageAsync(imageUrl!, ct);
if (!string.IsNullOrWhiteSpace(pdfUrl))
return await BuildPdfAsync(pdfUrl!, ct);
return null;
}
// ── 图片:调 VLM 输出结构化 JSON ──
private async Task<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct)
{
var filePath = ResolveLocalPath(imageUrl);
if (filePath == null || !File.Exists(filePath))
{
_logger.LogWarning("Image file not found for {Url}", imageUrl);
return new AttachmentContext("image", null, null, "图片暂时无法读取", "[图片附件读取失败,请描述图片内容]");
}
var bytes = await File.ReadAllBytesAsync(filePath, ct);
var mime = Path.GetExtension(filePath).ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
".heic" => "image/heic",
_ => "image/jpeg",
};
var dataUrl = $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
var prompt = """
JSON
{"category":"food|report|wound|drug|chart|other","summary":"1-2句话描述图片","details":["关键信息1","关键信息2"]}
- food
- reportX光/CT/MRI/B超截图等
- wound
- drug
- chart
- other
details
JSON
""";
string? raw = null;
try
{
var resp = await _vision.VisionAsync(prompt, [dataUrl], userText: null, maxTokens: 1024, ct: ct);
raw = resp.Choices?.FirstOrDefault()?.Message?.Content;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "VLM call failed for image {Url}", imageUrl);
}
if (string.IsNullOrWhiteSpace(raw))
return new AttachmentContext("image", null, null, "图片识别失败", "[图片附件识别失败,请简要描述图片内容]");
try
{
var cleaned = StripCodeFence(raw.Trim());
using var doc = JsonDocument.Parse(cleaned);
var root = doc.RootElement;
var category = GetString(root, "category") ?? "other";
var summary = GetString(root, "summary") ?? "图片内容";
var details = root.TryGetProperty("details", out var d) && d.ValueKind == JsonValueKind.Array
? string.Join("、", d.EnumerateArray().Select(x => x.GetString()).Where(s => !string.IsNullOrWhiteSpace(s)))
: "";
var compact = string.IsNullOrEmpty(details)
? $"图片识别:{summary}"
: $"图片识别:{summary}{details}";
var llmContent = $"[图片识别(类别 {category}{summary}{(string.IsNullOrEmpty(details) ? "" : $" {details}")}]";
return new AttachmentContext("image", category, null, compact, llmContent);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse VLM JSON: {Raw}", raw);
// 兜底:原文也能用
return new AttachmentContext("image", null, null, $"图片识别:{raw[..Math.Min(raw.Length, 80)]}", $"[图片识别:{raw}]");
}
}
// ── PDFPdfPig 抽取文本 ──
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct)
{
var filePath = ResolveLocalPath(pdfUrl);
var fileName = Path.GetFileName(pdfUrl);
if (filePath == null || !File.Exists(filePath))
{
_logger.LogWarning("PDF file not found for {Url}", pdfUrl);
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
"pdf", null, fileName, "PDF 文件读取失败",
"[PDF 附件读取失败,请描述文档内容]"));
}
try
{
using var pdf = PdfDocument.Open(filePath);
var sb = new System.Text.StringBuilder();
foreach (var page in pdf.GetPages())
{
sb.AppendLine(page.Text);
if (sb.Length > MaxPdfChars) break;
}
var text = sb.ToString().Trim();
if (string.IsNullOrWhiteSpace(text))
{
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
"pdf", null, fileName, "PDF 内容为空或扫描件",
"[PDF 看起来是扫描件或图片版,无法提取文字。请提示用户改用拍照上传,或简要描述报告内容]"));
}
var truncated = text.Length > MaxPdfChars ? text[..MaxPdfChars] + "...(已截断)" : text;
var summary = $"PDF{fileName}";
var llmContent = $"[用户上传 PDF 「{fileName}」,文档内容如下]\n{truncated}";
return Task.FromResult<AttachmentContext?>(new AttachmentContext("pdf", null, fileName, summary, llmContent));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse PDF {Url}", pdfUrl);
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
"pdf", null, fileName, "PDF 解析异常",
"[PDF 解析失败,请提示用户文件可能损坏或加密]"));
}
}
private static string? ResolveLocalPath(string url)
{
// url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。
var idx = url.IndexOf("/uploads/", StringComparison.Ordinal);
if (idx < 0) return null;
var relative = url[(idx + "/uploads/".Length)..];
// 去掉可能的 query string
var q = relative.IndexOf('?');
if (q >= 0) relative = relative[..q];
return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative);
}
private static string StripCodeFence(string raw)
{
var t = raw.Trim();
if (t.StartsWith("```"))
{
var firstNewline = t.IndexOf('\n');
if (firstNewline > 0) t = t[(firstNewline + 1)..];
if (t.EndsWith("```")) t = t[..^3];
}
return t.Trim();
}
private static string? GetString(JsonElement el, string name) =>
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}

View File

@@ -0,0 +1,45 @@
using Health.Application.AI;
namespace Health.Infrastructure.AI;
public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversationRepository
{
private readonly AppDbContext _db = db;
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) =>
_db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId, ct);
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) =>
_db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId, ct);
public async Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) =>
await _db.Conversations
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.UpdatedAt)
.ToListAsync(ct);
public async Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) =>
await _db.ConversationMessages
.Where(m => m.ConversationId == conversationId)
.OrderBy(m => m.CreatedAt)
.ToListAsync(ct);
public async Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) =>
await _db.ConversationMessages
.Where(m => m.ConversationId == conversationId)
.OrderByDescending(m => m.CreatedAt)
.Take(limit)
.ToListAsync(ct);
public async Task AddConversationAsync(Conversation conversation, CancellationToken ct) =>
await _db.Conversations.AddAsync(conversation, ct);
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
await _db.ConversationMessages.AddAsync(message, ct);
public void Delete(Conversation conversation) =>
_db.Conversations.Remove(conversation);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,98 @@
using Health.Application.AI;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.AI;
public sealed class EfAiWriteConfirmationStore(AppDbContext db) : IAiWriteConfirmationStore
{
private readonly AppDbContext _db = db;
public async Task<PendingAiWriteCommand> CreateAsync(
Guid userId,
string toolName,
string arguments,
TimeSpan lifetime,
CancellationToken ct)
{
var now = DateTime.UtcNow;
if (IsInMemory())
{
var expired = await _db.AiWriteCommands.Where(x => x.Status == "Pending" && x.ExpiresAt <= now).ToListAsync(ct);
foreach (var item in expired) { item.Status = "Expired"; item.UpdatedAt = now; }
}
else
{
await _db.AiWriteCommands
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Expired")
.SetProperty(x => x.UpdatedAt, now), ct);
}
var command = new AiWriteCommandRecord
{
Id = Guid.NewGuid(),
UserId = userId,
ToolName = toolName,
Arguments = arguments,
Status = "Pending",
ExpiresAt = now.Add(lifetime),
CreatedAt = now,
UpdatedAt = now,
};
await _db.AiWriteCommands.AddAsync(command, ct);
await _db.SaveChangesAsync(ct);
return ToCommand(command);
}
public async Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct)
{
var now = DateTime.UtcNow;
if (IsInMemory())
{
var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync(
x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now, ct);
if (tracked == null) return null;
tracked.Status = "Processing";
tracked.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
}
else
{
var updated = await _db.AiWriteCommands
.Where(x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.UpdatedAt, now), ct);
if (updated == 0) return null;
}
var record = await _db.AiWriteCommands.AsNoTracking().FirstAsync(x => x.Id == commandId, ct);
return ToCommand(record);
}
public async Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct)
{
if (IsInMemory())
{
var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync(
x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing", ct);
if (tracked != null) { tracked.Status = "Completed"; tracked.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); }
return;
}
await _db.AiWriteCommands
.Where(x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
private bool IsInMemory() => _db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory";
private static PendingAiWriteCommand ToCommand(AiWriteCommandRecord record) => new(
record.Id,
record.UserId,
record.ToolName,
record.Arguments,
record.ExpiresAt);
}

View File

@@ -40,7 +40,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await AiHttpResponseGuard.ThrowIfFailedAsync(response, "DeepSeek", ct);
using var stream = await response.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
@@ -76,11 +76,9 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.PostAsync("chat/completions", content, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
using var response = await _http.PostAsync("chat/completions", content, ct);
var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "DeepSeek", ct);
return AiHttpResponseGuard.DeserializeRequired<ChatCompletionResponse>(body, _jsonOptions, "DeepSeek");
}
}
@@ -121,17 +119,50 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
{
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
Temperature = 0.1f, VlHighResolutionImages = true,
EnableThinking = false,
};
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.PostAsync("chat/completions", content, ct);
if (!response.IsSuccessStatusCode)
using var response = await _http.PostAsync("chat/completions", content, ct);
var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "VLM", ct);
return AiHttpResponseGuard.DeserializeRequired<ChatCompletionResponse>(body, _jsonOptions, "VLM");
}
}
internal static class AiHttpResponseGuard
{
var errorBody = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"VLM API {response.StatusCode}: {errorBody}");
}
private const int MaxErrorBodyLength = 2000;
public static async Task ThrowIfFailedAsync(HttpResponseMessage response, string provider, CancellationToken ct)
{
if (response.IsSuccessStatusCode) return;
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
throw new HttpRequestException($"{provider} API {(int)response.StatusCode} ({response.StatusCode}): {Truncate(body)}");
}
public static async Task<string> ReadSuccessBodyAsync(HttpResponseMessage response, string provider, CancellationToken ct)
{
await ThrowIfFailedAsync(response, provider, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (string.IsNullOrWhiteSpace(body))
throw new InvalidDataException($"{provider} API返回了空响应");
return body;
}
public static T DeserializeRequired<T>(string body, JsonSerializerOptions options, string provider)
{
try
{
return JsonSerializer.Deserialize<T>(body, options)
?? throw new InvalidDataException($"{provider} API响应内容为空");
}
catch (JsonException ex)
{
throw new InvalidDataException($"{provider} API响应格式无效", ex);
}
}
private static string Truncate(string value) =>
value.Length <= MaxErrorBodyLength ? value : value[..MaxErrorBodyLength] + "...";
}

View File

@@ -0,0 +1,84 @@
namespace Health.Infrastructure.AI;
public sealed class ChatCompletionRequest
{
public string Model { get; set; } = string.Empty;
public List<ChatMessage> Messages { get; set; } = [];
public bool Stream { get; set; }
public int MaxTokens { get; set; } = 2048;
public float Temperature { get; set; } = 0.7f;
public float? TopP { get; set; }
public List<ToolDefinition>? Tools { get; set; }
public string? ToolChoice { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
public bool VlHighResolutionImages { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("enable_thinking")]
public bool? EnableThinking { get; set; }
}
public sealed class ChatMessage
{
public string Role { get; set; } = string.Empty;
public object? Content { get; set; } = string.Empty;
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public string? ToolCallId { get; set; }
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public List<ToolCall>? ToolCalls { get; set; }
}
public sealed class ToolDefinition
{
public string Type { get; set; } = "function";
public ToolFunction Function { get; set; } = new();
}
public sealed class ToolFunction
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public object Parameters { get; set; } = new();
}
public sealed class ToolCall
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = "function";
public ToolCallFunction Function { get; set; } = new();
}
public sealed class ToolCallFunction
{
public string Name { get; set; } = string.Empty;
public string Arguments { get; set; } = string.Empty;
}
public sealed class ChatCompletionResponse
{
public string Id { get; set; } = string.Empty;
public List<Choice> Choices { get; set; } = [];
}
public sealed class Choice
{
public int Index { get; set; }
public ResponseMessage? Message { get; set; }
public ResponseDelta? Delta { get; set; }
public string? FinishReason { get; set; }
}
public sealed class ResponseMessage
{
public string Role { get; set; } = string.Empty;
public string? Content { get; set; }
public List<ToolCall>? ToolCalls { get; set; }
}
public sealed class ResponseDelta
{
public string? Content { get; set; }
public string? Role { get; set; }
}

View File

@@ -0,0 +1,71 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.AI;
/// <summary>
/// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。
/// 我们后端的 DeepSeek 依旧负责对话生成与工具调用;
/// 这里只把检索到的医学知识片段拼回 system prompt提升回答准确度。
/// </summary>
public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger<FastGptKnowledgeClient> logger)
{
private readonly HttpClient _http = http;
private readonly string _datasetId = config["FASTGPT_DATASET_ID"] ?? "";
private readonly float _similarity = float.TryParse(config["FASTGPT_SIMILARITY"], out var s) ? s : 0.4f;
private readonly int _limit = int.TryParse(config["FASTGPT_LIMIT"], out var l) ? l : 3000;
private readonly ILogger<FastGptKnowledgeClient> _logger = logger;
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId);
/// <summary>
/// 按用户问题检索知识库,返回最相关的文档片段。
/// 失败或未配置时返回空字符串调用方应当容错——RAG 只是增强,不是必需。
/// </summary>
public async Task<string> SearchAsync(string question, CancellationToken ct = default)
{
if (!IsEnabled || string.IsNullOrWhiteSpace(question)) return "";
try
{
var payload = new
{
datasetId = _datasetId,
text = question,
limit = _limit,
similarity = _similarity,
searchMode = "embedding",
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _http.PostAsync("core/dataset/searchTest", content, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("FastGPT 检索失败: {Status}", response.StatusCode);
return "";
}
var body = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("data", out var data)) return "";
if (!data.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) return "";
var snippets = new List<string>();
foreach (var item in list.EnumerateArray())
{
var q = item.TryGetProperty("q", out var qEl) ? qEl.GetString() ?? "" : "";
var a = item.TryGetProperty("a", out var aEl) ? aEl.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(q) && string.IsNullOrWhiteSpace(a)) continue;
snippets.Add(string.IsNullOrWhiteSpace(a) ? q : $"{q}{a}");
}
return snippets.Count == 0 ? "" : string.Join("\n", snippets);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "FastGPT 检索异常,已忽略");
return "";
}
}
}

View File

@@ -1,237 +0,0 @@
using System.Net.Http.Headers;
namespace Health.Infrastructure.AI;
/// <summary>
/// OpenAI 兼容协议 HTTP 客户端,统一调用 DeepSeek / 千问 VL
/// </summary>
public sealed class OpenAiCompatibleClient(string baseUrl, string apiKey, string model)
{
private readonly HttpClient _http = CreateHttpClient(baseUrl, apiKey);
private readonly string _model = model;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
private static HttpClient CreateHttpClient(string baseUrl, string apiKey)
{
var client = new HttpClient
{
BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(60)
};
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return client;
}
/// <summary>
/// 流式 Chat CompletionsSSE
/// </summary>
public async IAsyncEnumerable<string> ChatStreamAsync(
List<ChatMessage> messages,
List<ToolDefinition>? tools = null,
int maxTokens = 2048,
float temperature = 0.7f)
{
var request = new ChatCompletionRequest
{
Model = _model,
Messages = messages,
Stream = true,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
};
if (tools?.Count > 0)
request.ToolChoice = "auto";
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
{
Content = content
};
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
if (!line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
if (data == "[DONE]") break;
yield return data;
}
}
/// <summary>
/// 非流式 Chat Completions
/// </summary>
public async Task<ChatCompletionResponse> ChatAsync(
List<ChatMessage> messages,
List<ToolDefinition>? tools = null,
int maxTokens = 2048,
float temperature = 0.7f)
{
var request = new ChatCompletionRequest
{
Model = _model,
Messages = messages,
Stream = false,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
};
if (tools?.Count > 0)
request.ToolChoice = "auto";
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.PostAsync("chat/completions", content);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
}
/// <summary>
/// Vision 图片理解(非流式)
/// </summary>
public async Task<ChatCompletionResponse> VisionAsync(
string systemPrompt,
List<string> imageUrls,
string? userText = null,
int maxTokens = 2048)
{
var messages = new List<ChatMessage>();
if (!string.IsNullOrEmpty(systemPrompt))
messages.Add(new ChatMessage { Role = "system", Content = systemPrompt });
// 构建多模态消息内容
var contentParts = new List<object>();
foreach (var url in imageUrls)
contentParts.Add(new { type = "image_url", image_url = new { url } });
if (!string.IsNullOrEmpty(userText))
contentParts.Add(new { type = "text", text = userText });
var userMessage = new ChatMessage
{
Role = "user",
Content = JsonSerializer.Serialize(contentParts, _jsonOptions)
};
messages.Add(userMessage);
var request = new ChatCompletionRequest
{
Model = _model,
Messages = messages,
MaxTokens = maxTokens,
Stream = false,
};
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.PostAsync("chat/completions", content);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
}
}
#region /
public sealed class ChatCompletionRequest
{
public string Model { get; set; } = string.Empty;
public List<ChatMessage> Messages { get; set; } = [];
public bool Stream { get; set; }
public int MaxTokens { get; set; } = 2048;
public float Temperature { get; set; } = 0.7f;
public float? TopP { get; set; }
public List<ToolDefinition>? Tools { get; set; }
public string? ToolChoice { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
public bool VlHighResolutionImages { get; set; }
}
public sealed class ChatMessage
{
public string Role { get; set; } = string.Empty;
public object? Content { get; set; } = string.Empty;
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public string? ToolCallId { get; set; }
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public List<ToolCall>? ToolCalls { get; set; }
}
public sealed class ToolDefinition
{
public string Type { get; set; } = "function";
public ToolFunction Function { get; set; } = new();
}
public sealed class ToolFunction
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public object Parameters { get; set; } = new();
}
public sealed class ToolCall
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = "function";
public ToolCallFunction Function { get; set; } = new();
}
public sealed class ToolCallFunction
{
public string Name { get; set; } = string.Empty;
public string Arguments { get; set; } = string.Empty;
}
public sealed class ChatCompletionResponse
{
public string Id { get; set; } = string.Empty;
public List<Choice> Choices { get; set; } = [];
}
public sealed class Choice
{
public int Index { get; set; }
public ResponseMessage? Message { get; set; }
public ResponseDelta? Delta { get; set; }
public string? FinishReason { get; set; }
}
public sealed class ResponseMessage
{
public string Role { get; set; } = string.Empty;
public string? Content { get; set; }
public List<ToolCall>? ToolCalls { get; set; }
}
public sealed class ResponseDelta
{
public string? Content { get; set; }
public string? Role { get; set; }
}
#endregion

View File

@@ -8,7 +8,9 @@ public sealed class PromptManager
/// <summary>
/// 获取指定 Agent 的 System Prompt
/// </summary>
public string GetSystemPrompt(AgentType agentType) => agentType switch
public string GetSystemPrompt(AgentType agentType)
{
var prompt = agentType switch
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
@@ -21,6 +23,41 @@ public sealed class PromptManager
_ => DefaultPrompt
};
return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}";
}
private const string SmartLinkRules = """
- / Markdown [](app://diet),引导用户使用专门的饮食拍照分析功能。
- [](app://report),引导上传到报告分析功能获得详细解读。
- [](app://device)。
-
- "问热量/卡路里" app://diet"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。
- Markdown 4
""";
private const string MedicalBoundaryRules = """
- AI
- //
-
-
- 使
- AI
- 使 ******~~线~~ Markdown
- 使 HTML
- 使 Markdown |--|--|
- 使 ### 使 #######
- 使 -
- 120 mmHg6.2 mmol/L
- $
- MedicalBoundaryRules
""";
private const string DefaultPrompt = """
AI健康管家
@@ -38,15 +75,15 @@ public sealed class PromptManager
""";
private const string ConsultationPrompt = """
AI
1.
2. 2-3
3.
4.
5. >160/100>120<50
6. "以上为AI分析具体请咨询医生"
5. >160/100>120<50
6.
7.
""";
@@ -55,13 +92,13 @@ public sealed class PromptManager
1. ////
2. + record_health_data
2. + record_health_data
3. "血压120/80血糖6.2血氧97" record_health_data
4. "早上血压120下午血压130"recorded_at
4. "早上血压120下午血压130"recorded_at
5. "120""收缩压还是血糖?"
6.
7.
8. record_health_data
8. record_health_data
- 90-139 mmHg 60-89 mmHg
@@ -86,24 +123,24 @@ public sealed class PromptManager
""";
private const string MedicationPrompt = """
1. manage_medication(action="query")
2. ///
3. "早饭后"
4.
4. manage_medication(action="create")
5.
6.
6.
""";
private const string ReportPrompt = """
1.
2. //
3.
3.
4. "AI预解读待医生确认"
5. /CT"需医生人工审阅"
""";
@@ -120,32 +157,34 @@ public sealed class PromptManager
""";
private const string UnifiedPrompt = """
AI健康管
AI
1. //// record_health_data
2. estimate_food_text
2. 使
3. manage_medication
4. manage_exercise
5. / check_archive / manage_archive
6. query_health_records
manage_exercise
- action="create", week_start_date="今天日期(YYYY-MM-DD)"
- items: [{"day_of_week":0-6(=0),"exercise_type":"散步","duration_minutes":30,"is_rest_day":false}, ...]
- 7
- "坚持10天"10"到6月20日"
- items
- action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
- duration_days=exercise_type="散步"duration_minutes=30reminder_time="19:00"
- =30 =60 =30 =90 =80
- "一个月"=30 "一周"=7 "10天"=107
- "坚持X天/月/周"
- start_date + duration_days - 1
manage_medication
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
-
- +
-
- +
-
- pendingConfirmation=true
-
-
""";

View File

@@ -0,0 +1,90 @@
using Health.Application.Admin;
namespace Health.Infrastructure.Admin;
public sealed class AdminService(AppDbContext db) : IAdminService
{
private readonly AppDbContext _db = db;
public async Task<AdminResult> ListDoctorsAsync(CancellationToken ct)
{
var doctors = await _db.Doctors.AsNoTracking().OrderBy(x => x.CreatedAt)
.Select(x => new { x.Id, x.Name, x.Title, x.Department, x.Phone, x.ProfessionalDirection, x.IsActive, x.AvatarUrl, x.Introduction, x.CreatedAt })
.ToListAsync(ct);
return Ok(doctors);
}
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空");
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空");
var doctor = new Doctor
{
Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department,
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow,
};
_db.Doctors.Add(doctor);
if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct))
{
var user = new User { Id = Guid.NewGuid(), Phone = command.Phone, Role = "Doctor", Name = command.Name, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
_db.Users.Add(user);
_db.DoctorProfiles.Add(new DoctorProfile
{
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = command.Name,
Title = command.Title, Department = command.Department, IsActive = true,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
});
}
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.Name });
}
public async Task<AdminResult> UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
if (command.Name != null) doctor.Name = command.Name;
if (command.Title != null) doctor.Title = command.Title;
if (command.Department != null) doctor.Department = command.Department;
if (command.Phone != null) doctor.Phone = command.Phone;
if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id });
}
public async Task<AdminResult> ToggleDoctorAsync(Guid id, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
doctor.IsActive = !doctor.IsActive;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.IsActive });
}
public async Task<AdminResult> DeleteDoctorAsync(Guid id, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct);
_db.Doctors.Remove(doctor);
await _db.SaveChangesAsync(ct);
return Ok(new { success = true });
}
public async Task<AdminResult> ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 100);
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search));
var total = await query.CountAsync(ct);
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
.ToListAsync(ct);
return Ok(new { patients, total, page, pageSize });
}
private static AdminResult Ok(object data) => new(0, data);
private static AdminResult Error(int code, string message) => new(code, null, message);
}

View File

@@ -0,0 +1,172 @@
using Health.Application.Auth;
using Health.Infrastructure.Services;
namespace Health.Infrastructure.Auth;
public sealed class AuthService(
AppDbContext db,
JwtProvider jwt,
SmsService sms,
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
) : IAuthService
{
private const string AdminPhone = "12345678910";
private const string AdminSmsCode = "000000";
private static readonly Guid AdminId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private readonly AppDbContext _db = db;
private readonly JwtProvider _jwt = jwt;
private readonly SmsService _sms = sms;
private readonly AppleTokenValidator _appleValidator = appleValidator;
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
{
var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode();
await _db.VerificationCodes.AddAsync(new VerificationCode
{
Id = Guid.NewGuid(), Phone = phone, Code = code,
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
}, ct);
await _db.SaveChangesAsync(ct);
if (phone != AdminPhone) await _sms.SendCodeAsync(phone, code);
return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null });
}
public async Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct)
{
var code = await TakeCodeAsync(command.Phone, command.SmsCode, ct);
if (code == null) return Error(40001, "验证码错误或已过期");
if (await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct)) return Error(40002, "该手机号已注册,请直接登录");
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40003, "请输入姓名");
if (!await _db.Doctors.AnyAsync(x => x.Id == command.DoctorId && x.IsActive, ct)) return Error(40004, "所选医生不存在或已停诊");
code.IsUsed = true;
var user = new User
{
Id = Guid.NewGuid(), Phone = command.Phone, Role = "User", Name = command.Name,
DoctorId = command.DoctorId, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
_db.Users.Add(user);
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id });
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id });
var tokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { user.Id, user.Phone, user.Role, isNew = true } });
}
public async Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct)
{
var code = await TakeCodeAsync(phone, smsCode, ct);
if (code == null) return Error(40001, "验证码错误或已过期");
code.IsUsed = true;
if (phone == AdminPhone)
{
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { id = AdminId, phone = AdminPhone, role = "Admin", name = "管理员", isNew = false } });
}
var user = await _db.Users.FirstOrDefaultAsync(x => x.Phone == phone, ct);
if (user == null) return Error(40004, "该手机号未注册,请先注册");
user.UpdatedAt = DateTime.UtcNow;
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new
{
userTokens.accessToken, userTokens.refreshToken,
user = new { user.Id, user.Phone, user.Role, user.Name, user.Gender, user.AvatarUrl, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), isNew = false }
});
}
public async Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct)
{
var oldToken = await _db.RefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken && !x.IsRevoked, ct);
if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow) return Error(40002, "登录已过期,请重新登录");
oldToken.IsRevoked = true;
if (oldToken.UserId == AdminId)
{
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } });
}
var user = await _db.Users.FindAsync([oldToken.UserId], ct);
if (user == null) return Error(40002, "用户不存在");
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { userTokens.accessToken, userTokens.refreshToken, user = new { user.Role } });
}
public async Task LogoutAsync(string refreshToken, CancellationToken ct)
{
var token = await _db.RefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken, ct);
if (token != null) token.IsRevoked = true;
await _db.SaveChangesAsync(ct);
}
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
{
// 1. 验证 Apple identityToken
string appleUserId;
try
{
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
}
catch (Exception ex)
{
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
}
// 2. 查找已有用户
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
if (existingUser != null)
{
existingUser.UpdatedAt = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new
{
tokens.accessToken, tokens.refreshToken,
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
});
}
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
var newUser = new User
{
Id = Guid.NewGuid(),
Phone = null,
AppleUserId = appleUserId,
Role = "User",
Name = command.Name,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_db.Users.Add(newUser);
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new
{
newTokens.accessToken, newTokens.refreshToken,
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
});
}
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
{
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);
}
private static AuthResult Error(int code, string message) => new(code, null, message);
}

View File

@@ -0,0 +1,27 @@
using Health.Application.Calendars;
namespace Health.Infrastructure.Calendars;
public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository
{
private readonly AppDbContext _db = db;
public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct)
{
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var medications = await _db.Medications
.Where(m => m.UserId == userId && m.IsActive)
.ToListAsync(ct);
var exercisePlans = await _db.ExercisePlans
.Include(p => p.Items)
.Where(p => p.UserId == userId && p.StartDate < end && p.EndDate >= start)
.ToListAsync(ct);
var followUps = await _db.FollowUps
.Where(f => f.UserId == userId && f.ScheduledAt >= startUtc && f.ScheduledAt < endUtc)
.ToListAsync(ct);
return new CalendarDataSnapshot(medications, exercisePlans, followUps);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,946 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AiWriteCommands",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ToolName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Arguments = table.Column<string>(type: "jsonb", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AiWriteCommands", x => x.Id);
});
migrationBuilder.CreateTable(
name: "DietImageAnalysisTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FilePaths = table.Column<string>(type: "jsonb", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ResultCode = table.Column<int>(type: "integer", nullable: true),
ResultData = table.Column<string>(type: "text", nullable: true),
ResultMessage = table.Column<string>(type: "text", nullable: true),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DietImageAnalysisTasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Doctors",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: true),
Department = table.Column<string>(type: "text", nullable: true),
Phone = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
ProfessionalDirection = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: true),
Introduction = table.Column<string>(type: "text", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Doctors", x => x.Id);
});
migrationBuilder.CreateTable(
name: "MedicationReminderTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Dosage = table.Column<string>(type: "text", nullable: true),
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MedicationReminderTasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NotificationOutbox",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
SourceTaskId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Payload = table.Column<string>(type: "jsonb", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationOutbox", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RefreshTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Token = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsRevoked = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ReportAnalysisTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ReportId = table.Column<Guid>(type: "uuid", nullable: false),
FilePath = table.Column<string>(type: "text", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReportAnalysisTasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserNotifications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
SourceId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
Severity = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
ActionType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ActionTargetId = table.Column<string>(type: "text", nullable: true),
IsRead = table.Column<bool>(type: "boolean", nullable: false),
ReadAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserNotifications", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VerificationCodes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Phone = table.Column<string>(type: "text", nullable: false),
Code = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsUsed = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VerificationCodes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Phone = table.Column<string>(type: "text", nullable: false),
Role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "User"),
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
Name = table.Column<string>(type: "text", nullable: true),
Gender = table.Column<string>(type: "text", nullable: true),
BirthDate = table.Column<DateOnly>(type: "date", nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
table.ForeignKey(
name: "FK_Users_Doctors_DoctorId",
column: x => x.DoctorId,
principalTable: "Doctors",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "Consultations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
DoctorId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
Month = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Consultations", x => x.Id);
table.ForeignKey(
name: "FK_Consultations_Doctors_DoctorId",
column: x => x.DoctorId,
principalTable: "Doctors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Consultations_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Conversations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
AgentType = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: true),
Summary = table.Column<string>(type: "text", nullable: true),
MessageCount = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Conversations", x => x.Id);
table.ForeignKey(
name: "FK_Conversations_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DeviceTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Platform = table.Column<string>(type: "text", nullable: false),
PushToken = table.Column<string>(type: "text", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DeviceTokens", x => x.Id);
table.ForeignKey(
name: "FK_DeviceTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DietRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MealType = table.Column<string>(type: "text", nullable: false),
TotalCalories = table.Column<int>(type: "integer", nullable: true),
HealthScore = table.Column<int>(type: "integer", nullable: true),
RecordedAt = table.Column<DateOnly>(type: "date", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DietRecords", x => x.Id);
table.ForeignKey(
name: "FK_DietRecords_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DoctorProfiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
Name = table.Column<string>(type: "text", nullable: true),
Title = table.Column<string>(type: "text", nullable: true),
Department = table.Column<string>(type: "text", nullable: true),
Hospital = table.Column<string>(type: "text", nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: true),
IsOnline = table.Column<bool>(type: "boolean", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DoctorProfiles", x => x.Id);
table.ForeignKey(
name: "FK_DoctorProfiles_Doctors_DoctorId",
column: x => x.DoctorId,
principalTable: "Doctors",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_DoctorProfiles_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExercisePlans",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
ReminderTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExercisePlans", x => x.Id);
table.ForeignKey(
name: "FK_ExercisePlans_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FollowUps",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
DoctorName = table.Column<string>(type: "text", nullable: true),
Department = table.Column<string>(type: "text", nullable: true),
ScheduledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Notes = table.Column<string>(type: "text", nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FollowUps", x => x.Id);
table.ForeignKey(
name: "FK_FollowUps_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HealthArchives",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Diagnosis = table.Column<string>(type: "text", nullable: true),
SurgeryType = table.Column<string>(type: "text", nullable: true),
SurgeryDate = table.Column<DateOnly>(type: "date", nullable: true),
Allergies = table.Column<List<string>>(type: "text[]", nullable: false),
DietRestrictions = table.Column<List<string>>(type: "text[]", nullable: false),
ChronicDiseases = table.Column<List<string>>(type: "text[]", nullable: false),
FamilyHistory = table.Column<string>(type: "text", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HealthArchives", x => x.Id);
table.ForeignKey(
name: "FK_HealthArchives_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HealthRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MetricType = table.Column<string>(type: "text", nullable: false),
Systolic = table.Column<int>(type: "integer", nullable: true),
Diastolic = table.Column<int>(type: "integer", nullable: true),
Value = table.Column<decimal>(type: "numeric", nullable: true),
Unit = table.Column<string>(type: "text", nullable: true),
Source = table.Column<string>(type: "text", nullable: false),
IsAbnormal = table.Column<bool>(type: "boolean", nullable: false),
RecordedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HealthRecords", x => x.Id);
table.ForeignKey(
name: "FK_HealthRecords_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Medications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Dosage = table.Column<string>(type: "text", nullable: true),
Frequency = table.Column<string>(type: "text", nullable: false),
TimeOfDay = table.Column<List<TimeOnly>>(type: "time[]", nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: true),
EndDate = table.Column<DateOnly>(type: "date", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
Source = table.Column<string>(type: "text", nullable: false),
Notes = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Medications", x => x.Id);
table.ForeignKey(
name: "FK_Medications_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "NotificationPreferences",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MedicationReminder = table.Column<bool>(type: "boolean", nullable: false),
FollowUpReminder = table.Column<bool>(type: "boolean", nullable: false),
DoctorReply = table.Column<bool>(type: "boolean", nullable: false),
AbnormalAlert = table.Column<bool>(type: "boolean", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationPreferences", x => x.Id);
table.ForeignKey(
name: "FK_NotificationPreferences_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Reports",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
FileUrl = table.Column<string>(type: "text", nullable: false),
FileType = table.Column<string>(type: "text", nullable: false),
Category = table.Column<string>(type: "text", nullable: false),
AiSummary = table.Column<string>(type: "text", nullable: true),
AiIndicators = table.Column<string>(type: "jsonb", nullable: true),
Status = table.Column<string>(type: "text", nullable: false),
Severity = table.Column<string>(type: "text", nullable: true),
DoctorComment = table.Column<string>(type: "text", nullable: true),
DoctorRecommendation = table.Column<string>(type: "text", nullable: true),
DoctorName = table.Column<string>(type: "text", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Reports", x => x.Id);
table.ForeignKey(
name: "FK_Reports_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ConsultationMessages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ConsultationId = table.Column<Guid>(type: "uuid", nullable: false),
SenderType = table.Column<string>(type: "text", nullable: false),
SenderName = table.Column<string>(type: "text", nullable: true),
Content = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConsultationMessages", x => x.Id);
table.ForeignKey(
name: "FK_ConsultationMessages_Consultations_ConsultationId",
column: x => x.ConsultationId,
principalTable: "Consultations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ConversationMessages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "text", nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
Intent = table.Column<string>(type: "text", nullable: true),
MetadataJson = table.Column<string>(type: "jsonb", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConversationMessages", x => x.Id);
table.ForeignKey(
name: "FK_ConversationMessages_Conversations_ConversationId",
column: x => x.ConversationId,
principalTable: "Conversations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DietFoodItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DietRecordId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Portion = table.Column<string>(type: "text", nullable: true),
Calories = table.Column<int>(type: "integer", nullable: true),
ProteinGrams = table.Column<decimal>(type: "numeric", nullable: true),
CarbsGrams = table.Column<decimal>(type: "numeric", nullable: true),
FatGrams = table.Column<decimal>(type: "numeric", nullable: true),
Warning = table.Column<string>(type: "text", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DietFoodItems", x => x.Id);
table.ForeignKey(
name: "FK_DietFoodItems_DietRecords_DietRecordId",
column: x => x.DietRecordId,
principalTable: "DietRecords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExercisePlanItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
PlanId = table.Column<Guid>(type: "uuid", nullable: false),
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
ExerciseType = table.Column<string>(type: "text", nullable: false),
DurationMinutes = table.Column<int>(type: "integer", nullable: false),
IsCompleted = table.Column<bool>(type: "boolean", nullable: false),
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsRestDay = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExercisePlanItems", x => x.Id);
table.ForeignKey(
name: "FK_ExercisePlanItems_ExercisePlans_PlanId",
column: x => x.PlanId,
principalTable: "ExercisePlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HealthArchiveSurgeries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
HealthArchiveId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Date = table.Column<DateOnly>(type: "date", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HealthArchiveSurgeries", x => x.Id);
table.ForeignKey(
name: "FK_HealthArchiveSurgeries_HealthArchives_HealthArchiveId",
column: x => x.HealthArchiveId,
principalTable: "HealthArchives",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MedicationLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
ConfirmedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MedicationLogs", x => x.Id);
table.ForeignKey(
name: "FK_MedicationLogs_Medications_MedicationId",
column: x => x.MedicationId,
principalTable: "Medications",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AiWriteCommands_UserId_Status_ExpiresAt",
table: "AiWriteCommands",
columns: new[] { "UserId", "Status", "ExpiresAt" });
migrationBuilder.CreateIndex(
name: "IX_ConsultationMessages_ConsultationId_CreatedAt",
table: "ConsultationMessages",
columns: new[] { "ConsultationId", "CreatedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_Consultations_DoctorId",
table: "Consultations",
column: "DoctorId");
migrationBuilder.CreateIndex(
name: "IX_Consultations_UserId",
table: "Consultations",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_ConversationMessages_ConversationId_CreatedAt",
table: "ConversationMessages",
columns: new[] { "ConversationId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Conversations_UserId_UpdatedAt",
table: "Conversations",
columns: new[] { "UserId", "UpdatedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_DeviceTokens_UserId",
table: "DeviceTokens",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_DietFoodItems_DietRecordId",
table: "DietFoodItems",
column: "DietRecordId");
migrationBuilder.CreateIndex(
name: "IX_DietImageAnalysisTasks_Status_AvailableAt",
table: "DietImageAnalysisTasks",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_DietRecords_UserId_RecordedAt",
table: "DietRecords",
columns: new[] { "UserId", "RecordedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_DoctorProfiles_DoctorId",
table: "DoctorProfiles",
column: "DoctorId");
migrationBuilder.CreateIndex(
name: "IX_DoctorProfiles_UserId",
table: "DoctorProfiles",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExercisePlanItems_PlanId_ScheduledDate",
table: "ExercisePlanItems",
columns: new[] { "PlanId", "ScheduledDate" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExercisePlans_UserId_StartDate_EndDate",
table: "ExercisePlans",
columns: new[] { "UserId", "StartDate", "EndDate" });
migrationBuilder.CreateIndex(
name: "IX_FollowUps_UserId",
table: "FollowUps",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_HealthArchives_UserId",
table: "HealthArchives",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_HealthArchiveSurgeries_HealthArchiveId_SortOrder",
table: "HealthArchiveSurgeries",
columns: new[] { "HealthArchiveId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_HealthRecords_UserId_MetricType",
table: "HealthRecords",
columns: new[] { "UserId", "MetricType" });
migrationBuilder.CreateIndex(
name: "IX_HealthRecords_UserId_RecordedAt",
table: "HealthRecords",
columns: new[] { "UserId", "RecordedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_MedicationLogs_MedicationId_CreatedAt",
table: "MedicationLogs",
columns: new[] { "MedicationId", "CreatedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_MedicationReminderTasks_MedicationId_ScheduledDate_Schedule~",
table: "MedicationReminderTasks",
columns: new[] { "MedicationId", "ScheduledDate", "ScheduledTime" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MedicationReminderTasks_Status_AvailableAt",
table: "MedicationReminderTasks",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_Medications_UserId_IsActive",
table: "Medications",
columns: new[] { "UserId", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_NotificationOutbox_SourceTaskId",
table: "NotificationOutbox",
column: "SourceTaskId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NotificationOutbox_Status_AvailableAt",
table: "NotificationOutbox",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_NotificationPreferences_UserId",
table: "NotificationPreferences",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ReportAnalysisTasks_ReportId",
table: "ReportAnalysisTasks",
column: "ReportId");
migrationBuilder.CreateIndex(
name: "IX_ReportAnalysisTasks_Status_AvailableAt",
table: "ReportAnalysisTasks",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_Reports_UserId",
table: "Reports",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserNotifications_SourceId",
table: "UserNotifications",
column: "SourceId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserNotifications_UserId_IsDeleted_IsRead_CreatedAt",
table: "UserNotifications",
columns: new[] { "UserId", "IsDeleted", "IsRead", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Users_DoctorId",
table: "Users",
column: "DoctorId");
migrationBuilder.CreateIndex(
name: "IX_Users_Phone",
table: "Users",
column: "Phone",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VerificationCodes_Phone_CreatedAt",
table: "VerificationCodes",
columns: new[] { "Phone", "CreatedAt" },
descending: new[] { false, true });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AiWriteCommands");
migrationBuilder.DropTable(
name: "ConsultationMessages");
migrationBuilder.DropTable(
name: "ConversationMessages");
migrationBuilder.DropTable(
name: "DeviceTokens");
migrationBuilder.DropTable(
name: "DietFoodItems");
migrationBuilder.DropTable(
name: "DietImageAnalysisTasks");
migrationBuilder.DropTable(
name: "DoctorProfiles");
migrationBuilder.DropTable(
name: "ExercisePlanItems");
migrationBuilder.DropTable(
name: "FollowUps");
migrationBuilder.DropTable(
name: "HealthArchiveSurgeries");
migrationBuilder.DropTable(
name: "HealthRecords");
migrationBuilder.DropTable(
name: "MedicationLogs");
migrationBuilder.DropTable(
name: "MedicationReminderTasks");
migrationBuilder.DropTable(
name: "NotificationOutbox");
migrationBuilder.DropTable(
name: "NotificationPreferences");
migrationBuilder.DropTable(
name: "RefreshTokens");
migrationBuilder.DropTable(
name: "ReportAnalysisTasks");
migrationBuilder.DropTable(
name: "Reports");
migrationBuilder.DropTable(
name: "UserNotifications");
migrationBuilder.DropTable(
name: "VerificationCodes");
migrationBuilder.DropTable(
name: "Consultations");
migrationBuilder.DropTable(
name: "Conversations");
migrationBuilder.DropTable(
name: "DietRecords");
migrationBuilder.DropTable(
name: "ExercisePlans");
migrationBuilder.DropTable(
name: "HealthArchives");
migrationBuilder.DropTable(
name: "Medications");
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.DropTable(
name: "Doctors");
}
}
}

View File

@@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddHealthRecordReminder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminder",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminderBloodPressure",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminderGlucose",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminderHeartRate",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminderSpO2",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminderWeight",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "HealthRecordReminder",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "HealthRecordReminderBloodPressure",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "HealthRecordReminderGlucose",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "HealthRecordReminderHeartRate",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "HealthRecordReminderSpO2",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "HealthRecordReminderWeight",
table: "NotificationPreferences");
}
}
}

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
namespace Health.Infrastructure.Data.Records;
public sealed class AiWriteCommandRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string ToolName { get; set; } = string.Empty;
public string Arguments { get; set; } = string.Empty;
public string Status { get; set; } = "Pending";
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,16 @@
namespace Health.Infrastructure.Data.Records;
public sealed class DietImageAnalysisTaskRecord
{
public Guid Id { get; set; }
public string FilePaths { get; set; } = "[]";
public string Status { get; set; } = "Pending";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public int? ResultCode { get; set; }
public string? ResultData { get; set; }
public string? ResultMessage { get; set; }
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,18 @@
namespace Health.Infrastructure.Data.Records;
public sealed class MedicationReminderTaskRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid MedicationId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Dosage { get; set; }
public DateOnly ScheduledDate { get; set; }
public TimeOnly ScheduledTime { get; set; }
public string Status { get; set; } = "Pending";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,16 @@
namespace Health.Infrastructure.Data.Records;
public sealed class NotificationOutboxRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid SourceTaskId { get; set; }
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = "{}";
public string Status { get; set; } = "AwaitingProvider";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,14 @@
namespace Health.Infrastructure.Data.Records;
public sealed class ReportAnalysisTaskRecord
{
public Guid Id { get; set; }
public Guid ReportId { get; set; }
public string FilePath { get; set; } = string.Empty;
public string Status { get; set; } = "Pending";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Data;
@@ -24,14 +25,22 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
public DbSet<Consultation> Consultations => Set<Consultation>();
public DbSet<ConsultationMessage> ConsultationMessages => Set<ConsultationMessage>();
public DbSet<Doctor> Doctors => Set<Doctor>();
public DbSet<DoctorProfile> DoctorProfiles => Set<DoctorProfile>();
public DbSet<FollowUp> FollowUps => Set<FollowUp>();
public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>();
public DbSet<HealthArchiveSurgery> HealthArchiveSurgeries => Set<HealthArchiveSurgery>();
// 支撑表
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
public DbSet<NotificationOutboxRecord> NotificationOutbox => Set<NotificationOutboxRecord>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -41,6 +50,24 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
builder.Entity<User>(e =>
{
e.HasIndex(u => u.Phone).IsUnique();
e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
});
// ---- Doctor ----
builder.Entity<Doctor>(e =>
{
e.Property(d => d.Phone).HasMaxLength(20);
e.Property(d => d.ProfessionalDirection).HasMaxLength(256);
});
// ---- DoctorProfile ----
builder.Entity<DoctorProfile>(e =>
{
e.HasIndex(d => d.UserId).IsUnique();
e.HasOne(d => d.User).WithOne(u => u.DoctorProfile).HasForeignKey<DoctorProfile>(d => d.UserId);
e.HasOne(d => d.Doctor).WithMany().HasForeignKey(d => d.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
});
// ---- HealthRecord ----
@@ -77,7 +104,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
// ---- ExercisePlan ----
builder.Entity<ExercisePlan>(e =>
{
e.HasIndex(p => new { p.UserId, p.WeekStartDate }).IsDescending(false, true);
e.HasIndex(p => new { p.UserId, p.StartDate, p.EndDate });
});
builder.Entity<ExercisePlanItem>(e =>
{
e.HasIndex(i => new { i.PlanId, i.ScheduledDate }).IsUnique();
});
// ---- Report ----
@@ -125,10 +157,74 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
e.HasIndex(n => n.UserId).IsUnique();
});
builder.Entity<UserNotification>(e =>
{
e.ToTable("UserNotifications");
e.HasIndex(n => n.SourceId).IsUnique();
e.HasIndex(n => new { n.UserId, n.IsDeleted, n.IsRead, n.CreatedAt });
e.Property(n => n.Type).HasMaxLength(64);
e.Property(n => n.Severity).HasMaxLength(16);
e.Property(n => n.ActionType).HasMaxLength(64);
});
// ---- HealthArchive ----
builder.Entity<HealthArchive>(e =>
{
e.HasIndex(a => a.UserId).IsUnique();
});
builder.Entity<HealthArchiveSurgery>(e =>
{
e.ToTable("HealthArchiveSurgeries");
e.HasIndex(s => new { s.HealthArchiveId, s.SortOrder });
e.Property(s => s.Type).HasMaxLength(256);
e.HasOne(s => s.HealthArchive)
.WithMany(a => a.Surgeries)
.HasForeignKey(s => s.HealthArchiveId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<AiWriteCommandRecord>(e =>
{
e.ToTable("AiWriteCommands");
e.HasIndex(x => new { x.UserId, x.Status, x.ExpiresAt });
e.Property(x => x.ToolName).HasMaxLength(64);
e.Property(x => x.Status).HasMaxLength(32);
e.Property(x => x.Arguments).HasColumnType("jsonb");
});
builder.Entity<ReportAnalysisTaskRecord>(e =>
{
e.ToTable("ReportAnalysisTasks");
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.HasIndex(x => x.ReportId);
e.Property(x => x.Status).HasMaxLength(32);
});
builder.Entity<DietImageAnalysisTaskRecord>(e =>
{
e.ToTable("DietImageAnalysisTasks");
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.Property(x => x.FilePaths).HasColumnType("jsonb");
e.Property(x => x.Status).HasMaxLength(32);
});
builder.Entity<MedicationReminderTaskRecord>(e =>
{
e.ToTable("MedicationReminderTasks");
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.HasIndex(x => new { x.MedicationId, x.ScheduledDate, x.ScheduledTime }).IsUnique();
e.Property(x => x.Status).HasMaxLength(32);
});
builder.Entity<NotificationOutboxRecord>(e =>
{
e.ToTable("NotificationOutbox");
e.HasIndex(x => x.SourceTaskId).IsUnique();
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.Property(x => x.Type).HasMaxLength(64);
e.Property(x => x.Status).HasMaxLength(32);
e.Property(x => x.Payload).HasColumnType("jsonb");
});
}
}

View File

@@ -1,45 +0,0 @@
namespace Health.Infrastructure.Data;
/// <summary>
/// 数据库种子数据
/// </summary>
public static class DataSeeder
{
public static async Task SeedAsync(AppDbContext db)
{
// 种子医生数据(固定 ID与客户端 fallback 保持一致)
if (!db.Doctors.Any())
{
db.Doctors.AddRange(
new Doctor
{
Id = Guid.Parse("ef0953c9-eb63-4d03-b6d7-050a1897d4a3"),
Name = "王建国",
Title = "主任医师",
Department = "心血管内科",
Introduction = "擅长冠心病术后管理、心脏康复指导",
IsActive = true
},
new Doctor
{
Id = Guid.Parse("d4148733-b538-4398-af17-0c7592fc0c2d"),
Name = "李芳",
Title = "副主任医师",
Department = "营养科",
Introduction = "擅长术后营养指导、膳食规划",
IsActive = true
},
new Doctor
{
Id = Guid.Parse("468b82e2-d95a-4436-bff6-a50eecf99a66"),
Name = "张明",
Title = "主任医师",
Department = "心脏康复科",
Introduction = "擅长心脏术后运动康复、心肺功能评估",
IsActive = true
}
);
await db.SaveChangesAsync();
}
}
}

View File

@@ -1,145 +0,0 @@
using Microsoft.Extensions.Configuration;
namespace Health.Infrastructure.Data;
/// <summary>
/// 开发环境测试数据填充。生产环境不要调用!
/// 开关DEVDATA_ENABLED=true 才会执行
/// </summary>
public static class DevDataSeeder
{
public static async Task SeedIfEnabled(AppDbContext db, IConfiguration config)
{
// 通过环境变量控制DEVDATA_ENABLED=true 才填充测试数据
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
if (enabled != "true") return;
// 已有任何真实用户时跳过(避免污染真实数据)
if (db.Users.Any(u => u.Phone != "13800000001")) return;
// 检查是否已有测试用户(避免重复填充)
if (db.Users.Any(u => u.Phone == "13800000001")) return;
// ---- 创建测试患者 ----
var user = new User
{
Id = Guid.NewGuid(), Phone = "13800000001", Name = "张三",
Gender = "男", BirthDate = new DateOnly(1970, 3, 15),
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
db.Users.Add(user);
await db.SaveChangesAsync();
// ---- 健康档案 ----
db.HealthArchives.Add(new HealthArchive
{
Id = Guid.NewGuid(), UserId = user.Id,
Diagnosis = "冠心病", SurgeryType = "PCI支架植入术",
SurgeryDate = new DateOnly(2026, 3, 15),
Allergies = ["青霉素"], DietRestrictions = ["低盐", "低脂"],
ChronicDiseases = ["高血压", "高血脂"],
FamilyHistory = "父亲冠心病",
UpdatedAt = DateTime.UtcNow,
});
// ---- 健康数据(过去 7 天)----
var random = new Random(42);
for (int i = 7; i >= 0; i--)
{
var date = DateTime.UtcNow.AddDays(-i);
// 血压
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
Systolic = 120 + random.Next(-5, 15), Diastolic = 75 + random.Next(-5, 10),
Unit = "mmHg", Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
// 心率
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.HeartRate,
Value = 68 + random.Next(-5, 10), Unit = "次/分",
Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
// 血糖
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.Glucose,
Value = 5.0m + (decimal)(random.NextDouble() * 1.5),
Unit = "mmol/L", Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
// 血氧
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.SpO2,
Value = 96 + random.Next(0, 3), Unit = "%",
Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
}
// 一条异常血压
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
Systolic = 148, Diastolic = 92, Unit = "mmHg",
Source = HealthRecordSource.AiEntry, IsAbnormal = true,
RecordedAt = DateTime.UtcNow.AddDays(-1),
});
// ---- 用药计划 ----
db.Medications.Add(new Medication
{
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿司匹林", Dosage = "100mg",
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)],
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
});
db.Medications.Add(new Medication
{
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿托伐他汀", Dosage = "20mg",
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(20, 0)],
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
});
// ---- 运动计划 ----
var monday = DateOnly.FromDateTime(DateTime.Now.AddDays(-(int)DateTime.Now.DayOfWeek + 1));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday };
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, ExerciseType = "散步", DurationMinutes = 30 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 3, IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 4, ExerciseType = "太极", DurationMinutes = 40 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 5, IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 6, ExerciseType = "散步", DurationMinutes = 30 });
db.ExercisePlans.Add(plan);
// ---- 饮食记录 ----
var lunch = new DietRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch,
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.Now),
};
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 });
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 });
db.DietRecords.Add(lunch);
// ---- 复查计划 ----
db.FollowUps.Add(new FollowUp
{
Id = Guid.NewGuid(), UserId = user.Id, Title = "心内科复查",
DoctorName = "王建国", Department = "心血管内科",
ScheduledAt = DateTime.UtcNow.AddDays(3), Status = FollowUpStatus.Upcoming,
});
db.FollowUps.Add(new FollowUp
{
Id = Guid.NewGuid(), UserId = user.Id, Title = "术后3周复查",
DoctorName = "王建国", Department = "心血管内科",
ScheduledAt = DateTime.UtcNow.AddDays(-14), Status = FollowUpStatus.Completed,
});
await db.SaveChangesAsync();
Console.WriteLine($"[DEV] 测试数据已填充:用户 {user.Phone}");
}
}

View File

@@ -0,0 +1,60 @@
using Health.Application.Diets;
namespace Health.Infrastructure.Diets;
public sealed class DietImageAnalysisCoordinator(IDietImageAnalysisQueue queue) : IDietImageAnalysisCoordinator
{
private const long MaxImageBytes = 20 * 1024 * 1024;
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".heic"
};
private readonly IDietImageAnalysisQueue _queue = queue;
public async Task<DietImageAnalysisResult> AnalyzeAsync(IReadOnlyList<DietImageUploadFile> files, CancellationToken ct)
{
if (files.Count == 0)
return new DietImageAnalysisResult(false, 40001, null, "请上传至少一张图片");
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "diet-analysis");
Directory.CreateDirectory(uploadsDir);
var paths = new List<string>();
var handedOff = false;
try
{
foreach (var file in files)
{
if (file.Length <= 0 || file.Length > MaxImageBytes)
return new DietImageAnalysisResult(false, 40001, null, "文件大小超过 20MB 限制");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return new DietImageAnalysisResult(false, 40001, null, "不支持的图片格式,仅支持 JPG/PNG/HEIC");
var filePath = Path.Combine(uploadsDir, $"{Guid.NewGuid()}{extension}");
await using var stream = new FileStream(filePath, FileMode.CreateNew);
await file.Content.CopyToAsync(stream, ct);
paths.Add(filePath);
}
var jobId = await _queue.EnqueueAsync(paths, ct);
handedOff = true;
return await _queue.WaitForResultAsync(jobId, ct);
}
catch
{
if (!handedOff)
{
foreach (var path in paths)
{
try { if (File.Exists(path)) File.Delete(path); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}
throw;
}
}
}

View File

@@ -0,0 +1,126 @@
using System.Text.Json;
using Health.Application.Diets;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Diets;
public sealed class DietImageAnalysisQueue(AppDbContext db) : IDietImageAnalysisQueue
{
private const int MaxAttempts = 3;
private readonly AppDbContext _db = db;
public async Task<Guid> EnqueueAsync(IReadOnlyList<string> filePaths, CancellationToken ct)
{
var now = DateTime.UtcNow;
var task = new DietImageAnalysisTaskRecord
{
Id = Guid.NewGuid(),
FilePaths = JsonSerializer.Serialize(filePaths),
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
};
await _db.DietImageAnalysisTasks.AddAsync(task, ct);
await _db.SaveChangesAsync(ct);
return task.Id;
}
public async Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var staleCutoff = now.AddMinutes(-5);
// 超时但仍有重试余量 → 重新排队
await _db.DietImageAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing也让 WaitForResultAsync 能尽快返回失败)
await _db.DietImageAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.ResultCode, 50001)
.SetProperty(x => x.ResultMessage, "食物识别超时且已达最大重试次数")
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.DietImageAnalysisTasks.AsNoTracking()
.Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return null;
var claimed = await _db.DietImageAnalysisTasks
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
if (claimed == 0) return null;
var paths = JsonSerializer.Deserialize<List<string>>(candidate.FilePaths) ?? [];
return new DietImageAnalysisJob(candidate.Id, paths);
}
public async Task<DietImageAnalysisResult> WaitForResultAsync(Guid jobId, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var task = await _db.DietImageAnalysisTasks.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == jobId, ct);
if (task == null)
return new DietImageAnalysisResult(false, 40004, null, "饮食识别任务不存在");
if (task.Status is "Completed" or "Failed")
return new DietImageAnalysisResult(
task.Status == "Completed",
task.ResultCode ?? 50001,
task.ResultData,
task.ResultMessage);
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
}
throw new OperationCanceledException(ct);
}
public Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.DietImageAnalysisTasks.Where(x => x.Id == jobId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, result.Success ? "Completed" : "Failed")
.SetProperty(x => x.ResultCode, result.Code)
.SetProperty(x => x.ResultData, result.Data)
.SetProperty(x => x.ResultMessage, result.Message)
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<bool> RetryAsync(Guid jobId, string error, CancellationToken ct)
{
var task = await _db.DietImageAnalysisTasks.FirstOrDefaultAsync(x => x.Id == jobId, ct);
if (task == null) return true;
task.LastError = error.Length > 2000 ? error[..2000] : error;
task.UpdatedAt = DateTime.UtcNow;
if (task.Attempts >= MaxAttempts)
{
task.Status = "Failed";
task.ResultCode = 50001;
task.ResultMessage = $"食物识别失败: {error}";
}
else
{
task.Status = "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
return task.Status == "Failed";
}
}

View File

@@ -0,0 +1,71 @@
using Health.Application.Diets;
using Health.Infrastructure.AI;
namespace Health.Infrastructure.Diets;
public sealed class DietImageAnalyzer(VisionClient vision) : IDietImageAnalyzer
{
private readonly VisionClient _vision = vision;
public async Task<DietImageAnalysisResult> AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct)
{
var imageUrls = new List<string>();
foreach (var filePath in job.FilePaths)
{
if (!File.Exists(filePath))
return new DietImageAnalysisResult(false, 40004, null, "待识别图片不存在");
var bytes = await File.ReadAllBytesAsync(filePath, ct);
var base64 = Convert.ToBase64String(bytes);
if (base64.Length > 7 * 1024 * 1024)
return new DietImageAnalysisResult(false, 40001, null, "图片过大,请压缩后重新上传");
var extension = Path.GetExtension(filePath).ToLowerInvariant();
var mime = extension switch
{
".png" => "image/png",
".heic" => "image/heic",
_ => "image/jpeg"
};
imageUrls.Add($"data:{mime};base64,{base64}");
}
var prompt = """
JSON
##
** []**
##
[{"name":"食物名","portion":"具体克数","calories":数字}]
##
- (11cm)200g250g250g100g
- (14cm)400ml200ml
- (23cm)300g150g
- /250ml200ml
- 150g1100g180g115g
- 1100g1200g1120g
- 80-120g1100g1()300g
## 仿
{"name":"米饭","portion":"约200g","calories":230}
{"name":"红烧肉","portion":"约150g","calories":420}
{"name":"豆浆","portion":"约250ml","calories":80}
{"name":"米饭","portion":"一碗","calories":230} portion
{"name":"汤","portion":"一份","calories":80} portion
{"name":"水果","portion":"少许","calories":50} portion
##
1. []
2. portion + (g ml)
3. 使/"约"("约80-120g")
4. calories
JSON
""";
var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]";
return new DietImageAnalysisResult(true, 0, result, null);
}
}

View File

@@ -0,0 +1,33 @@
using Health.Application.Diets;
namespace Health.Infrastructure.Diets;
public sealed class EfDietRepository(AppDbContext db) : IDietRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<DietRecord>> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct)
{
var query = _db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
if (recordedAt.HasValue)
query = query.Where(r => r.RecordedAt == recordedAt.Value);
if (mealType.HasValue)
query = query.Where(r => r.MealType == mealType.Value);
return await query.OrderByDescending(r => r.RecordedAt).ToListAsync(ct);
}
public Task<DietRecord?> GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct) =>
_db.DietRecords.FirstOrDefaultAsync(r => r.Id == recordId && r.UserId == userId, ct);
public async Task AddAsync(DietRecord record, CancellationToken ct) =>
await _db.DietRecords.AddAsync(record, ct);
public void Delete(DietRecord record) =>
_db.DietRecords.Remove(record);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,44 @@
using Health.Application.Exercises;
namespace Health.Infrastructure.Exercises;
public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) =>
await _db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId && p.StartDate <= date && p.EndDate >= date)
.OrderByDescending(p => p.StartDate)
.ToListAsync(ct);
public async Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) =>
await _db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.StartDate)
.Take(limit)
.ToListAsync(ct);
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) =>
_db.ExercisePlans.Include(p => p.Items)
.FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct);
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) =>
_db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.StartDate)
.FirstOrDefaultAsync(ct);
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) =>
_db.ExercisePlanItems.Include(i => i.Plan)
.FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct);
public async Task AddAsync(ExercisePlan plan, CancellationToken ct) =>
await _db.ExercisePlans.AddAsync(plan, ct);
public void Delete(ExercisePlan plan) =>
_db.ExercisePlans.Remove(plan);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json;
using Health.Application.Exercises;
using Health.Application.Notifications;
namespace Health.Infrastructure.Exercises;
public sealed class ExerciseReminderProducer(AppDbContext db, IUserNotificationProducer notifications) : IExerciseReminderProducer
{
private readonly AppDbContext _db = db;
private readonly IUserNotificationProducer _notifications = notifications;
public async Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct)
{
var beijingNow = utcNow.AddHours(8);
var today = DateOnly.FromDateTime(beijingNow);
var currentTime = TimeOnly.FromDateTime(beijingNow);
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
.Include(x => x.Plan)
.Where(x => x.ScheduledDate == today && !x.IsCompleted && !x.IsRestDay && x.Plan.ReminderTime <= currentTime)
.ToListAsync(ct);
var created = 0;
foreach (var item in dueItems)
{
if (await _notifications.EnqueueAsync(
item.Plan.UserId,
item.Id,
new NotificationMessage(
"ExerciseReminder",
"今日运动计划",
$"今天安排了 {item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
"info",
"exercise",
item.PlanId.ToString()),
ct))
created++;
}
return created;
}
}

View File

@@ -1,15 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Health.Application\Health.Application.csproj" />
<ProjectReference Include="..\Health.Domain\Health.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
<PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="PdfPig" Version="0.1.16-alpha-20260629-34229" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
</ItemGroup>

View File

@@ -0,0 +1,19 @@
using Health.Application.HealthArchives;
namespace Health.Infrastructure.HealthArchives;
public sealed class EfHealthArchiveRepository(AppDbContext db) : IHealthArchiveRepository
{
private readonly AppDbContext _db = db;
public Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct) =>
_db.HealthArchives
.Include(a => a.Surgeries)
.FirstOrDefaultAsync(a => a.UserId == userId, ct);
public async Task AddAsync(HealthArchive archive, CancellationToken ct) =>
await _db.HealthArchives.AddAsync(archive, ct);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,45 @@
using Health.Application.HealthRecords;
namespace Health.Infrastructure.HealthRecords;
public sealed class EfHealthRecordRepository(AppDbContext db) : IHealthRecordRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct)
{
var query = _db.HealthRecords.Where(r => r.UserId == userId);
if (type.HasValue)
query = query.Where(r => r.MetricType == type.Value);
if (recordedAfter.HasValue)
query = query.Where(r => r.RecordedAt >= recordedAfter.Value);
return await query.OrderByDescending(r => r.RecordedAt).Take(limit).ToListAsync(ct);
}
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) =>
_db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
public Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct) =>
_db.HealthRecords
.Where(r => r.UserId == userId && r.MetricType == type)
.OrderByDescending(r => r.RecordedAt)
.FirstOrDefaultAsync(ct);
public async Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct) =>
await _db.HealthRecords
.Where(r => r.UserId == userId && r.MetricType == type && r.RecordedAt >= recordedAfter)
.OrderBy(r => r.RecordedAt)
.ToListAsync(ct);
public async Task AddAsync(HealthRecord record, CancellationToken ct) =>
await _db.HealthRecords.AddAsync(record, ct);
public void Delete(HealthRecord record) =>
_db.HealthRecords.Remove(record);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,52 @@
using Health.Application.Maintenance;
namespace Health.Infrastructure.Maintenance;
public sealed class EfMaintenanceRepository(AppDbContext db) : IMaintenanceRepository
{
private readonly AppDbContext _db = db;
public async Task<MaintenanceCleanupResult> CleanupAsync(
DateTime conversationCutoff,
DateTime taskCutoff,
DateTime utcNow,
CancellationToken ct)
{
var oldConversationIds = await _db.Conversations.AsNoTracking()
.Where(x => x.UpdatedAt < conversationCutoff)
.Select(x => x.Id)
.ToListAsync(ct);
if (oldConversationIds.Count > 0)
{
await _db.ConversationMessages
.Where(x => oldConversationIds.Contains(x.ConversationId))
.ExecuteDeleteAsync(ct);
await _db.Conversations
.Where(x => oldConversationIds.Contains(x.Id))
.ExecuteDeleteAsync(ct);
}
var verificationCodes = await _db.VerificationCodes
.Where(x => x.ExpiresAt < utcNow)
.ExecuteDeleteAsync(ct);
var backgroundTasks = 0;
backgroundTasks += await _db.ReportAnalysisTasks
.Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed"))
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.DietImageAnalysisTasks
.Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed"))
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.MedicationReminderTasks
.Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed"))
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.NotificationOutbox
.Where(x => x.UpdatedAt < taskCutoff)
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.AiWriteCommands
.Where(x => x.UpdatedAt < taskCutoff)
.ExecuteDeleteAsync(ct);
return new MaintenanceCleanupResult(oldConversationIds.Count, verificationCodes, backgroundTasks);
}
}

View File

@@ -0,0 +1,73 @@
using Health.Application.Medications;
namespace Health.Infrastructure.Medications;
public sealed class EfMedicationRepository(AppDbContext db) : IMedicationRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct)
{
var query = _db.Medications.Include(m => m.Logs).Where(m => m.UserId == userId);
if (filter == "active") query = query.Where(m => m.IsActive);
else if (filter == "inactive") query = query.Where(m => !m.IsActive);
return await query.OrderByDescending(m => m.CreatedAt).ToListAsync(ct);
}
public async Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) =>
await _db.Medications
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0
&& (m.StartDate == null || m.StartDate <= today)
&& (m.EndDate == null || m.EndDate >= today))
.ToListAsync(ct);
public async Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
await _db.MedicationLogs
.Where(l => l.UserId == userId && l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc)
.ToListAsync(ct);
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
_db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct);
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
_db.Medications.AnyAsync(m => m.Id == medicationId && m.UserId == userId, ct);
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.FirstOrDefaultAsync(l =>
l.MedicationId == medicationId && l.UserId == userId
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
&& l.Status == MedicationLogStatus.Taken, ct);
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.AnyAsync(l =>
l.MedicationId == medicationId && l.UserId == userId
&& l.ScheduledTime == scheduledTime
&& l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc, ct);
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.FirstOrDefaultAsync(l =>
l.MedicationId == medicationId && l.UserId == userId
&& l.ScheduledTime == scheduledTime
&& l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc, ct);
public async Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) =>
await _db.Medications
.Where(m => m.IsActive && m.TimeOfDay.Count > 0)
.ToListAsync(ct);
public async Task AddMedicationAsync(Medication medication, CancellationToken ct) =>
await _db.Medications.AddAsync(medication, ct);
public async Task AddLogAsync(MedicationLog log, CancellationToken ct) =>
await _db.MedicationLogs.AddAsync(log, ct);
public void DeleteMedication(Medication medication) =>
_db.Medications.Remove(medication);
public void DeleteLog(MedicationLog log) =>
_db.MedicationLogs.Remove(log);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,121 @@
using Health.Application.Medications;
using Health.Infrastructure.Data.Records;
using Npgsql;
namespace Health.Infrastructure.Medications;
public sealed class MedicationReminderQueue(AppDbContext db) : IMedicationReminderQueue
{
private const int MaxAttempts = 5;
private readonly AppDbContext _db = db;
public async Task<bool> EnqueueAsync(MedicationReminderTask task, CancellationToken ct)
{
var exists = await _db.MedicationReminderTasks.AsNoTracking().AnyAsync(x =>
x.MedicationId == task.MedicationId &&
x.ScheduledDate == task.ScheduledDate &&
x.ScheduledTime == task.ScheduledTime, ct);
if (exists) return false;
var now = DateTime.UtcNow;
await _db.MedicationReminderTasks.AddAsync(new MedicationReminderTaskRecord
{
Id = task.TaskId,
UserId = task.UserId,
MedicationId = task.MedicationId,
Name = task.Name,
Dosage = task.Dosage,
ScheduledDate = task.ScheduledDate,
ScheduledTime = task.ScheduledTime,
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
try
{
await _db.SaveChangesAsync(ct);
return true;
}
catch (DbUpdateException ex) when (
ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
_db.ChangeTracker.Clear();
return false;
}
}
public async Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var staleCutoff = now.AddMinutes(-5);
// 超时但仍有重试余量 → 重新排队
await _db.MedicationReminderTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing
await _db.MedicationReminderTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.MedicationReminderTasks.AsNoTracking()
.Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return null;
var claimed = await _db.MedicationReminderTasks
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
if (claimed == 0) return null;
return new MedicationReminderTask(
candidate.Id,
candidate.UserId,
candidate.MedicationId,
candidate.Name,
candidate.Dosage,
candidate.ScheduledDate,
candidate.ScheduledTime);
}
public Task CompleteAsync(Guid taskId, CancellationToken ct) =>
_db.MedicationReminderTasks.Where(x => x.Id == taskId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
public async Task RetryAsync(Guid taskId, string error, CancellationToken ct)
{
var task = await _db.MedicationReminderTasks.FirstOrDefaultAsync(x => x.Id == taskId, ct);
if (task == null) return;
task.LastError = error.Length > 2000 ? error[..2000] : error;
task.UpdatedAt = DateTime.UtcNow;
if (task.Attempts >= MaxAttempts)
{
task.Status = "Failed";
}
else
{
task.Status = "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json;
using Health.Application.Medications;
using Health.Application.Notifications;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Medications;
public sealed class OutboxMedicationReminderDispatcher(
IUserNotificationProducer notifications,
ILogger<OutboxMedicationReminderDispatcher> logger) : IMedicationReminderDispatcher
{
private readonly IUserNotificationProducer _notifications = notifications;
private readonly ILogger<OutboxMedicationReminderDispatcher> _logger = logger;
public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct)
{
var details = string.Join(" · ", new[]
{
task.Dosage,
task.ScheduledTime.ToString("HH:mm")
}.Where(x => !string.IsNullOrWhiteSpace(x)));
await _notifications.EnqueueAsync(
task.UserId,
task.TaskId,
new NotificationMessage(
"MedicationReminder",
"用药时间到了",
$"请按计划服用{task.Name}{(details.Length > 0 ? $"{details}" : "")},服用后记得打卡。",
"info",
"medication",
task.MedicationId.ToString()),
ct);
_logger.LogInformation(
"用药提醒已写入通知 Outbox等待推送服务: {TaskId} {UserId}",
task.TaskId,
task.UserId);
}
}

View File

@@ -0,0 +1,52 @@
using Health.Application.Notifications;
namespace Health.Infrastructure.Notifications;
public sealed class EfInAppNotificationRepository(AppDbContext db) : IInAppNotificationRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct) =>
await _db.UserNotifications.AsNoTracking()
.Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted)
.OrderBy(x => x.CreatedAt)
.Take(10)
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Title, x.Message,
x.Severity, x.ActionType, x.ActionTargetId, x.IsRead, x.CreatedAt))
.ToListAsync(ct);
public async Task<IReadOnlyList<InAppNotificationRecord>> GetHistoryAsync(Guid userId, CancellationToken ct) =>
await _db.UserNotifications.AsNoTracking()
.Where(x => x.UserId == userId && !x.IsDeleted)
.OrderByDescending(x => x.CreatedAt)
.Take(100)
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Title, x.Message,
x.Severity, x.ActionType, x.ActionTargetId, x.IsRead, x.CreatedAt))
.ToListAsync(ct);
public async Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct)
{
var updated = await _db.UserNotifications
.Where(x => x.Id == notificationId && x.UserId == userId && !x.IsRead && !x.IsDeleted)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.IsRead, true)
.SetProperty(x => x.ReadAt, DateTime.UtcNow), ct);
return updated > 0;
}
public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
_db.UserNotifications
.Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.IsRead, true)
.SetProperty(x => x.ReadAt, DateTime.UtcNow), ct);
public async Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct)
{
var deleted = await _db.UserNotifications
.Where(x => x.Id == notificationId && x.UserId == userId && !x.IsDeleted)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.IsDeleted, true), ct);
return deleted > 0;
}
}

View File

@@ -0,0 +1,139 @@
using System.Text.Json;
using Health.Application.Notifications;
using Health.Infrastructure.Data.Records;
using Npgsql;
namespace Health.Infrastructure.Notifications;
public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificationProducer
{
private readonly AppDbContext _db = db;
public async Task<bool> EnqueueAsync(
Guid userId,
Guid sourceId,
NotificationMessage message,
CancellationToken ct)
{
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
return false;
var now = DateTime.UtcNow;
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
{
Id = Guid.NewGuid(),
UserId = userId,
SourceTaskId = sourceId,
Type = message.Type,
Payload = JsonSerializer.Serialize(message),
Status = "Pending",
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
try
{
await _db.SaveChangesAsync(ct);
return true;
}
catch (DbUpdateException ex) when (
ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
_db.ChangeTracker.Clear();
return false;
}
}
}
public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificationOutboxProcessor
{
private const int MaxAttempts = 5;
private readonly AppDbContext _db = db;
public async Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
await _db.NotificationOutbox
.Where(x => x.Status == "Processing" &&
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
await _db.NotificationOutbox
.Where(x => x.Status == "Processing" &&
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<bool> ProcessNextAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.NotificationOutbox.AsNoTracking()
.Where(x => x.Status == "Pending" &&
x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return false;
var claimed = await _db.NotificationOutbox
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
if (claimed == 0) return true;
try
{
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
?? throw new InvalidOperationException("通知消息内容为空");
if (!await _db.UserNotifications.AsNoTracking()
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
{
await _db.UserNotifications.AddAsync(new UserNotification
{
Id = Guid.NewGuid(),
UserId = candidate.UserId,
SourceId = candidate.SourceTaskId,
Type = message.Type,
Title = message.Title,
Message = message.Message,
Severity = message.Severity,
ActionType = message.ActionType,
ActionTargetId = message.ActionTargetId,
CreatedAt = candidate.CreatedAt,
}, ct);
// Persist the inbox item first. If completion fails, stale-task
// recovery can safely retry because SourceId is idempotent.
await _db.SaveChangesAsync(ct);
}
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.LastError, (string?)null)
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
catch (Exception ex)
{
var attempts = candidate.Attempts + 1;
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
.SetProperty(x => x.AvailableAt,
DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5))
.SetProperty(x => x.LastError,
ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message)
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
return true;
}
}

View File

@@ -0,0 +1,123 @@
using Health.Application.Reports;
using Health.Application.Notifications;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Reports;
public sealed class EfReportAnalysisQueue(
AppDbContext db,
IUserNotificationProducer? notifications = null) : IReportAnalysisQueue
{
private const int MaxAttempts = 3;
private readonly AppDbContext _db = db;
private readonly IUserNotificationProducer? _notifications = notifications;
public async Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
await _db.ReportAnalysisTasks.AddAsync(new ReportAnalysisTaskRecord
{
Id = job.TaskId,
ReportId = job.ReportId,
FilePath = job.FilePath,
Status = "Pending",
Attempts = 0,
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
await _db.SaveChangesAsync(ct);
}
public async Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var staleCutoff = now.AddMinutes(-5);
// 超时但仍有重试余量 → 重新排队
await _db.ReportAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing
await _db.ReportAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.ReportAnalysisTasks.AsNoTracking()
.Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return null;
var claimed = await _db.ReportAnalysisTasks
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
return claimed == 0
? null
: new ReportAnalysisJob(candidate.Id, candidate.ReportId, candidate.FilePath);
}
public async Task CompleteAsync(Guid taskId, CancellationToken ct)
{
await _db.ReportAnalysisTasks
.Where(x => x.Id == taskId && x.Status == "Processing")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
public async Task RetryAsync(Guid taskId, string error, CancellationToken ct)
{
var task = await _db.ReportAnalysisTasks.FirstOrDefaultAsync(x => x.Id == taskId, ct);
if (task == null) return;
task.LastError = error.Length > 2000 ? error[..2000] : error;
task.UpdatedAt = DateTime.UtcNow;
if (task.Attempts >= MaxAttempts)
{
task.Status = "Failed";
}
else
{
task.Status = "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
if (task.Status == "Failed" && _notifications != null)
{
var report = await _db.Reports.AsNoTracking()
.Where(x => x.Id == task.ReportId)
.Select(x => new { x.Id, x.UserId })
.FirstOrDefaultAsync(ct);
if (report != null)
{
await _notifications.EnqueueAsync(
report.UserId,
task.Id,
new NotificationMessage(
"ReportAnalysisFailed",
"报告分析未完成",
"这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
"warning",
"report",
report.Id.ToString()),
ct);
}
}
}
}

View File

@@ -0,0 +1,29 @@
using Health.Application.Reports;
namespace Health.Infrastructure.Reports;
public sealed class EfReportRepository(AppDbContext db) : IReportRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<Report>> ListAsync(Guid userId, CancellationToken ct) =>
await _db.Reports
.Where(r => r.UserId == userId)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync(ct);
public Task<Report?> GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct) =>
_db.Reports.FirstOrDefaultAsync(r => r.Id == reportId && r.UserId == userId, ct);
public Task<Report?> GetByIdAsync(Guid reportId, CancellationToken ct) =>
_db.Reports.FirstOrDefaultAsync(r => r.Id == reportId, ct);
public async Task AddAsync(Report report, CancellationToken ct) =>
await _db.Reports.AddAsync(report, ct);
public void Delete(Report report) =>
_db.Reports.Remove(report);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,30 @@
using Health.Application.Reports;
namespace Health.Infrastructure.Reports;
public sealed class LocalReportFileStorage : IReportFileStorage
{
public async Task<StoredReportFile> SaveAsync(ReportUploadFile file, string extension, CancellationToken ct)
{
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports");
Directory.CreateDirectory(uploadsDir);
var fileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(uploadsDir, fileName);
await using (var stream = new FileStream(filePath, FileMode.CreateNew))
{
await file.Content.CopyToAsync(stream, ct);
}
return new StoredReportFile($"/uploads/reports/{fileName}", filePath);
}
public string GetLocalFilePath(string fileUrl) =>
Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/'));
public bool Exists(string filePath) =>
File.Exists(filePath);
public void Delete(string filePath) =>
File.Delete(filePath);
}

View File

@@ -0,0 +1,258 @@
using Health.Application.Reports;
using Health.Application.Notifications;
using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging;
using UglyToad.PdfPig;
namespace Health.Infrastructure.Reports;
public sealed class ReportAnalysisService(
IReportRepository reports,
VisionClient vision,
DeepSeekClient llm,
IUserNotificationProducer notifications,
ILogger<ReportAnalysisService> logger) : IReportAnalysisService
{
private readonly IReportRepository _reports = reports;
private readonly VisionClient _vision = vision;
private readonly DeepSeekClient _llm = llm;
private readonly IUserNotificationProducer _notifications = notifications;
private readonly ILogger<ReportAnalysisService> _logger = logger;
private const int MaxPdfChars = 8000;
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
{
var report = await _reports.GetByIdAsync(job.ReportId, ct);
if (report == null) return;
try
{
var vlmJson = await ExtractIndicatorsAsync(job.FilePath, ct);
var isReport = vlmJson.TryGetProperty("isReport", out var ir) && ir.GetBoolean();
var reason = vlmJson.TryGetProperty("reason", out var rsn) ? rsn.GetString() : null;
if (!isReport)
{
report.AiSummary = $"AI 暂时无法完成解读:{reason ?? ""}。请重新上传清晰的报告图片。";
report.AiIndicators = "[]";
report.Status = ReportStatus.AnalysisFailed;
await _reports.SaveChangesAsync(ct);
await NotifyAsync(report, job.TaskId, false, ct);
return;
}
var reportType = vlmJson.TryGetProperty("reportType", out var rt) ? rt.GetString() ?? "Other" : "Other";
var indicatorsJson = vlmJson.TryGetProperty("indicators", out var inds) ? inds.GetRawText() : "[]";
report.Category = Enum.TryParse<ReportCategory>(reportType, out var cat) ? cat : ReportCategory.Other;
report.AiIndicators = indicatorsJson;
report.AiSummary = await GenerateSummaryAsync(indicatorsJson, ct);
report.Status = ReportStatus.PendingDoctor;
await _reports.SaveChangesAsync(ct);
await NotifyAsync(report, job.TaskId, true, ct);
_logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// 进程停机导致的取消:不算失败,不消耗重试,交还给恢复机制
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId);
report.Status = ReportStatus.AnalysisFailed;
report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。";
report.AiIndicators = "[]";
await _reports.SaveChangesAsync(ct);
// 重新抛出,使 Worker 进入 RetryAsync指数退避重试超限后任务置 Failed
// 若为可恢复的瞬时故障,重试成功会把状态改回 PendingDoctor。
throw;
}
}
private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct)
{
if (Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase))
return await ExtractIndicatorsFromPdfAsync(filePath, ct);
var imageUrl = await GetLocalImageUrl(filePath, ct);
if (imageUrl == null)
throw new InvalidOperationException("报告图片文件不存在或无法读取");
var prompt = """
{"isReport": false, "reason": "这不是医学报告,原因:..."}
JSON格式markdown包裹
{
"isReport": true,
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
"indicators": [
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
]
}
- status =normal=high=low
-
""";
var response = await _vision.VisionAsync(
prompt,
[imageUrl],
userText: "请分析这份医学报告",
maxTokens: 2048,
ct: ct);
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
// 不记录 VLM 返回正文:可能包含检验指标等敏感医疗信息,生产日志只记录长度
_logger.LogInformation("报告 VLM 返回长度: {Length}", content.Length);
var json = TryParseJson(content);
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
}
private async Task<JsonElement> ExtractIndicatorsFromPdfAsync(string filePath, CancellationToken ct)
{
var text = ExtractPdfText(filePath);
if (string.IsNullOrWhiteSpace(text))
{
return JsonDocument.Parse("""
{"isReport": false, "reason": "这份 PDF 可能是扫描件或图片版,当前无法直接提取文字。请上传清晰图片,或后续启用 PDF 页转图片后再用 VLM 识别。"}
""").RootElement;
}
var prompt = $$"""
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。
PDF 文本:
{{text}}
第一步:判断这到底是不是一份医学检验报告。
如果不是,直接返回:
{"isReport": false, "reason": "..."}
JSON markdown
{
"isReport": true,
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
"indicators": [
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
]
}
-
- status =normal=high=low
-
""";
var messages = new List<ChatMessage>
{
new() { Role = "system", Content = "你是医学报告结构化抽取助手,只输出严格 JSON。" },
new() { Role = "user", Content = prompt }
};
var response = await _llm.ChatAsync(messages, maxTokens: 1800, temperature: 0.1f, ct: ct);
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
var json = TryParseJson(content);
return json ?? throw new InvalidOperationException("PDF 报告解析结果格式异常");
}
private static string ExtractPdfText(string filePath)
{
using var pdf = PdfDocument.Open(filePath);
var sb = new System.Text.StringBuilder();
foreach (var page in pdf.GetPages())
{
sb.AppendLine(page.Text);
if (sb.Length > MaxPdfChars) break;
}
var text = sb.ToString().Trim();
return text.Length > MaxPdfChars ? text[..MaxPdfChars] : text;
}
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
{
var prompt = $"""
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
检测指标:{indicatorsJson}
要求:
1. 总字数200-300字
2. 先总结整体情况
3. 指出异常指标及其可能原因,但不要给出确定诊断
4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议
5. 如指标明显异常或存在急症风险,优先建议及时就医
6. 末尾提醒"AI预解读"
JSON格式
""";
var messages = new List<ChatMessage>
{
new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" },
new() { Role = "user", Content = prompt }
};
var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct);
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
return !string.IsNullOrWhiteSpace(summary)
? summary
: throw new InvalidOperationException("AI 未返回有效解读内容");
}
private static async Task<string?> GetLocalImageUrl(string filePath, CancellationToken ct)
{
if (!File.Exists(filePath)) return null;
var bytes = await File.ReadAllBytesAsync(filePath, ct);
var base64 = Convert.ToBase64String(bytes);
var ext = Path.GetExtension(filePath).ToLowerInvariant().TrimStart('.');
var mime = ext switch
{
"png" => "image/png",
"jpg" or "jpeg" => "image/jpeg",
"webp" => "image/webp",
_ => "image/png"
};
return $"data:{mime};base64,{base64}";
}
private static JsonElement? TryParseJson(string? content)
{
if (string.IsNullOrWhiteSpace(content)) return null;
content = content.Trim();
if (content.StartsWith("```"))
{
var end = content.IndexOf('\n');
if (end > 0) content = content[(end + 1)..];
if (content.EndsWith("```"))
content = content[..^3];
}
content = content.Trim();
try { return JsonDocument.Parse(content).RootElement; }
catch (JsonException) { return null; }
}
private Task NotifyAsync(Report report, Guid sourceId, bool succeeded, CancellationToken ct) =>
_notifications.EnqueueAsync(
report.UserId,
sourceId,
new NotificationMessage(
succeeded ? "ReportAnalysisCompleted" : "ReportAnalysisFailed",
succeeded ? "报告分析完成" : "报告分析未完成",
succeeded ? "您的报告已完成 AI 预解读,点击查看分析结果。" : "这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
succeeded ? "success" : "warning",
"report",
report.Id.ToString()),
ct);
}

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

@@ -18,12 +18,14 @@ public sealed class JwtProvider(IConfiguration configuration)
/// <summary>
/// 生成 access_token30 分钟有效)
/// </summary>
public string GenerateAccessToken(Guid userId, string phone)
public string GenerateAccessToken(Guid userId, string phone, string role = "User")
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim(ClaimTypes.MobilePhone, phone),
new Claim(ClaimTypes.Role, role),
new Claim("Role", role),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};

View File

@@ -0,0 +1,53 @@
using Health.Application.Users;
namespace Health.Infrastructure.Users;
public sealed class EfUserRepository(AppDbContext db) : IUserRepository
{
private readonly AppDbContext _db = db;
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
public async Task DeleteAccountDataAsync(Guid userId, CancellationToken ct)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var profile = await _db.DoctorProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct);
if (profile?.DoctorId != null)
{
await _db.Users.Where(u => u.DoctorId == profile.DoctorId)
.ExecuteUpdateAsync(u => u.SetProperty(x => x.DoctorId, (Guid?)null), ct);
await _db.Doctors.Where(d => d.Id == profile.DoctorId).ExecuteDeleteAsync(ct);
}
await _db.HealthRecords.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
await _db.MedicationLogs.Where(l => l.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Medications.Where(m => m.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DietFoodItems.Where(i => i.DietRecord!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DietRecords.Where(d => d.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ExercisePlanItems.Where(i => i.Plan!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ExercisePlans.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ReportAnalysisTasks.Where(t => _db.Reports.Any(r => r.Id == t.ReportId && r.UserId == userId)).ExecuteDeleteAsync(ct);
await _db.Reports.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ConversationMessages.Where(m => m.Conversation!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Conversations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ConsultationMessages.Where(m => m.Consultation!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Consultations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await _db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct);
await _db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
await _db.UserNotifications.Where(n => n.UserId == userId).ExecuteDeleteAsync(ct);
await _db.NotificationOutbox.Where(n => n.UserId == userId).ExecuteDeleteAsync(ct);
await _db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct);
await _db.AiWriteCommands.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DoctorProfiles.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Users.Where(u => u.Id == userId).ExecuteDeleteAsync(ct);
await transaction.CommitAsync(ct);
}
}

View File

@@ -1,8 +1,7 @@
using Health.Application.Maintenance;
namespace Health.WebApi.BackgroundServices;
/// <summary>
/// 数据清理后台服务(每小时检查一次)
/// </summary>
public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<CleanupService> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
@@ -15,31 +14,20 @@ public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<Cl
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 清理 30 天前的对话记录
var cutoff = DateTime.UtcNow.AddDays(-30);
var oldConversations = await db.Conversations
.Where(c => c.CreatedAt < cutoff)
.ToListAsync(stoppingToken);
if (oldConversations.Count > 0)
var maintenance = scope.ServiceProvider.GetRequiredService<IMaintenanceService>();
var result = await maintenance.CleanupAsync(DateTime.UtcNow, stoppingToken);
if (result.Conversations + result.VerificationCodes + result.BackgroundTasks > 0)
{
db.Conversations.RemoveRange(oldConversations);
await db.SaveChangesAsync(stoppingToken);
_logger.LogInformation("清理 {Count} 条过期对话", oldConversations.Count);
_logger.LogInformation(
"自动清理完成: 会话 {Conversations}, 验证码 {Codes}, 后台任务 {Tasks}",
result.Conversations,
result.VerificationCodes,
result.BackgroundTasks);
}
// 清理过期验证码
var expiredCodes = await db.VerificationCodes
.Where(v => v.ExpiresAt < DateTime.UtcNow)
.ToListAsync(stoppingToken);
if (expiredCodes.Count > 0)
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
db.VerificationCodes.RemoveRange(expiredCodes);
await db.SaveChangesAsync(stoppingToken);
}
break;
}
catch (Exception ex)
{

View File

@@ -0,0 +1,74 @@
using Health.Application.Diets;
namespace Health.WebApi.BackgroundServices;
public sealed class DietImageAnalysisWorker(
IServiceScopeFactory scopeFactory,
ILogger<DietImageAnalysisWorker> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<DietImageAnalysisWorker> _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("饮食图片识别持久化任务消费者已启动");
var idleDelay = TimeSpan.FromSeconds(1);
var nextRecovery = DateTime.MinValue;
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var queue = scope.ServiceProvider.GetRequiredService<IDietImageAnalysisQueue>();
if (DateTime.UtcNow >= nextRecovery)
{
await queue.RecoverStaleAsync(stoppingToken);
nextRecovery = DateTime.UtcNow.AddMinutes(1);
}
var job = await queue.TryTakeAsync(stoppingToken);
if (job == null)
{
await Task.Delay(idleDelay, stoppingToken);
idleDelay = TimeSpan.FromSeconds(Math.Min(idleDelay.TotalSeconds * 2, 5));
continue;
}
idleDelay = TimeSpan.FromSeconds(1);
try
{
var analyzer = scope.ServiceProvider.GetRequiredService<IDietImageAnalyzer>();
var result = await analyzer.AnalyzeAsync(job, stoppingToken);
await queue.CompleteAsync(job.Id, result, stoppingToken);
DeleteFiles(job.FilePaths);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "饮食图片识别任务失败: {JobId}", job.Id);
var terminal = await queue.RetryAsync(job.Id, ex.Message, stoppingToken);
if (terminal) DeleteFiles(job.FilePaths);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "饮食图片识别消费者循环异常");
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
private static void DeleteFiles(IEnumerable<string> paths)
{
foreach (var path in paths)
{
try { if (File.Exists(path)) File.Delete(path); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}
}

View File

@@ -0,0 +1,32 @@
using Health.Application.Exercises;
namespace Health.WebApi.BackgroundServices;
public sealed class ExerciseReminderService(IServiceScopeFactory scopeFactory, ILogger<ExerciseReminderService> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<ExerciseReminderService> _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var producer = scope.ServiceProvider.GetRequiredService<IExerciseReminderProducer>();
var count = await producer.ProduceDueAsync(DateTime.UtcNow, stoppingToken);
if (count > 0) _logger.LogInformation("已生成 {Count} 条 App 内运动提醒", count);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "生成运动提醒失败");
}
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}

View File

@@ -0,0 +1,107 @@
using Health.Application.Notifications;
using Health.Domain.Enums;
namespace Health.WebApi.BackgroundServices;
/// <summary>
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
/// 对未录入指标且开关开启的用户推送提醒。
/// 中国时区 UTC+8。
/// </summary>
public sealed class HealthRecordReminderService(
IServiceScopeFactory scopeFactory,
ILogger<HealthRecordReminderService> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<HealthRecordReminderService> _logger = logger;
// 北京时间 9:00 和 12:00 触发
private static readonly int[] ReminderHoursCst = [9, 12];
private DateTime _lastFired = DateTime.MinValue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("健康录入提醒服务已启动");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var nowCst = DateTime.UtcNow.AddHours(8);
// 当前小时是否在触发列表,且今天该小时还没触发过
if (ReminderHoursCst.Contains(nowCst.Hour)
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
{
await ScanAndPushAsync(nowCst, stoppingToken);
_lastFired = nowCst;
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
var todayEnd = todayStart.AddDays(1);
// 取所有启用了健康录入提醒的用户偏好
var prefs = await db.NotificationPreferences
.Where(p => p.HealthRecordReminder)
.ToListAsync(ct);
foreach (var pref in prefs)
{
// 该用户当天已录入的指标
var recordedTypes = await db.HealthRecords
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
.Select(r => r.MetricType)
.Distinct()
.ToListAsync(ct);
// 哪些指标启用开关但当天没录入
var missing = new List<(HealthMetricType type, bool enabled, string label)>
{
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
};
var missingLabels = missing
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
.Select(m => m.label)
.ToList();
if (missingLabels.Count == 0) continue;
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
var sourceId = MakeSourceId(pref.UserId, nowCst);
var summary = string.Join("、", missingLabels);
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
Type: "health_record_reminder",
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
Message: $"建议录入:{summary}",
Severity: "info",
ActionType: "health",
ActionTargetId: null), ct);
}
}
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);
}
}

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