Compare commits

10 Commits

Author SHA1 Message Date
MingNian
3b5cec10a6 feat: AI 提示词模块化 + AI 同意门控 + AI 草稿存储 + 意图路由 + 医疗引用知识库 + 多页面 UI 优化
- AI 提示词拆分为 markdown 模块(Prompts/global + modules + rag + router)
- 新增 AI 同意门控(用户需同意后才能使用 AI 功能)
- 新增 AI 录入草稿存储(AiEntryDraftContracts/EfAiEntryDraftStore/AiEntryDraftRecord)
- 新增 AI 意图路由(ai_intent_router)
- 新增医疗引用知识库(MedicalCitationKnowledge)
- 重构 prompt_manager 和 ai_chat_endpoints
- 优化聊天/趋势/档案/抽屉/医生端等多个页面 UI
- 新增 agent 插画和趋势指标图标资源
- 删除 HANDOFF-2026-07-17.md
2026-07-27 16:53:20 +08:00
sccsbc
ad93e38b7e fix: 移除 BLE 蓝牙依赖,消除 CoreLocation API 引用
- 注释 flutter_blue_plus 和 permission_handler 依赖
- 删除蓝牙设备页、BLE service、omron provider 等 6 个源文件
- 移除路由和设备设置入口
- 清理 2 个 BLE 相关测试文件
- 解决 Transporter 报 NSLocationWhenInUseUsageDescription 缺失

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 17:19:39 +08:00
MingNian
39c32f842b feat: 去除"问诊"字眼 + "药管家"改名 + 删除AI对话欢迎卡片紫色框
- "AI问诊" -> "AI对话"(胶囊名、首页标签、页面标题、提示词)
- "药管家" -> "药提醒"(胶囊名、枚举注释、处理器注释)
- AI对话欢迎卡片删除紫色提示框(与底部提示重复)
- 底部提示文字去掉"观察或就医建议",改为"帮您记录和整理症状信息"
2026-07-22 17:08:16 +08:00
MingNian
5cd3584ae9 feat: iOS 审核阉割版 - iOS 蓝牙功能屏蔽 + Info.plist 删蓝牙权限 + AI 提示词去医疗化(应对 App Store 1.4.1)
- iOS 蓝牙 UI 入口用 Platform.isIOS 屏蔽(抽屉/设置页/记数据卡片/AI 嵌入链接)
- Info.plist 删除 NSBluetoothAlwaysUsageDescription 和 NSBluetoothPeripheralUsageDescription
- AI 提示词重写:只描述客观事实和综合信息,不给医疗建议,不做关联性分析/推测
- 所有"专家/教练/预问诊/预解读"角色改为"记录/整理/结构化提取助手"
- 去掉基于疾病(冠心病/糖尿病等)的饮食运动建议
- 保留正常值参考范围用于客观描述"超出范围",不作诊断
2026-07-21 11:57:54 +08:00
MingNian
28f704c98e feat: 长辈模式(大字大按钮 + 偏好按账号隔离)+ 语音输入(按住说话 + 实时转写 + 后端代理 DashScope ASR + 患者端专用)+ 健康仪表盘背景渐变 2026-07-21 10:53:16 +08:00
MingNian
0cb5b8e85a feat: AI Agent 处理器扩展 + 服药打卡确认链路 + AI 气泡配色微调 + 数据刷新 providers 抽离 + 多端页面细节调整 2026-07-20 17:12:00 +08:00
MingNian
9cea41705e feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固
- 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越
- LocalReportFileStorage: 文件存储路径安全加固
- local_account_file_cleanup: 账号删除时文件清理逻辑增强
- AuthService: 认证逻辑增强
- file_endpoints / report_endpoints: 文件访问接口安全加固
- ai_chat_endpoints / doctor_endpoints: 接口安全调整
- Program.cs: 服务注册调整

## 前端认证与媒体
- 新增 authenticated_network_image.dart: 带认证的图片加载组件
- auth_provider: 认证状态管理大幅增强(+173)
- api_client: 网络客户端增强(+124)
- chat_provider: 聊天 provider 重构(+76)
- omron_device_provider: 蓝牙设备 provider 增强(+53)
- sse_handler: SSE 处理增强(+35)
- consultation_provider / data_providers / conversation_history_provider: 调整

## 页面调整
- remaining_pages: 健康档案/饮食记录等页面增强(+115)
- home_page / chat_messages_view: 主页微调
- doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports)
- report_pages / settings_pages / notification_prefs_page: 微调
- device_scan_page / diet_capture_page / admin_home_page: 微调

## 测试
- 新增 file_path_security_tests: 文件路径安全测试
- 新增 protected_media_url_test: 媒体URL保护测试
- 新增 user_session_identity_test: 用户会话身份测试
- account_deletion_tests / application_service_tests / auth_tests: 更新
2026-07-20 10:19:01 +08:00
MingNian
0d4fd88ce7 feat: 三端抽屉重构 + 配色系统更新 + 后台管理页精调 + 键盘抬起组件
## 抽屉重构
- admin_drawer / doctor_drawer / health_drawer 三端抽屉全部重做
- drawer_shell 增强

## 配色系统
- app_colors / app_module_visuals / app_theme 更新
- app.dart 启动流程调整

## 后台管理页
- admin_doctors_page 大幅重构(+251)
- admin_home / doctor_dashboard / doctor_reports / doctor_home / doctor_followups / doctor_settings 精调

## 患者端
- home_page / chat_messages_view / medication_list / notification_center / remaining_pages / exercise_plan / device / diet / consultation 微调

## 新增
- keyboard_lift.dart: 键盘抬起处理组件
- AGENTS.md: agent 指引文档

## 其他
- api_client IP 适配
- app_future_view 增强
- data_providers 调整
- secondary_page_visuals_test 更新
2026-07-19 19:11:30 +08:00
MingNian
ae94ced2d5 feat: 后台管理页全量重构 + backoffice 共享模块 + 启动脚本优化
## 后台管理页重构
- admin 端: add_doctor / doctors / home / patients 四页重构
- doctor 端: consultations / dashboard / followup_edit / followups / home / patient_detail / patients / profile / report_detail / reports / settings 十一页重构
- 新增 backoffice 共享模块: backoffice_refresh_providers + backoffice_formatters + backoffice_ui

## 后端
- AdminService 增强(分页/搜索/统计)
- doctor_endpoints 微调
- appsettings.Development 调整
- 新增 admin_service_tests

## 前端其他
- 今日健康卡片 taskRow 行高 5->7(每行 44->48px)
- 健康仪表盘配色定 #4FACFE 纯色蓝
- admin_drawer / doctor_drawer 微调
- api_client IP 适配

## 启动脚本
- start-dev.bat: 加后端就绪检测(轮询 openapi 端点, 最多等 30s)

## 清理
- 删除旧品牌图(agent_welcome_abstract / login_background_v1)
- 删除 app_status_badge 组件
- 删除旧 HANDOFF 文档, 新增 07-17 版

## 测试
- 新增 backoffice_formatters / backoffice_refresh / backoffice_ui 三个测试
- app_router_test 扩展
2026-07-18 17:48:44 +08:00
MingNian
e1f4a4b91f docs: define doctor admin UI unification 2026-07-17 21:54:00 +08:00
188 changed files with 11386 additions and 5537 deletions

1
.gitignore vendored
View File

@@ -46,6 +46,7 @@ backend/src/Health.WebApi/data-protection-keys/
*.txt
*.jpg
*.png
!backend/src/Health.WebApi/wwwroot/app-icon.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

57
AGENTS.md Normal file
View File

@@ -0,0 +1,57 @@
# 项目协作准则
本文件适用于整个 `health_project` 仓库。处理任务时按改动风险分级,验证强度与风险匹配,避免小改动套用重型流程。
## 默认规则
- 默认不执行 `git add``git commit``git push`、合并、回滚或创建分支;只有用户明确要求时才进行。
- 保留工作区中用户已有的未提交改动,不覆盖、不清理、不顺带重构。
- 数据库、上传文件、密钥、生产配置和用户数据不视为普通文件;任何写入、迁移、删除或恢复操作必须先获得用户明确确认。
- 只修改用户指定的端和功能范围。发现相邻问题可以说明,但不要擅自扩大修改范围。
## 小改动:快速处理
适用于颜色、字号、间距、圆角、文案、图标、单个组件简单布局等不涉及状态、接口和数据的调整。
1. 确认具体文件和修改范围。
2. 直接完成修改。
3. 执行格式化。
4. 进行一次快速编译或静态检查,二者按实际需要选择。
5. 简要说明改动结果。
不运行完整测试套件,不额外编写计划文档,不提交 Git。
## 中等改动:针对性验证
适用于页面交互、Provider、路由、表单逻辑、接口调用、跨端数据展示、启动脚本和配置修改。
1. 检查相关代码和现有改动。
2. 修改前简要说明方案。
3. 完成代码修改和格式化。
4. 运行静态检查或编译。
5. 只运行与本次改动直接相关的测试。
6. 必要时启动对应页面或接口验证一次。
默认不提交 Git。
## 大改动:完整验证
适用于完整新功能、多页面联动、登录、聊天、蓝牙、AI 核心流程、后端结构调整和大范围重构。
1. 阅读相关代码、文档和交接资料。
2. 与用户确认范围、交互和成功标准。
3. 分步骤实施,并在关键阶段做针对性检查。
4. 完成后运行 Flutter 和后端相关完整测试。
5. 汇总改动、验证结果和遗留问题。
## 高风险改动:确认后执行
适用于数据库迁移或数据修复、大量删除、用户数据、上传文件、签名、生产配置、部署,以及 Git 提交、推送、合并和回滚。
1. 先进行只读检查,确认精确目标和影响。
2. 向用户说明风险、影响范围和恢复方式。
3. 必要时先制作可验证的备份。
4. 获得用户明确确认后再执行。
5. 执行后进行完整验证并报告结果。
用户在具体任务中的直接指令始终优先于本文件。

File diff suppressed because one or more lines are too long

View File

@@ -16,6 +16,14 @@ QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
QWEN_API_KEY=sk-your-key-here
QWEN_VISION_MODEL=qwen-vl-max
# 阿里云百炼实时语音输入Fun-ASR
DASHSCOPE_ASR_API_KEY=sk-your-dashscope-key-here
DASHSCOPE_ASR_WORKSPACE_ID=your-workspace-id
DASHSCOPE_ASR_MODEL=fun-asr-realtime
DASHSCOPE_ASR_MAX_DURATION_SECONDS=60
# 可选;不填时按 Workspace ID 自动生成北京地域专属地址
# DASHSCOPE_ASR_WEBSOCKET_URL=wss://your-workspace-id.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference
# MinIO
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin

View File

@@ -0,0 +1,24 @@
namespace Health.Application.AI;
public sealed record AiEntryDraft(
Guid Id,
Guid UserId,
Guid ConversationId,
string EntryType,
string Payload,
string MissingFields,
DateTime ExpiresAt);
public interface IAiEntryDraftStore
{
Task<AiEntryDraft?> GetActiveAsync(Guid userId, Guid conversationId, string entryType, CancellationToken ct);
Task<AiEntryDraft> UpsertAsync(
Guid userId,
Guid conversationId,
string entryType,
string payload,
string missingFields,
TimeSpan lifetime,
CancellationToken ct);
Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct);
}

View File

@@ -16,5 +16,5 @@ public interface IAttachmentContextBuilder
/// <summary>
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
/// </summary>
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct);
}

View File

@@ -10,10 +10,11 @@ public static class DietCommentaryPolicy
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
public const string SystemPrompt = """
App 23
1222使 Markdown
App 23
1222使 Markdown
5
使 S-DIET-01 S-DIET-02
""";
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>

View File

@@ -0,0 +1,91 @@
namespace Health.Application.AI;
/// <summary>
/// iOS 审核版内置医学来源目录。
/// 这是固定提示词资料,不依赖在线 RAG 检索。
/// </summary>
public static class MedicalCitationKnowledge
{
public const string Prompt = """
PMIDDOI URL
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
https://pubmed.ncbi.nlm.nih.gov/39210715/
[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes2025
https://pubmed.ncbi.nlm.nih.gov/39651981/
[S-HR-01] All About Heart Rate, American Heart Association
https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse
[S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration
https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
https://pubmed.ncbi.nlm.nih.gov/37128940/
[S-DIET-02] Diet and nutrition in cardiovascular disease prevention
https://pubmed.ncbi.nlm.nih.gov/40504596/
[S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
https://pubmed.ncbi.nlm.nih.gov/38059362/
[S-EX-02] Core Components of Cardiac Rehabilitation Programs: 2024 Update
https://pubmed.ncbi.nlm.nih.gov/39315436/
[S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
https://pubmed.ncbi.nlm.nih.gov/39480983/
[S-REPORT-01] Interpreting Normal Values and Reference Ranges for Laboratory Tests
https://pubmed.ncbi.nlm.nih.gov/40268322/
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
使
https://pubmed.ncbi.nlm.nih.gov/29729142/
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
AI
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
使
-
- /尿
-
- /
-
-
-
- 使/
- AI
""";
public const string FixedDietCitation = """
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
https://pubmed.ncbi.nlm.nih.gov/37128940/
""";
public const string FixedReportCitation = """
[S-REPORT-01] Interpreting Normal Values and Reference Ranges for Laboratory Tests
https://pubmed.ncbi.nlm.nih.gov/40268322/
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
https://pubmed.ncbi.nlm.nih.gov/29729142/
""";
public const string EmergencyCitation = """
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
""";
}

View File

@@ -1,24 +1,20 @@
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
IHealthRecordService healthRecords) : 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)
@@ -36,23 +32,11 @@ public sealed class PatientContextService(
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})");
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({ToBeijing(record.RecordedAt):MM-dd HH:mm} 北京时间)");
}
return sb.ToString();
@@ -67,4 +51,15 @@ public sealed class PatientContextService(
"Weight" => $"{record.Value}kg",
_ => "—"
};
private static DateTime ToBeijing(DateTime value)
{
var utc = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
};
return utc.AddHours(8);
}
}

View File

@@ -40,7 +40,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
.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)
.Where(f => BeijingDate(f.ScheduledAt) == date)
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
.ToList();
@@ -67,7 +67,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
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))
foreach (var followUp in snapshot.FollowUps.Where(f => BeijingDate(f.ScheduledAt) == date))
{
entries.Add(new CalendarEntry("followup", new
{
@@ -88,5 +88,14 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
&& (medication.StartDate == null || medication.StartDate <= date)
&& (medication.EndDate == null || medication.EndDate >= date);
private static DateOnly BeijingDate(DateTime value)
{
// EF 从 timestamptz 读取的是 UTC单元测试及旧内存对象可能是 Unspecified。
var beijing = value.Kind == DateTimeKind.Unspecified
? value
: value.ToUniversalTime().AddHours(8);
return DateOnly.FromDateTime(beijing);
}
private sealed record CalendarEntry(string Type, object Value);
}

View File

@@ -42,19 +42,19 @@ 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<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(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<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, 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);

View File

@@ -33,8 +33,11 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType,
DurationMinutes = duration, IsRestDay = false,
Id = Guid.NewGuid(),
ScheduledDate = date,
ExerciseType = exerciseType,
DurationMinutes = duration,
IsRestDay = false,
});
}
await SaveNewAsync(plan, ct);
@@ -52,10 +55,13 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
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();
return plans.Select(ToSummaryDto).ToList();
}
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct)
{
var plans = await _exercises.ListAllAsync(userId, ct);
return plans.Select(ToSummaryDto).ToList();
}
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
@@ -103,12 +109,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
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);
@@ -117,15 +117,24 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
{
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate,
Id = Guid.NewGuid(),
UserId = userId,
StartDate = startDate,
EndDate = endDate,
ReminderTime = reminderTime ?? DefaultReminderTime,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
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 ExercisePlanSummaryDto ToSummaryDto(ExercisePlan plan) => new(
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt,
plan.Items.Count, plan.Items.Count(i => i.IsCompleted),
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);

View File

@@ -188,6 +188,16 @@ public sealed class HealthRecordService(
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
};
var citation = record.MetricType switch
{
HealthMetricType.BloodPressure => "参考来源:\n[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension\nhttps://pubmed.ncbi.nlm.nih.gov/39210715/",
HealthMetricType.Glucose => "参考来源:\n[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes—2025\nhttps://pubmed.ncbi.nlm.nih.gov/39651981/",
HealthMetricType.HeartRate => "参考来源:\n[S-HR-01] All About Heart Rate, American Heart Association\nhttps://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse",
HealthMetricType.SpO2 => "参考来源:\n[S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration\nhttps://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics",
_ => string.Empty,
};
if (!string.IsNullOrWhiteSpace(citation)) message = $"{message}\n\n{citation}";
var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
await _notifications.EnqueueAsync(

View File

@@ -45,6 +45,21 @@ public sealed record MedicationReminderDto(
string ScheduledTime,
string Status);
public sealed record AiMedicationDoseDto(
string ScheduledTime,
string Status);
public sealed record AiMedicationPlanDto(
Guid Id,
string Name,
string? Dosage,
string Frequency,
DateOnly? StartDate,
DateOnly? EndDate,
string Phase,
bool ScheduledOnDate,
IReadOnlyList<AiMedicationDoseDto> Doses);
public sealed record MedicationReminderTask(
Guid TaskId,
Guid UserId,
@@ -60,12 +75,10 @@ public interface IMedicationService
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);
Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(Guid userId, DateOnly date, CancellationToken ct);
}
public interface IMedicationRepository
@@ -74,8 +87,6 @@ public interface IMedicationRepository
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);

View File

@@ -96,23 +96,6 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
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);
@@ -147,7 +130,14 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
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 medication = await _medications.GetOwnedAsync(userId, medicationId, ct);
if (medication == null) return null;
var today = BeijingToday();
if (!IsActiveOn(medication, today) ||
!GetDosesForToday(medication, today) ||
!medication.TimeOfDay.Contains(scheduledTime))
return false;
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
@@ -168,18 +158,48 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
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)
public async Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(
Guid userId,
DateOnly date,
CancellationToken ct)
{
var result = await ConfirmDoseAsync(
userId,
medicationId,
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
MedicationLogStatus.Taken,
ct);
return result == true;
var medications = await _medications.ListAsync(userId, null, ct);
var (startUtc, endUtc) = GetBeijingDateUtcWindow(date);
var logs = await _medications.ListLogsInWindowAsync(userId, startUtc, endUtc, ct);
var today = BeijingToday();
var now = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
return medications.Select(medication =>
{
var phase = ResolvePhase(medication, date);
var scheduled = phase == "active" && GetDosesForToday(medication, date);
var doses = scheduled
? medication.TimeOfDay.OrderBy(time => time).Select(time =>
{
var log = logs.FirstOrDefault(item =>
item.MedicationId == medication.Id && item.ScheduledTime == time);
var status = log != null
? log.Status.ToString().ToLowerInvariant()
: date < today
? "missed"
: date > today
? "scheduled"
: MedicationScheduleStatus.Resolve(time, now, null);
return new AiMedicationDoseDto(time.ToString("HH:mm"), status);
}).ToList()
: [];
return new AiMedicationPlanDto(
medication.Id,
medication.Name,
medication.Dosage,
medication.Frequency.ToString(),
medication.StartDate,
medication.EndDate,
phase,
scheduled,
doses);
}).ToList();
}
private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct)
@@ -217,9 +237,28 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
{
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
var todayStartUtc = beijingToday.AddHours(-8);
return (todayStartUtc, todayStartUtc.AddDays(1));
return GetBeijingDateUtcWindow(BeijingToday());
}
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingDateUtcWindow(DateOnly date)
{
var startUtc = DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue).AddHours(-8), DateTimeKind.Utc);
return (startUtc, startUtc.AddDays(1));
}
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
private static bool IsActiveOn(Medication medication, DateOnly date) =>
medication.IsActive &&
(!medication.StartDate.HasValue || medication.StartDate.Value <= date) &&
(!medication.EndDate.HasValue || medication.EndDate.Value >= date);
private static string ResolvePhase(Medication medication, DateOnly date)
{
if (!medication.IsActive) return "inactive";
if (medication.StartDate.HasValue && medication.StartDate.Value > date) return "upcoming";
if (medication.EndDate.HasValue && medication.EndDate.Value < date) return "ended";
return "active";
}
private static bool GetDosesForToday(Medication med, DateOnly today)

View File

@@ -94,7 +94,7 @@ public sealed class ReportService(
public static ReportDto ToDto(Report report) => new(
report.Id,
report.UserId,
report.FileUrl,
$"/api/reports/{report.Id}/file",
report.FileType.ToString(),
report.Category.ToString(),
report.Status.ToString(),

View File

@@ -0,0 +1,10 @@
using System.Net.WebSockets;
namespace Health.Application.Speech;
public interface IRealtimeSpeechRecognitionProxy
{
bool IsConfigured { get; }
Task ProxyAsync(WebSocket clientSocket, CancellationToken cancellationToken);
}

View File

@@ -14,7 +14,8 @@ public sealed record UserProfileDto(
public sealed record UserProfileUpdateRequest(
string? Name,
string? Gender,
DateOnly? BirthDate);
DateOnly? BirthDate,
string? AvatarUrl);
public sealed record AccountFileReferences(
IReadOnlyList<string> FileUrls,

View File

@@ -28,6 +28,7 @@ public sealed class UserService(
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;
if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl;
user.UpdatedAt = DateTime.UtcNow;
await _users.SaveChangesAsync(ct);
return true;

View File

@@ -136,10 +136,10 @@ public enum FollowUpStatus
public enum AgentType
{
Default, // 默认对话
Consultation, // AI 问诊
Consultation, // AI 对话
Health, // 记数据
Diet, // 拍饮食
Medication, // 药管家
Medication, // 药提醒
Report, // 看报告
Exercise, // 运动计划
Unified // 统一入口(自动路由)

View File

@@ -0,0 +1,49 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Health.Infrastructure.AI.AgentHandlers;
internal static partial class AiDateTime
{
public static DateTime BeijingNow => DateTime.UtcNow.AddHours(8);
public static DateOnly BeijingToday => DateOnly.FromDateTime(BeijingNow);
public static (DateTime StartUtc, DateTime EndUtc) BeijingDateWindowUtc(DateOnly date)
{
var startUtc = DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue).AddHours(-8), DateTimeKind.Utc);
return (startUtc, startUtc.AddDays(1));
}
public static DateTime ToUtc(DateTime value) => value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
};
public static string ToBeijingText(DateTime value) =>
ToUtc(value).AddHours(8).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
public static bool TryParseUserDateTime(string? value, out DateTime utc)
{
utc = default;
if (string.IsNullOrWhiteSpace(value)) return false;
if ((value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || OffsetSuffix().IsMatch(value)) &&
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var offsetValue))
{
utc = offsetValue.UtcDateTime;
return true;
}
if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var beijingValue))
return false;
utc = DateTime.SpecifyKind(beijingValue, DateTimeKind.Unspecified).AddHours(-8);
utc = DateTime.SpecifyKind(utc, DateTimeKind.Utc);
return true;
}
[GeneratedRegex(@"[+-]\d{2}:?\d{2}$")]
private static partial Regex OffsetSuffix();
}

View File

@@ -8,12 +8,47 @@ namespace Health.Infrastructure.AI.AgentHandlers;
/// </summary>
public static class CommonAgentHandler
{
public static readonly ToolDefinition SearchMedicalKnowledgeTool = new()
{
Function = new()
{
Name = "search_medical_knowledge",
Description = "按需检索医学知识库。仅用于症状分析、健康建议、疾病/药品/检查指标解释等需要医学资料支撑的回答;健康数据录入、用药或运动计划创建/查询/打卡、普通寒暄不得调用。",
Parameters = new
{
type = "object",
properties = new
{
query = new
{
type = "string",
description = "需要检索的医学问题,保留关键症状、疾病、药品或指标名称",
},
},
required = new[] { "query" },
},
},
};
public static readonly ToolDefinition QueryHealthRecordsTool = new()
{
Function = new()
{
Name = "query_health_records", Description = "查询近期健康数据",
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } }
Name = "query_health_records",
Description = "查询近期健康数据",
Parameters = new
{
type = "object",
properties = new
{
type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight", "all" }, description = "健康指标类型" },
scope = new { type = "string", @enum = new[] { "today", "yesterday", "date", "recent_days", "range" }, description = "日期范围,默认 recent_days" },
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
start_date = new { type = "string", description = "scope=range 时的北京时间开始日期 yyyy-MM-dd" },
end_date = new { type = "string", description = "scope=range 时的北京时间结束日期 yyyy-MM-dd包含" },
days = new { type = "integer", description = "scope=recent_days 时的天数1到365" },
}
}
}
};
@@ -42,10 +77,67 @@ public static class CommonAgentHandler
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 records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
return new { count = records.Count, records };
var rawType = args.TryGetProperty("type", out var t) ? t.GetString() : null;
if (!TryNormalizeMetricType(rawType, out var metricType))
return new { success = false, message = $"无法识别健康指标类型: {rawType}" };
var scope = args.TryGetProperty("scope", out var scopeValue)
? scopeValue.GetString()?.ToLowerInvariant()
: "recent_days";
DateTime? startUtc = null;
DateTime? endUtc = null;
int days;
switch (scope)
{
case "today":
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
days = 2;
break;
case "yesterday":
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday.AddDays(-1));
days = 3;
break;
case "date":
if (!TryReadDate(args, "date", out var date))
return new { success = false, message = "查询指定日期时必须提供有效的 date" };
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
days = DaysSince(startUtc.Value);
break;
case "range":
if (!TryReadDate(args, "start_date", out var startDate) ||
!TryReadDate(args, "end_date", out var endDate) || endDate < startDate)
return new { success = false, message = "必须提供有效的 start_date 和 end_date且结束日期不能早于开始日期" };
startUtc = AiDateTime.BeijingDateWindowUtc(startDate).StartUtc;
endUtc = AiDateTime.BeijingDateWindowUtc(endDate).EndUtc;
days = DaysSince(startUtc.Value);
break;
default:
days = args.TryGetProperty("days", out var d) && d.TryGetInt32(out var parsedDays)
? Math.Clamp(parsedDays, 1, 365)
: 7;
scope = "recent_days";
break;
}
var records = await healthRecords.GetRecordsAsync(userId, metricType, days, ct);
var filtered = records
.Where(record => !startUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) >= startUtc.Value)
.Where(record => !endUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) < endUtc.Value)
.Select(record => new
{
record.Id,
record.Type,
record.Systolic,
record.Diastolic,
record.Value,
record.Unit,
record.Source,
record.IsAbnormal,
recorded_at_beijing = AiDateTime.ToBeijingText(record.RecordedAt),
})
.ToList();
return new { success = true, scope, count = filtered.Count, records = filtered };
}
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
@@ -54,55 +146,39 @@ public static class CommonAgentHandler
if (archive == null) return new { found = false };
return new
{
found = true, archive.Diagnosis, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries,
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
found = true,
archive.Diagnosis,
archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Surgeries,
archive.Allergies,
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
};
}
public static readonly ToolDefinition ManageArchiveTool = new()
private static bool TryNormalizeMetricType(string? value, out string? normalized)
{
Function = new()
normalized = value?.Trim().ToLowerInvariant() switch
{
Name = "manage_archive", Description = "管理用户健康档案",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" },
diagnosis = new { type = "string" },
surgery_type = new { type = "string" },
surgery_date = new { type = "string" },
allergies = new { type = "array", items = new { type = "string" } },
chronic_diseases = new { type = "array", items = new { type = "string" } },
diet_restrictions = new { type = "array", items = new { type = "string" } },
},
required = new[] { "action" }
}
}
null or "" or "all" or "全部" => null,
"bloodpressure" or "blood_pressure" or "血压" => "BloodPressure",
"heartrate" or "heart_rate" or "心率" or "脉搏" => "HeartRate",
"glucose" or "blood_glucose" or "血糖" => "Glucose",
"spo2" or "blood_oxygen" or "血氧" or "血氧饱和度" => "SpO2",
"weight" or "体重" => "Weight",
_ => "__invalid__",
};
return normalized != "__invalid__";
}
public static Task<object> ExecuteManageArchive(
IHealthArchiveService archives,
Guid userId,
JsonElement args,
CancellationToken ct = default)
private static bool TryReadDate(JsonElement args, string name, out DateOnly date)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
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);
date = default;
return args.TryGetProperty(name, out var value) && DateOnly.TryParse(value.GetString(), out date);
}
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;
private static int DaysSince(DateTime startUtc) =>
Math.Clamp((int)Math.Ceiling((DateTime.UtcNow - startUtc).TotalDays) + 1, 1, 3650);
}

View File

@@ -15,7 +15,9 @@ public static class ExerciseAgentHandler
type = "object",
properties = new
{
action = new { type = "string", description = "create/query/checkin" },
action = new { type = "string", @enum = new[] { "create", "query", "checkin" }, description = "create/query/checkin" },
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "all_plans" }, description = "查询时必填:今日任务/指定日期任务/当前计划/即将开始/已结束/全部计划" },
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd默认今天" },
duration_days = new { type = "integer", description = "连续运动天数如一周为7天" },
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
@@ -30,20 +32,50 @@ public static class ExerciseAgentHandler
public static List<ToolDefinition> Tools => [ManageExerciseTool];
public static string? ValidateWriteArguments(JsonElement args)
{
var action = args.TryGetProperty("action", out var actionValue)
? actionValue.GetString()?.ToLowerInvariant()
: null;
if (action != "create") return null;
if (!args.TryGetProperty("exercise_type", out var typeValue) ||
string.IsNullOrWhiteSpace(typeValue.GetString()))
return "创建运动计划前需要确认运动项目";
if (!args.TryGetProperty("duration_minutes", out var minutesValue) ||
!minutesValue.TryGetInt32(out var minutes) || minutes is <= 0 or > 300)
return "创建运动计划前需要确认合理的单次运动时长";
if (!args.TryGetProperty("duration_days", out var daysValue) ||
!daysValue.TryGetInt32(out var days) || days is <= 0 or > 366)
return "创建运动计划前需要确认计划持续天数";
if (!args.TryGetProperty("start_date", out var startValue) ||
!DateOnly.TryParse(startValue.GetString(), out _))
return "创建运动计划前需要确认开始日期";
if (!args.TryGetProperty("reminder_time", out var reminderValue) ||
!TimeOnly.TryParse(reminderValue.GetString(), out _))
return "创建运动计划前需要确认具体提醒时间";
return null;
}
public static async Task<object> Execute(
string toolName,
JsonElement args,
AppDbContext db,
Guid userId,
IExerciseService? exercises = null,
IExerciseService exercises,
CancellationToken ct = default)
{
if (toolName != "manage_exercise" || exercises == null)
if (toolName != "manage_exercise")
return new { success = false, message = $"未知工具: {toolName}" };
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
var action = args.TryGetProperty("action", out var actionValue)
? actionValue.GetString()?.ToLowerInvariant()
: null;
if (action == "create")
{
var validationError = ValidateWriteArguments(args);
if (validationError != null)
return new { success = false, message = validationError };
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
? parsedStart
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
@@ -67,17 +99,111 @@ public static class ExerciseAgentHandler
return success ? new { success = true } : new { success = false, message = "条目不存在" };
}
var plan = await exercises.GetLatestAsync(userId, ct);
return plan == null
? new { found = false }
: new
if (action != "query")
return new { success = false, message = $"未知操作: {action}" };
var scope = args.TryGetProperty("scope", out var scopeValue)
? scopeValue.GetString()?.ToLowerInvariant()
: null;
if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "all_plans"))
return new
{
success = false,
message = "查询运动计划时必须明确 scopetoday、scheduled_date、current_plans、upcoming_plans、ended_plans 或 all_plans",
};
if (scope == "scheduled_date" &&
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
return new { success = false, message = "查询指定日期的运动安排时必须提供有效的 date" };
var queryDate = scope == "scheduled_date"
? DateOnly.Parse(args.GetProperty("date").GetString()!)
: AiDateTime.BeijingToday;
var isDateScope = scope is "today" or "scheduled_date";
var plans = await exercises.ListAllAsync(userId, ct);
var overview = plans.Select(plan => new
{
found = true,
plan_id = plan.Id,
plan.StartDate,
plan.EndDate,
phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active",
plan.ReminderTime,
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
total_items = plan.Items.Count,
completed_items = plan.Items.Count(item => item.IsCompleted),
returned_items = isDateScope
? plan.Items.Count(item => item.ScheduledDate == queryDate)
: Math.Min(plan.Items.Count, 14),
items_truncated = !isDateScope && plan.Items.Count > 14,
items = plan.Items
.Where(item => !isDateScope || item.ScheduledDate == queryDate)
.OrderBy(item => item.ScheduledDate)
.Take(isDateScope ? int.MaxValue : 14)
.Select(item => new
{
item.Id,
item.ScheduledDate,
item.ExerciseType,
item.DurationMinutes,
item.IsCompleted,
item.IsRestDay,
completed_at_beijing = item.CompletedAt.HasValue ? AiDateTime.ToBeijingText(item.CompletedAt.Value) : null,
})
.ToList(),
});
var filtered = scope switch
{
"all_plans" => overview,
"current_plans" => overview.Where(plan => plan.phase == "active"),
"upcoming_plans" => overview
.Where(plan => plan.phase == "upcoming")
.OrderBy(plan => plan.StartDate),
"ended_plans" => overview
.Where(plan => plan.phase == "ended")
.OrderByDescending(plan => plan.EndDate),
_ => overview.Where(plan => plan.phase == "active" && plan.items.Count > 0),
};
var result = filtered.ToList();
var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList();
var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault();
string authoritativeAnswer;
if (result.Count == 0)
{
authoritativeAnswer = isDateScope
? $"没有查到 {queryDate:yyyy-MM-dd} 的运动安排。"
: "当前没有查到符合条件的运动计划。";
}
else if (isDateScope)
{
var taskLines = scheduledTasks.Select(item =>
$"- {item.ExerciseType}{item.DurationMinutes} 分钟,{(item.IsCompleted ? "" : "")}");
authoritativeAnswer =
$"{queryDate:yyyy-MM-dd} 的运动安排:\n{string.Join('\n', taskLines)}";
}
else
{
var planLines = result.Select(plan =>
{
var representative = plan.items.FirstOrDefault(item => !item.IsRestDay);
var exerciseType = representative?.ExerciseType ?? "运动";
var durationMinutes = representative?.DurationMinutes ?? 0;
return $"- {exerciseType}{plan.StartDate:yyyy-MM-dd} 至 {plan.EndDate:yyyy-MM-dd}"
+ $"每天 {durationMinutes} 分钟,提醒时间 {plan.ReminderTime:HH:mm}";
});
authoritativeAnswer =
$"查到 {result.Count} 个符合条件的运动计划:\n{string.Join('\n', planLines)}";
}
return new
{
found = result.Count > 0,
query_date = queryDate.ToString("yyyy-MM-dd"),
scope,
count = result.Count,
all_tasks_completed = isDateScope
? scheduledTasks.Count > 0 && scheduledTasks.All(item => item.IsCompleted)
: (bool?)null,
has_upcoming_plans = nextPlan != null,
next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"),
days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber,
plans = result,
authoritative_answer = authoritativeAnswer,
};
}
}

View File

@@ -7,35 +7,162 @@ namespace Health.Infrastructure.AI.AgentHandlers;
/// </summary>
public static class HealthDataAgentHandler
{
private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"];
public static readonly ToolDefinition RecordHealthDataTool = new()
{
Function = new()
{
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
Parameters = new { type = "object", properties = new { type = new { type = "string" }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" } }, required = new[] { "type" } }
Name = "record_health_data",
Description = "记录健康数据(血压/心率/血糖/血氧/体重。用户说“脉搏”时按“心率”处理type 使用 heart_rate数值写入 heart_rate。",
Parameters = new { type = "object", properties = new { type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight" } }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" }, recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" } }, required = new[] { "type" } }
}
};
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
public static readonly ToolDefinition RecordHealthDataBatchTool = new()
{
Function = new()
{
Name = "record_health_data_batch",
Description = "提交当前这一轮要录入的全部健康指标。一次只调用一次;即使只有一个指标也放入 metrics。血压可先只提供已知的一侧脉搏按心率 heart_rate 处理。后端会整批校验、追问或生成一张确认卡。",
Parameters = new
{
type = "object",
properties = new
{
metrics = new
{
type = "array",
minItems = 1,
description = "当前录入批次的全部指标,不得遗漏;不要带入普通历史对话或上一张已生成卡片的数据",
items = new
{
type = "object",
properties = new
{
type = new { type = "string", @enum = MetricTypes },
systolic = new { type = "integer" },
diastolic = new { type = "integer" },
heart_rate = new { type = "number" },
glucose = new { type = "number" },
spo2 = new { type = "number" },
weight = new { type = "number" },
recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" },
},
required = new[] { "type" },
},
},
},
required = new[] { "metrics" },
},
},
};
public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
public static List<ToolDefinition> Tools => [RecordHealthDataBatchTool, CommonAgentHandler.QueryHealthRecordsTool];
public static string? ValidateWriteArguments(JsonElement args)
{
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
return type switch
{
"blood_pressure" => ValidateBloodPressure(args),
"heart_rate" => ValidateValue(args, "heart_rate", "心率", 1m, 250m),
"glucose" => ValidateValue(args, "glucose", "血糖", 0.1m, 50m),
"spo2" => ValidateValue(args, "spo2", "血氧", 1m, 100m),
"weight" => ValidateValue(args, "weight", "体重", 1m, 500m),
_ => "缺少有效的健康指标类型",
};
}
public static string? GetUrgentWarning(JsonElement args)
{
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
return type switch
{
"blood_pressure"
when args.TryGetProperty("systolic", out var systolic) &&
systolic.TryGetInt32(out var systolicValue) &&
args.TryGetProperty("diastolic", out var diastolic) &&
diastolic.TryGetInt32(out var diastolicValue) &&
(systolicValue >= 180 || diastolicValue >= 120)
=> "这次血压数值已达到明显危险范围,请不要继续等待录入,尽快联系医生或前往急诊评估。",
"heart_rate" when IsValueOutside(args, "heart_rate", 45m, 130m)
=> "这次心率数值明显异常,请尽快联系医生评估;如伴有胸痛、晕厥或呼吸困难,请立即就医。",
"glucose" when IsValueOutside(args, "glucose", 3.0m, 16.7m)
=> "这次血糖数值属于需要尽快处理的危险范围,请立即联系医生或前往医疗机构评估。",
"spo2"
when args.TryGetProperty("spo2", out var spo2) &&
spo2.TryGetDecimal(out var spo2Value) &&
spo2Value <= 90m
=> "这次血氧数值明显偏低,请尽快就医评估;如有呼吸困难、胸闷或意识异常,请立即拨打当地急救电话。",
_ => null,
};
}
public static IReadOnlyList<JsonElement> GetBatchMetrics(JsonElement args) =>
args.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Array
? metrics.EnumerateArray().Select(metric => metric.Clone()).ToList()
: [];
public static Dictionary<string, object?> CreatePreview(JsonElement args)
{
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() ?? "" : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null)
return [];
var request = new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
null);
return new Dictionary<string, object?>
{
["type"] = metricType.Value.ToString(),
["value"] = metricType == HealthMetricType.BloodPressure
? $"{systolic}/{diastolic}"
: value?.ToString() ?? "",
["unit"] = unit,
["abnormal"] = HealthRecordRules.CheckAbnormal(request),
};
}
public static async Task<object> Execute(
string toolName,
JsonElement args,
Guid userId,
IHealthRecordService healthRecords,
CancellationToken ct)
{
return toolName switch
{
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args),
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args, ct),
"record_health_data_batch" => await ExecuteRecordHealthDataBatch(healthRecords, userId, args, ct),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
private static async Task<object> ExecuteRecordHealthData(
IHealthRecordService healthRecords,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var validationError = ValidateWriteArguments(args);
if (validationError != null)
return new { success = false, message = validationError };
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
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
var recordedAt = args.TryGetProperty("recorded_at", out var ra) &&
AiDateTime.TryParseUserDateTime(ra.GetString(), out var parsedRecordedAt)
? parsedRecordedAt
: DateTime.UtcNow;
var request = new HealthRecordUpsertRequest(
@@ -47,7 +174,7 @@ public static class HealthDataAgentHandler
HealthRecordSource.AiEntry,
recordedAt);
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
var id = await healthRecords.CreateAsync(userId, request, ct);
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
return new
{
@@ -60,6 +187,75 @@ public static class HealthDataAgentHandler
};
}
private static async Task<object> ExecuteRecordHealthDataBatch(
IHealthRecordService healthRecords,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var metrics = GetBatchMetrics(args);
if (metrics.Count == 0)
return new { success = false, message = "当前录入批次没有健康指标" };
var requests = new List<HealthRecordUpsertRequest>(metrics.Count);
var previews = new List<Dictionary<string, object?>>(metrics.Count);
foreach (var metric in metrics)
{
var validationError = ValidateWriteArguments(metric);
if (validationError != null)
return new { success = false, message = validationError };
var type = metric.GetProperty("type").GetString()!;
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, metric);
if (metricType == null)
return new { success = false, message = $"未知指标类型: {type}" };
var recordedAt = metric.TryGetProperty("recorded_at", out var recordedAtValue) &&
AiDateTime.TryParseUserDateTime(recordedAtValue.GetString(), out var parsedRecordedAt)
? parsedRecordedAt
: DateTime.UtcNow;
requests.Add(new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
recordedAt));
previews.Add(CreatePreview(metric));
}
var ids = await healthRecords.CreateManyAsync(userId, requests, ct);
return new
{
success = true,
record_ids = ids,
items = previews,
};
}
private static string? ValidateBloodPressure(JsonElement args)
{
if (!args.TryGetProperty("systolic", out var systolicValue) || !systolicValue.TryGetInt32(out var systolic) ||
!args.TryGetProperty("diastolic", out var diastolicValue) || !diastolicValue.TryGetInt32(out var diastolic))
return "录入血压前需要同时提供收缩压和舒张压";
if (systolic is <= 0 or > 260 || diastolic is <= 0 or > 160 || systolic <= diastolic)
return "血压数值明显无效,请核对收缩压和舒张压后重新提供";
return null;
}
private static string? ValidateValue(JsonElement args, string property, string label, decimal minimum, decimal maximum)
{
if (!args.TryGetProperty(property, out var valueElement) || !valueElement.TryGetDecimal(out var value))
return $"录入{label}前需要提供数值";
return value < minimum || value > maximum ? $"{label}数值明显无效,请重新提供" : null;
}
private static bool IsValueOutside(JsonElement args, string property, decimal minimum, decimal maximum) =>
args.TryGetProperty(property, out var valueElement) &&
valueElement.TryGetDecimal(out var value) &&
(value <= minimum || value >= maximum);
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
{
return type switch

View File

@@ -3,7 +3,7 @@ using Health.Application.Medications;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 药管家 Agent 工具处理器。
/// 药提醒 Agent 工具处理器。
/// </summary>
public static class MedicationAgentHandler
{
@@ -12,19 +12,23 @@ public static class MedicationAgentHandler
Function = new()
{
Name = "manage_medication",
Description = "用药管理(创建/查询/确认服药)",
Description = "用药管理(创建计划、按日期查询计划与逐顿服药状态、确认某一顿服药)",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "create/query/confirm" },
action = new { type = "string", @enum = new[] { "create", "query", "confirm" }, description = "create/query/confirm" },
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "inactive_plans", "all_plans" }, description = "查询时必填:今日安排/指定日期安排/当前计划/即将开始/自然结束/已停用/全部计划" },
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
medication_id = new { type = "string", description = "药品 ID确认服药时使用" },
scheduled_time = new { type = "string", description = "要确认的计划服药时间 HH:mm" },
name = new { type = "string", description = "药品名称" },
dosage = new { type = "string", description = "剂量,如 100mg" },
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
frequency = new { type = "string", @enum = new[] { "Daily", "TwiceDaily", "ThreeTimesDaily", "EveryOtherDay", "Weekly", "AsNeeded" }, description = "服药频率" },
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
duration_days = new { type = "integer", description = "服用天数" },
long_term = new { type = "boolean", description = "是否明确为长期服用;与 duration_days 二选一" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
},
required = new[] { "action" }
@@ -49,11 +53,11 @@ public static class MedicationAgentHandler
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";
var action = args.TryGetProperty("action", out var a) ? a.GetString()?.ToLowerInvariant() : null;
return action switch
{
"create" => await CreateMedication(medications, userId, args, ct),
"query" => await QueryMedications(medications, userId, ct),
"query" => await QueryMedications(medications, userId, args, ct),
"confirm" => await ConfirmMedication(medications, userId, args, ct),
_ => new { success = false, message = $"未知操作: {action}" }
};
@@ -61,14 +65,19 @@ public static class MedicationAgentHandler
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var validationError = ValidateWriteArguments(args);
if (validationError != null)
return new { success = false, message = validationError };
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequency = ReadFrequency(args);
var times = ReadTimes(args);
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
var longTerm = args.TryGetProperty("long_term", out var longTermValue) && longTermValue.ValueKind == JsonValueKind.True;
var startDate = ReadStartDate(args);
// 结束日为包含式IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
var endDate = durationDays > 0 ? startDate.AddDays(durationDays - 1) : (DateOnly?)null;
DateOnly? endDate = longTerm ? null : startDate.AddDays(durationDays - 1);
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
name,
@@ -91,27 +100,146 @@ public static class MedicationAgentHandler
time = timeLabels,
start_date = startDate.ToString("yyyy-MM-dd"),
duration_days = durationDays,
long_term = longTerm,
};
}
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
private static async Task<object> QueryMedications(
IMedicationService medications,
Guid userId,
JsonElement args,
CancellationToken ct)
{
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 }) };
var scope = args.TryGetProperty("scope", out var scopeValue)
? scopeValue.GetString()?.ToLowerInvariant()
: null;
if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "inactive_plans" or "all_plans"))
return new
{
success = false,
message = "查询用药时必须明确 scopetoday、scheduled_date、current_plans、upcoming_plans、ended_plans、inactive_plans 或 all_plans",
};
if (scope == "scheduled_date" &&
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
return new { success = false, message = "查询指定日期的用药安排时必须提供有效的 date" };
var date = scope == "scheduled_date"
? DateOnly.Parse(args.GetProperty("date").GetString()!)
: AiDateTime.BeijingToday;
var isDateScope = scope is "today" or "scheduled_date";
var overview = await medications.GetAiOverviewAsync(userId, date, ct);
var plans = scope switch
{
"all_plans" => overview.ToList(),
"current_plans" => overview.Where(plan => plan.Phase == "active").ToList(),
"upcoming_plans" => overview
.Where(plan => plan.Phase == "upcoming")
.OrderBy(plan => plan.StartDate)
.ToList(),
"ended_plans" => overview
.Where(plan => plan.Phase == "ended")
.OrderByDescending(plan => plan.EndDate)
.ToList(),
"inactive_plans" => overview
.Where(plan => plan.Phase == "inactive")
.OrderByDescending(plan => plan.EndDate)
.ToList(),
_ => overview.Where(plan => plan.ScheduledOnDate).ToList(),
};
var doseStatuses = plans.SelectMany(plan => plan.Doses).ToList();
var nextPlan = overview
.Where(plan => plan.Phase == "upcoming" && plan.StartDate.HasValue)
.OrderBy(plan => plan.StartDate)
.FirstOrDefault();
return new
{
query_date = date.ToString("yyyy-MM-dd"),
scope,
count = plans.Count,
all_doses_completed = isDateScope
? doseStatuses.Count > 0 && doseStatuses.All(dose => dose.Status == "taken")
: (bool?)null,
has_upcoming_plans = nextPlan != null,
next_start_date = nextPlan?.StartDate?.ToString("yyyy-MM-dd"),
days_until_next_start = nextPlan?.StartDate is DateOnly nextStart
? nextStart.DayNumber - date.DayNumber
: (int?)null,
plans,
};
}
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;
var success = await medications.ConfirmNowAsync(userId, medId, ct);
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
var validationError = ValidateWriteArguments(args);
if (validationError != null)
return new { success = false, message = validationError };
var medId = args.TryGetProperty("medication_id", out var mid) && mid.TryGetGuid(out var parsedMedicationId)
? parsedMedicationId
: Guid.Empty;
if (!args.TryGetProperty("scheduled_time", out var timeValue) ||
!TimeOnly.TryParse(timeValue.GetString(), out var scheduledTime))
return new { success = false, message = "缺少要确认的计划服药时间" };
var success = await medications.ConfirmDoseAsync(
userId,
medId,
scheduledTime,
MedicationLogStatus.Taken,
ct);
return success == true
? new { success = true, medication_id = medId, scheduled_time = scheduledTime.ToString("HH:mm") }
: new { success = false, message = success == null ? "药品不存在" : "该时间不属于今天的服药安排,或这一顿已经记录" };
}
public static string? ValidateWriteArguments(JsonElement args)
{
var action = args.TryGetProperty("action", out var actionValue)
? actionValue.GetString()?.ToLowerInvariant()
: null;
if (action == "confirm")
{
if (!args.TryGetProperty("medication_id", out var medicationId) || !medicationId.TryGetGuid(out _))
return "确认服药时缺少有效的药品 ID";
if (!args.TryGetProperty("scheduled_time", out var scheduledTime) || !TimeOnly.TryParse(scheduledTime.GetString(), out _))
return "确认服药时缺少具体的计划服药时间";
return null;
}
if (action != "create") return null;
if (!args.TryGetProperty("name", out var name) || string.IsNullOrWhiteSpace(name.GetString()))
return "创建用药计划前需要确认药品名称";
if (!args.TryGetProperty("dosage", out var dosage) || string.IsNullOrWhiteSpace(dosage.GetString()))
return "创建用药计划前需要确认每次剂量";
if (!args.TryGetProperty("frequency", out var frequencyValue) ||
!Enum.TryParse<MedicationFrequency>(frequencyValue.GetString(), ignoreCase: true, out _))
return "创建用药计划前需要确认服药频率";
if (!args.TryGetProperty("time_of_day", out var times) || times.ValueKind != JsonValueKind.Array)
return "创建用药计划前需要确认具体服药时间";
var timeValues = times.EnumerateArray().ToList();
if (timeValues.Count == 0 || timeValues.Any(value => !TimeOnly.TryParse(value.GetString(), out _)))
return "创建用药计划前需要提供有效的具体服药时间";
if (!args.TryGetProperty("start_date", out var startDate) || !DateOnly.TryParse(startDate.GetString(), out _))
return "创建用药计划前需要确认开始日期";
var durationDays = 0;
var hasDuration = args.TryGetProperty("duration_days", out var duration) &&
duration.TryGetInt32(out durationDays) && durationDays > 0;
var isLongTerm = args.TryGetProperty("long_term", out var longTerm) && longTerm.ValueKind == JsonValueKind.True;
if (hasDuration && durationDays > 3650)
return "服用天数不能超过3650天长期服用请明确设置 long_term=true";
if (hasDuration && isLongTerm)
return "服用天数和长期服用不能同时设置";
if (!hasDuration && !isLongTerm)
return "创建用药计划前需要确认服用天数,或明确说明长期服用";
return null;
}
private static MedicationFrequency ReadFrequency(JsonElement args)
{
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
return Enum.TryParse<MedicationFrequency>(frequencyStr, ignoreCase: true, out var frequency) ? frequency : MedicationFrequency.Daily;
}
private static DateOnly ReadStartDate(JsonElement args)

View File

@@ -0,0 +1,257 @@
using Health.Application.Diets;
using Health.Application.Notifications;
using Health.Application.Reports;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 统一助手使用的患者本人业务数据只读工具。
/// </summary>
public static class PatientReadAgentHandler
{
public static readonly ToolDefinition QueryDietRecordsTool = new()
{
Function = new()
{
Name = "query_diet_records",
Description = "查询当前用户指定北京时间日期的饮食记录和总热量",
Parameters = new
{
type = "object",
properties = new
{
date = new { type = "string", description = "北京时间日期 yyyy-MM-dd默认今天" },
meal_type = new { type = "string", @enum = new[] { "Breakfast", "Lunch", "Dinner", "Snack" }, description = "可选餐次" },
}
}
}
};
public static readonly ToolDefinition QueryFollowUpsTool = new()
{
Function = new()
{
Name = "query_followups",
Description = "查询当前用户的复查随访安排",
Parameters = new
{
type = "object",
properties = new
{
scope = new { type = "string", @enum = new[] { "next", "upcoming", "completed", "date", "all" }, description = "查询时必填:下一次/未来安排/已完成/指定日期/全部" },
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
limit = new { type = "integer", description = "最多返回条数1到20" },
}
}
}
};
public static readonly ToolDefinition QueryReportsTool = new()
{
Function = new()
{
Name = "query_reports",
Description = "查询当前用户已经保存的检查报告状态、AI摘要和医生审核结论不读取其他用户报告",
Parameters = new
{
type = "object",
properties = new
{
limit = new { type = "integer", description = "最多返回条数1到10默认5" },
status = new { type = "string", description = "可选报告状态" },
}
}
}
};
public static readonly ToolDefinition QueryNotificationsTool = new()
{
Function = new()
{
Name = "query_notifications",
Description = "查询当前用户的站内通知",
Parameters = new
{
type = "object",
properties = new
{
scope = new { type = "string", @enum = new[] { "unread", "today", "recent" }, description = "默认 unread" },
limit = new { type = "integer", description = "最多返回条数1到20" },
}
}
}
};
public static async Task<object> QueryDietAsync(
IDietService diets,
Guid userId,
JsonElement args,
CancellationToken ct)
{
if (args.TryGetProperty("date", out var suppliedDate) && !DateOnly.TryParse(suppliedDate.GetString(), out _))
return new { success = false, message = "查询饮食记录时提供了无效的 date" };
var date = args.TryGetProperty("date", out var dateValue) && DateOnly.TryParse(dateValue.GetString(), out var parsedDate)
? parsedDate
: AiDateTime.BeijingToday;
var mealType = args.TryGetProperty("meal_type", out var mealValue) ? mealValue.GetString() : null;
var records = await diets.ListAsync(userId, date.ToString("yyyy-MM-dd"), mealType, ct);
return new
{
query_date = date.ToString("yyyy-MM-dd"),
count = records.Count,
total_calories = records.Sum(record => record.TotalCalories ?? 0),
records = records.Select(record => new
{
record.Id,
record.MealType,
record.TotalCalories,
record.HealthScore,
record.RecordedAt,
record.FoodItems,
}),
};
}
public static async Task<object> QueryFollowUpsAsync(
AppDbContext db,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var scope = args.TryGetProperty("scope", out var scopeValue)
? scopeValue.GetString()?.ToLowerInvariant()
: null;
if (scope is not ("next" or "upcoming" or "completed" or "date" or "all"))
return new
{
success = false,
message = "查询复查安排时必须明确 scopenext、upcoming、completed、date 或 all",
};
var limit = ReadLimit(args, 10, 20);
var nowUtc = DateTime.UtcNow;
var query = db.FollowUps.AsNoTracking().Where(item => item.UserId == userId);
if (scope is "next" or "upcoming")
query = query.Where(item => item.Status == FollowUpStatus.Upcoming && item.ScheduledAt >= nowUtc);
else if (scope == "completed")
query = query.Where(item => item.Status == FollowUpStatus.Completed);
else if (scope == "date")
{
if (!args.TryGetProperty("date", out var dateValue) || !DateOnly.TryParse(dateValue.GetString(), out var date))
return new { success = false, message = "按日期查询复查时必须提供有效的 date" };
var (startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
query = query.Where(item => item.ScheduledAt >= startUtc && item.ScheduledAt < endUtc);
}
var take = scope == "next" ? 1 : limit;
var orderedQuery = scope is "all" or "completed"
? query.OrderByDescending(item => item.ScheduledAt)
: query.OrderBy(item => item.ScheduledAt);
var matchingCount = await query.CountAsync(ct);
var items = await orderedQuery
.Take(take)
.Select(item => new
{
item.Id,
item.Title,
item.DoctorName,
item.Department,
item.ScheduledAt,
item.Notes,
item.Status,
})
.ToListAsync(ct);
return new
{
success = true,
scope,
count = items.Count,
matching_count = matchingCount,
results_truncated = matchingCount > items.Count,
followups = items.Select(item => new
{
item.Id,
item.Title,
item.DoctorName,
item.Department,
scheduled_at_beijing = AiDateTime.ToBeijingText(item.ScheduledAt),
item.Notes,
status = item.Status.ToString(),
}),
};
}
public static async Task<object> QueryReportsAsync(
IReportService reports,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var limit = ReadLimit(args, 5, 10);
var status = args.TryGetProperty("status", out var statusValue) ? statusValue.GetString() : null;
var records = await reports.GetReportsAsync(userId, ct);
var filtered = records
.Where(report => string.IsNullOrWhiteSpace(status) || report.Status.Equals(status, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(report => report.CreatedAt)
.Take(limit)
.Select(report => new
{
report.Id,
report.Category,
report.Status,
report.AiStatus,
report.ReviewStatus,
report.Severity,
ai_summary = Truncate(report.AiSummary, 1800),
doctor_comment = Truncate(report.DoctorComment, 1000),
doctor_recommendation = Truncate(report.DoctorRecommendation, 1000),
report.DoctorName,
reviewed_at_beijing = report.ReviewedAt.HasValue ? AiDateTime.ToBeijingText(report.ReviewedAt.Value) : null,
created_at_beijing = AiDateTime.ToBeijingText(report.CreatedAt),
})
.ToList();
return new { count = filtered.Count, reports = filtered };
}
public static async Task<object> QueryNotificationsAsync(
IInAppNotificationService notifications,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var scope = args.TryGetProperty("scope", out var scopeValue)
? scopeValue.GetString()?.ToLowerInvariant() ?? "unread"
: "unread";
if (scope is not ("unread" or "today" or "recent")) scope = "unread";
var limit = ReadLimit(args, 10, 20);
var records = scope == "unread"
? await notifications.GetPendingAsync(userId, ct)
: await notifications.GetHistoryAsync(userId, ct);
var (todayStartUtc, todayEndUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
var filtered = records
.Where(item => scope != "today" ||
(AiDateTime.ToUtc(item.CreatedAt) >= todayStartUtc && AiDateTime.ToUtc(item.CreatedAt) < todayEndUtc))
.OrderByDescending(item => item.CreatedAt)
.Take(limit)
.Select(item => new
{
item.Id,
item.Type,
item.Title,
item.Message,
item.Severity,
item.IsRead,
created_at_beijing = AiDateTime.ToBeijingText(item.CreatedAt),
})
.ToList();
return new { scope, count = filtered.Count, notifications = filtered };
}
private static int ReadLimit(JsonElement args, int defaultValue, int max) =>
args.TryGetProperty("limit", out var value) && value.TryGetInt32(out var parsed)
? Math.Clamp(parsed, 1, max)
: defaultValue;
private static string? Truncate(string? value, int maxLength) =>
string.IsNullOrWhiteSpace(value) || value.Length <= maxLength ? value : value[..maxLength] + "…";
}

View File

@@ -19,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

@@ -1,9 +1,12 @@
using System.Text.Json;
using Health.Application.AI;
using Health.Application.Exercises;
using Health.Application.Diets;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
using Health.Application.Notifications;
using Health.Application.Reports;
using Health.Infrastructure.AI.AgentHandlers;
namespace Health.Infrastructure.AI;
@@ -15,6 +18,9 @@ public sealed class AiToolExecutionService(
IHealthRecordService healthRecords,
IExerciseService exercises,
IMedicationService medications,
IDietService diets,
IReportService reports,
IInAppNotificationService notifications,
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
{
private readonly AppDbContext _db = db;
@@ -23,6 +29,9 @@ public sealed class AiToolExecutionService(
private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IExerciseService _exercises = exercises;
private readonly IMedicationService _medications = medications;
private readonly IDietService _diets = diets;
private readonly IReportService _reports = reports;
private readonly IInAppNotificationService _notifications = notifications;
private readonly IAiWriteConfirmationStore _confirmations = confirmations;
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
@@ -31,13 +40,17 @@ public sealed class AiToolExecutionService(
var root = json.RootElement;
return await (toolName switch
{
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
"record_health_data" or "record_health_data_batch"
=> HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords, ct),
"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),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, userId, _exercises, ct),
"query_diet_records" => PatientReadAgentHandler.QueryDietAsync(_diets, userId, root, ct),
"query_followups" => PatientReadAgentHandler.QueryFollowUpsAsync(_db, userId, root, ct),
"query_reports" => PatientReadAgentHandler.QueryReportsAsync(_reports, userId, root, ct),
"query_notifications" => PatientReadAgentHandler.QueryNotificationsAsync(_notifications, userId, root, ct),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
});
}

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using Health.Application.AI;
using Health.Infrastructure.Files;
using Microsoft.Extensions.Logging;
using UglyToad.PdfPig;
@@ -19,19 +20,19 @@ public sealed class AttachmentContextBuilder(
private readonly VisionClient _vision = vision;
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct)
public async Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(imageUrl))
return await BuildImageAsync(imageUrl!, ct);
return await BuildImageAsync(userId, imageUrl!, ct);
if (!string.IsNullOrWhiteSpace(pdfUrl))
return await BuildPdfAsync(pdfUrl!, ct);
return await BuildPdfAsync(userId, pdfUrl!, ct);
return null;
}
// ── 图片:调 VLM 输出结构化 JSON ──
private async Task<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct)
private async Task<AttachmentContext?> BuildImageAsync(Guid userId, string imageUrl, CancellationToken ct)
{
var filePath = ResolveLocalPath(imageUrl);
var filePath = UserUploadPathResolver.Resolve(userId, imageUrl);
if (filePath == null || !File.Exists(filePath))
{
_logger.LogWarning("Image file not found for {Url}", imageUrl);
@@ -105,9 +106,9 @@ public sealed class AttachmentContextBuilder(
}
// ── PDFPdfPig 抽取文本 ──
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct)
private Task<AttachmentContext?> BuildPdfAsync(Guid userId, string pdfUrl, CancellationToken ct)
{
var filePath = ResolveLocalPath(pdfUrl);
var filePath = UserUploadPathResolver.Resolve(userId, pdfUrl);
var fileName = Path.GetFileName(pdfUrl);
if (filePath == null || !File.Exists(filePath))
{
@@ -150,18 +151,6 @@ public sealed class AttachmentContextBuilder(
}
}
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();

View File

@@ -0,0 +1,108 @@
using Health.Application.AI;
using Health.Infrastructure.Data;
using Health.Infrastructure.Data.Records;
using Microsoft.EntityFrameworkCore;
namespace Health.Infrastructure.AI;
public sealed class EfAiEntryDraftStore(AppDbContext db) : IAiEntryDraftStore
{
private readonly AppDbContext _db = db;
public async Task<AiEntryDraft?> GetActiveAsync(
Guid userId,
Guid conversationId,
string entryType,
CancellationToken ct)
{
var now = DateTime.UtcNow;
await ExpireAsync(now, ct);
var record = await _db.AiEntryDrafts.AsNoTracking().FirstOrDefaultAsync(x =>
x.UserId == userId &&
x.ConversationId == conversationId &&
x.EntryType == entryType &&
x.Status == "Pending" &&
x.ExpiresAt > now, ct);
return record == null ? null : ToDraft(record);
}
public async Task<AiEntryDraft> UpsertAsync(
Guid userId,
Guid conversationId,
string entryType,
string payload,
string missingFields,
TimeSpan lifetime,
CancellationToken ct)
{
var now = DateTime.UtcNow;
await ExpireAsync(now, ct);
var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x =>
x.UserId == userId &&
x.ConversationId == conversationId &&
x.EntryType == entryType &&
x.Status == "Pending", ct);
if (record == null)
{
record = new AiEntryDraftRecord
{
Id = Guid.NewGuid(),
UserId = userId,
ConversationId = conversationId,
EntryType = entryType,
CreatedAt = now,
};
await _db.AiEntryDrafts.AddAsync(record, ct);
}
record.Payload = payload;
record.MissingFields = missingFields;
record.ExpiresAt = now.Add(lifetime);
record.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
return ToDraft(record);
}
public async Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct)
{
var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x =>
x.Id == draftId && x.UserId == userId && x.Status == "Pending", ct);
if (record == null) return;
record.Status = "Completed";
record.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
private async Task ExpireAsync(DateTime now, CancellationToken ct)
{
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
{
var expired = await _db.AiEntryDrafts
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ToListAsync(ct);
foreach (var item in expired)
{
item.Status = "Expired";
item.UpdatedAt = now;
}
if (expired.Count > 0) await _db.SaveChangesAsync(ct);
return;
}
await _db.AiEntryDrafts
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Expired")
.SetProperty(x => x.UpdatedAt, now), ct);
}
private static AiEntryDraft ToDraft(AiEntryDraftRecord record) => new(
record.Id,
record.UserId,
record.ConversationId,
record.EntryType,
record.Payload,
record.MissingFields,
record.ExpiresAt);
}

View File

@@ -0,0 +1,8 @@
你是患者端统一 AI 健康助手“小脉”。你的任务是理解用户当前真正想做的事情,调用经过授权的业务工具,并用自然、温和、清楚的中文回复。
核心原则:
- 个人健康数据、计划、完成状态和报告状态必须以当前业务工具返回为准,不能根据聊天历史或患者背景猜测。
- 工具没有返回的数据不得编造;暂时没有对应能力时应如实说明。
- 一条消息可能包含多个意图,必须分别处理,不能为了简化而遗漏其中一项。
- 当前工具结果与历史聊天冲突时,以当前工具结果为准。
- 普通聊天历史用于理解语境,不等同于待写入数据;只有后端明确提供的待补充草稿才允许跨消息合并录入字段。

View File

@@ -0,0 +1,6 @@
医疗安全边界:
- 你是健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
- 可以解释健康数据、报告指标和症状可能方向,但不能作确定诊断。
- 不要求用户自行新增、停用、更换药物或调整剂量;涉及治疗变化时建议咨询医生或药师。
- 出现剧烈胸痛、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等危险信号时,立即停止普通流程并优先建议及时就医或急诊评估。
- 医疗分析使用“可能、建议、需要结合医生判断”等表达。

View File

@@ -0,0 +1,8 @@
回复要求:
- 语气自然、温和、专业,不使用系统报错式措辞。
- 信息不完整时,每次只追问最关键的缺失内容;可以在一句话中列出同一批次明确缺少的字段。
- 禁止使用粗体、斜体、删除线、HTML 和 Markdown 表格。
- 标题只使用三级标题;列表使用短横线。
- 数值和单位之间留空格,例如 120 mmHg、6.2 mmol/L。
- 不输出任何以 $ 开头的占位符。
- 不重复堆砌免责声明。

View File

@@ -0,0 +1,6 @@
新饮食图片分析模块:
- 仅用于本轮新上传的饮食图片;查询已经保存的饮食记录应进入个人数据查询模块。
- 识别食物名称、估算份量和热量,并结合患者健康档案评价。
- 对过敏、低盐、低脂、低糖等限制给出清楚提醒,不作绝对化医疗结论。
- 饮食建议需要医学资料支撑时可以按需调用知识库。
- 图片分析结果不能在用户未确认时冒充已经保存的饮食记录。

View File

@@ -0,0 +1,9 @@
运动计划录入模块:
- 用户明确创建运动计划时调用 manage_exercise(action="create")。
- 完整计划需要:运动项目、每天运动时长、持续天数、开始日期和具体提醒时间。
- 中文时长和周期应正确换算,例如半小时=30 分钟、一小时=60 分钟、一周=7 天、一个月=30 天。
- 只传用户明确提供的字段;当前产品不自动补默认运动项目、天数、开始日期或提醒时间。
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮补充时与草稿合并。
- 用户明确开始新的运动计划时不得继承旧草稿内容。
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
- 单纯创建运动计划不调用医学知识库。用户先询问该运动是否适合自己时,额外进入医疗咨询模块。

View File

@@ -0,0 +1,4 @@
普通聊天与应用帮助模块:
- 问候、感谢和普通闲聊自然简短回复,不调用业务工具或医学知识库。
- 用户询问应用功能位置或使用方法时可以直接说明;已有固定 App 内链接时可自然提供一个跳转链接。
- 不确定用户是想咨询数值还是保存记录时,只做一次简短澄清,不生成确认卡。

View File

@@ -0,0 +1,13 @@
健康指标录入模块:
- 支持血压、心率、血糖、血氧、体重;“脉搏”与“心率”是同一指标,统一使用 heart_rate。
- 必须理解常见完整血压表达,例如 113/86、113-86、113—86前者为收缩压后者为舒张压。
- 用户明确要求录入时必须调用 record_health_data_batch而且每个录入批次只调用一次即使只有一个指标也必须放进 metrics 数组。信息不完整也要调用,只传用户明确提供的字段,让后端保存整个待补充批次草稿。
- 不能自行补测量数值。测量时间未说明时可以由后端使用当前时间。
- 一次消息可以包含多个指标,必须完整提取,不得遗漏。多指标属于同一录入批次,最终应汇总在一张确认卡中。
- metrics 必须包含用户本轮明确要录入的全部指标;不要为每个指标分别调用工具。
- 如果同一批次存在不完整或明显无效的指标,应保留本批次其他已识别指标,先根据后端结果追问缺失或错误部分;全部补齐后再生成整批确认卡。
- 用户只补充一个缺失值时,必须结合后端提供的当前草稿,不能只记录最后一句。
- 已经生成确认卡的批次立即结束;无论用户是否点击,下一条新的录入请求都属于新批次,不能带入上一张卡的数据。
- 普通聊天历史中的指标不得自动加入当前卡;只有明确的待补充草稿可以跨消息合并。
- 后端返回 pendingConfirmation=true 才代表确认卡真实生成。确认卡出现前不得要求用户点击卡片,点击前不得声称已经保存或录入成功。
- 后端返回危险提醒或无效数值时,不生成成功话术,按返回内容提醒用户。

View File

@@ -0,0 +1,7 @@
医疗咨询与健康建议模块:
- 症状求助时先判断是否存在危险信号;非紧急情况下每轮只追问一个最关键问题,逐步了解部位、开始时间、程度、诱因和伴随表现。
- 信息足够后给出可能相关方向、严重程度以及观察、门诊、尽快就医或急诊建议,不作正式诊断。
- 解释用户个人指标或趋势时,应先调用相应个人数据查询工具获取真实数据。
- 疾病、药品、检查指标、康复、饮食或运动建议需要医学资料支撑时,调用 search_medical_knowledge。
- 不需要外部医学资料的简单澄清、问候或业务操作不调用知识库。
- 用户同时要求录入或创建计划时,医疗解释不能代替对应录入工具和确认卡。

View File

@@ -0,0 +1,9 @@
用药计划录入模块:
- 用户明确创建用药计划时调用 manage_medication(action="create")。
- 只传用户明确提供的字段,不自行新增药品、猜测剂量、频率、具体服药时间、开始日期或疗程。
- 完整计划需要:药品名称、每次剂量、频率、具体服药时间、开始日期,以及疗程天数或明确长期服用。
- “早饭后、晚上”等模糊时间不能擅自换算为具体时刻,应追问具体时间。
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮只补缺失字段时与草稿合并。
- 存在旧草稿但用户明确开始另一种药品的新计划时,应开始新批次,禁止继承旧药品的剂量、时间、频率或疗程。
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
- 用药计划录入本身不调用医学知识库。用户同时询问药品知识或副作用时,额外进入医疗咨询模块。

View File

@@ -0,0 +1,7 @@
个人数据查询模块:
- 查询用户自己的健康记录、健康档案、用药计划、运动计划、饮食记录、复查安排、已有报告和通知时,必须调用对应只读业务工具。
- 不能根据患者背景、聊天历史或以前的工具结果回答当前状态。
- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“任务已经完成”。
- 查询某一天任务与查询计划生命周期不能混用;时间意图不明确时只追问时间范围。
- 工具没有返回数据时明确说明未查到,不得编造。
- 个人数据查询通常不调用医学知识库;用户同时要求解释或建议时,额外进入医疗咨询模块。

View File

@@ -0,0 +1,7 @@
新报告分析模块:
- 仅用于本轮新上传的医学检查报告图片或报告文件;查询以前保存的报告应进入个人数据查询模块。
- 提取报告名称、日期、关键指标、参考范围、异常标记和原文结论。
- 区分结构化检验指标与影像/医生描述,不把影像描述改写成确定诊断。
- 给出患者可理解的初步解释和下一步建议,注明需要结合医生判断。
- 报告内容需要进一步医学解释时可以按需调用知识库。
- 用户没有明确要求时,不把报告中的指标直接写入健康记录。

View File

@@ -0,0 +1,7 @@
医学知识库使用规则:
- 知识库是医疗咨询、指标解释、药品知识、报告解释和健康建议的辅助能力,不是独立用户意图。
- 健康指标录入、用药或运动计划创建、个人数据查询、服药确认、运动打卡、普通聊天时禁止调用。
- 一条消息同时包含业务操作和医学咨询时,先完成必要业务工具调用;只有咨询部分确实需要资料时才检索。
- 检索问题应保留关键症状、疾病、药品或指标名称,删除无关寒暄和操作指令。
- 只能依据真实返回的知识片段作参考;没有结果时应谨慎回答,不得虚构检索内容或来源。
- 知识库内容不能覆盖患者当前业务数据,也不能替代紧急安全规则。

View File

@@ -0,0 +1,25 @@
你是主聊天的意图路由器,只负责识别意图,不回答用户问题。
请结合用户当前消息、附件类型、最近对话和后端提供的待补充草稿,选择一个或多个意图:
- health_entry录入健康指标或继续补充健康指标草稿。
- medication_entry创建用药计划或继续补充用药计划草稿。
- exercise_entry创建运动计划或继续补充运动计划草稿。
- personal_query查询用户自己的健康数据、健康档案、用药/运动计划、饮食、复查、报告或通知。
- medical_consultation症状求助、指标解释、疾病/药品知识、健康评价、饮食/运动/康复建议。
- report_analysis分析新上传的医学报告图片或报告文件。
- diet_analysis分析新上传的饮食图片。
- app_help询问应用功能位置或使用方法。
- general_chat问候、感谢、普通闲聊或不需要业务工具的对话。
判断规则:
- “帮我记录血压 116/89”是 health_entry。
- “血压 116/89 正常吗”是 medical_consultation不是录入。
- “记录血压 150/95并告诉我是否偏高”同时包含 health_entry 和 medical_consultation。
- 查询已有报告属于 personal_query分析本轮新上传报告属于 report_analysis。
- 创建计划与询问建议必须区分;“创建每天散步 30 分钟的计划”是 exercise_entry“我适合每天散步 30 分钟吗”是 medical_consultation。
- 只有确实在补充草稿缺失字段时才标记为 continue存在草稿不代表所有后续消息都必须继续草稿。
- 用户明确开始新的同类录入时标记 start_new不能继承旧草稿字段。
- 用户明确说取消、不录了时标记 cancel。
- 无法确定是咨询还是录入时选择 general_chat让主助手自然澄清。
必须通过 route_user_intent 工具返回结构化结果,不得输出解释文字。

View File

@@ -9,7 +9,7 @@ namespace Health.Infrastructure.AI;
public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{
private readonly HttpClient _http = http;
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat";
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-v4-flash";
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
@@ -30,6 +30,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{
Model = _model, Messages = messages, Stream = true,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
Thinking = new ThinkingOptions { Type = "disabled" },
};
if (tools?.Count > 0) request.ToolChoice = "auto";
@@ -64,14 +65,16 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
List<ToolDefinition>? tools = null,
int maxTokens = 2048,
float temperature = 0.7f,
string? toolChoice = null,
CancellationToken ct = default)
{
var request = new ChatCompletionRequest
{
Model = _model, Messages = messages, Stream = false,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
Thinking = new ThinkingOptions { Type = "disabled" },
};
if (tools?.Count > 0) request.ToolChoice = "auto";
if (tools?.Count > 0) request.ToolChoice = toolChoice ?? "auto";
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -88,7 +91,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
public sealed class VisionClient(HttpClient http, IConfiguration config)
{
private readonly HttpClient _http = http;
private readonly string _model = config["VLM_MODEL"] ?? "doubao-vision-pro";
private readonly string _model = config["VLM_MODEL"] ?? config["QWEN_VISION_MODEL"] ?? "qwen-vl-max";
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,

View File

@@ -14,8 +14,19 @@ public sealed class ChatCompletionRequest
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
public bool VlHighResolutionImages { get; set; }
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
[System.Text.Json.Serialization.JsonPropertyName("enable_thinking")]
public bool? EnableThinking { get; set; }
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
[System.Text.Json.Serialization.JsonPropertyName("thinking")]
public ThinkingOptions? Thinking { get; set; }
}
public sealed class ThinkingOptions
{
[System.Text.Json.Serialization.JsonPropertyName("type")]
public string Type { get; set; } = "disabled";
}
public sealed class ChatMessage

View File

@@ -0,0 +1,185 @@
using System.Text.Json;
namespace Health.Infrastructure.AI;
public sealed record AiIntentRoute(
IReadOnlyList<string> Intents,
string DraftAction,
string DraftType)
{
public static AiIntentRoute GeneralChat { get; } =
new(["general_chat"], "none", "none");
public static AiIntentRoute Compatibility { get; } = new(
[
"health_entry",
"medication_entry",
"exercise_entry",
"personal_query",
"medical_consultation",
"report_analysis",
"diet_analysis",
"general_chat",
],
"none",
"none");
}
public static class AiIntentRouter
{
private static readonly HashSet<string> AllowedIntents = new(
[
"health_entry",
"medication_entry",
"exercise_entry",
"personal_query",
"medical_consultation",
"report_analysis",
"diet_analysis",
"app_help",
"general_chat",
],
StringComparer.OrdinalIgnoreCase);
public static readonly ToolDefinition RouteTool = new()
{
Function = new()
{
Name = "route_user_intent",
Description = "返回当前用户消息的一个或多个业务意图,不回答用户问题",
Parameters = new
{
type = "object",
properties = new
{
intents = new
{
type = "array",
minItems = 1,
uniqueItems = true,
items = new
{
type = "string",
@enum = AllowedIntents.OrderBy(x => x).ToArray(),
},
},
draft_action = new
{
type = "string",
@enum = new[] { "none", "continue", "start_new", "cancel" },
description = "当前消息与待补充录入草稿的关系",
},
draft_type = new
{
type = "string",
@enum = new[] { "none", "health_entry", "medication_entry", "exercise_entry" },
},
},
required = new[] { "intents", "draft_action", "draft_type" },
},
},
};
public static async Task<AiIntentRoute> RouteAsync(
DeepSeekClient client,
PromptManager prompts,
string currentMessage,
string attachmentContext,
string draftContext,
string recentConversation,
CancellationToken ct)
{
var userContext = $"""
当前用户消息:
{currentMessage}
本轮附件:
{(string.IsNullOrWhiteSpace(attachmentContext) ? "" : attachmentContext)}
稿
{(string.IsNullOrWhiteSpace(draftContext) ? "无" : draftContext)}
{(string.IsNullOrWhiteSpace(recentConversation) ? "无" : recentConversation)}
""";
try
{
var response = await client.ChatAsync(
[
new ChatMessage { Role = "system", Content = prompts.GetIntentRouterPrompt() },
new ChatMessage { Role = "user", Content = userContext },
],
tools: [RouteTool],
maxTokens: 300,
temperature: 0.1f,
toolChoice: "required",
ct: ct);
var call = response.Choices?
.FirstOrDefault()?
.Message?
.ToolCalls?
.FirstOrDefault(item => item.Function.Name == "route_user_intent");
return call == null ? AiIntentRoute.Compatibility : Parse(call.Function.Arguments);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch
{
// Routing failure must not make the whole chat unavailable. The broad
// compatibility prompt/tool set remains the safe fallback.
return AiIntentRoute.Compatibility;
}
}
internal static AiIntentRoute Parse(string arguments)
{
try
{
using var json = JsonDocument.Parse(arguments);
var root = json.RootElement;
var intents = root.TryGetProperty("intents", out var values) &&
values.ValueKind == JsonValueKind.Array
? values.EnumerateArray()
.Where(value => value.ValueKind == JsonValueKind.String)
.Select(value => value.GetString()?.Trim().ToLowerInvariant() ?? "")
.Where(AllowedIntents.Contains)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
: [];
if (intents.Count == 0) intents.Add("general_chat");
var draftAction = ReadEnum(
root,
"draft_action",
["none", "continue", "start_new", "cancel"],
"none");
var draftType = ReadEnum(
root,
"draft_type",
["none", "health_entry", "medication_entry", "exercise_entry"],
"none");
return new AiIntentRoute(intents, draftAction, draftType);
}
catch (JsonException)
{
return AiIntentRoute.Compatibility;
}
}
private static string ReadEnum(
JsonElement root,
string property,
IReadOnlyCollection<string> allowed,
string fallback)
{
var value = root.TryGetProperty(property, out var element) &&
element.ValueKind == JsonValueKind.String
? element.GetString()?.Trim().ToLowerInvariant()
: null;
return value != null && allowed.Contains(value) ? value : fallback;
}
}

View File

@@ -14,10 +14,12 @@ public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration confi
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 bool _ragEnabled = bool.TryParse(config["ENABLE_MEDICAL_RAG"], out var enabled) && enabled;
private readonly ILogger<FastGptKnowledgeClient> _logger = logger;
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId);
// iOS 审核版使用后端内置医学来源目录,暂时关闭在线 RAG减少一次外部检索请求。
public bool IsEnabled => _ragEnabled && !string.IsNullOrWhiteSpace(_datasetId);
/// <summary>
/// 按用户问题检索知识库,返回最相关的文档片段。

View File

@@ -1,199 +1,157 @@
using System.Reflection;
using Health.Application.AI;
namespace Health.Infrastructure.AI;
/// <summary>
/// System Prompt 模板管理
/// Loads small, responsibility-focused prompt resources and composes only the
/// sections required by the current intent route.
/// </summary>
public sealed class PromptManager
{
private static readonly Assembly PromptAssembly = typeof(PromptManager).Assembly;
private static readonly IReadOnlyDictionary<string, string> PromptCache = LoadPrompts();
private static readonly string[] GlobalSections =
[
"global/identity.md",
"global/medical_safety.md",
"global/response_style.md",
];
public string GetIntentRouterPrompt() => Read("router/intent_router.md");
public string ComposeForIntents(IEnumerable<string> intents)
{
var normalized = intents
.Select(NormalizeIntent)
.Where(intent => intent.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalized.Count == 0) normalized.Add("general_chat");
var sections = new List<string>(GlobalSections);
foreach (var intent in normalized)
{
sections.Add(intent switch
{
"health_entry" => "modules/health_entry.md",
"medication_entry" => "modules/medication_entry.md",
"exercise_entry" => "modules/exercise_entry.md",
"personal_query" => "modules/personal_query.md",
"medical_consultation" => "modules/medical_consultation.md",
"report_analysis" => "modules/report_analysis.md",
"diet_analysis" => "modules/diet_analysis.md",
"app_help" or "general_chat" => "modules/general_chat.md",
_ => "modules/general_chat.md",
});
}
if (normalized.Contains("medical_consultation") ||
normalized.Contains("report_analysis") ||
normalized.Contains("diet_analysis"))
{
sections.Add("rag/retrieval_rules.md");
}
var composed = Compose(sections);
var needsMedicalSources = normalized.Contains("medical_consultation") ||
normalized.Contains("report_analysis") ||
normalized.Contains("diet_analysis");
if (needsMedicalSources)
{
composed += "\n\n" + CitationRules + "\n\n" + MedicalCitationKnowledge.Prompt;
}
// This is an iOS-specific scope rule and is intentionally added to every
// route, while the large medical source directory is only added to advice routes.
return composed + "\n\n" + CurrentScopeRules;
}
private const string CitationRules = """
- //
- PMIDDOI URL
-
[来源编号]
URL
-
- /
-
""";
private const string CurrentScopeRules = """
iOS
-
- app://device 链接,禁止引导用户使用蓝牙设备功能。
- AI
- iOS 线 search_medical_knowledge使
- health_entrymedication_entryexercise_entrypersonal_query general_chat medical_consultation使
""";
/// <summary>
/// 获取指定 Agent 的 System Prompt
/// Compatibility entry for older clients that still address a specific agent.
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
/// </summary>
public string GetSystemPrompt(AgentType agentType)
public string GetSystemPrompt(AgentType agentType) => agentType switch
{
var prompt = agentType switch
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
AgentType.Health => HealthDataPrompt,
AgentType.Diet => DietPrompt,
AgentType.Medication => MedicationPrompt,
AgentType.Report => ReportPrompt,
AgentType.Exercise => ExercisePrompt,
AgentType.Unified => UnifiedPrompt,
_ => DefaultPrompt
AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]),
AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]),
AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]),
AgentType.Consultation => ComposeForIntents(["medical_consultation"]),
AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]),
AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]),
AgentType.Unified => ComposeForIntents([
"health_entry",
"medication_entry",
"exercise_entry",
"personal_query",
"medical_consultation",
"report_analysis",
"diet_analysis",
"general_chat",
]),
_ => ComposeForIntents(["general_chat", "medical_consultation"]),
};
return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}";
private static string Compose(IEnumerable<string> sections) =>
string.Join(
"\n\n",
sections
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(Read)
.Where(content => !string.IsNullOrWhiteSpace(content)));
private static string NormalizeIntent(string? intent) =>
intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? "";
private static string Read(string path) =>
PromptCache.TryGetValue(NormalizePath(path), out var content)
? content
: throw new InvalidOperationException($"AI prompt resource not found: {path}");
private static IReadOnlyDictionary<string, string> LoadPrompts()
{
const string marker = ".AI.Prompts.";
var prompts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var resourceName in PromptAssembly.GetManifestResourceNames())
{
var markerIndex = resourceName.IndexOf(marker, StringComparison.Ordinal);
if (markerIndex < 0 || !resourceName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
continue;
using var stream = PromptAssembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Unable to open AI prompt resource: {resourceName}");
using var reader = new StreamReader(stream);
var relative = resourceName[(markerIndex + marker.Length)..];
var lastDot = relative.LastIndexOf(".md", StringComparison.OrdinalIgnoreCase);
relative = relative[..lastDot].Replace('.', '/') + ".md";
prompts[NormalizePath(relative)] = reader.ReadToEnd().Trim();
}
return prompts;
}
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健康管家
1.
2.
3.
4.
-
-
- /
- 怀
""";
private const string ConsultationPrompt = """
AI
1.
2. 2-3
3.
4.
5. >160/100>120<50
6.
7.
""";
private const string HealthDataPrompt = """
1. ////
2. + record_health_data
3. "血压120/80血糖6.2血氧97" record_health_data
4. "早上血压120下午血压130"recorded_at
5. "120""收缩压还是血糖?"
6.
7.
8. record_health_data
- 90-139 mmHg 60-89 mmHg
- 60-100 /
- 3.9-6.1 mmol/L
- 95-100%
""";
private const string DietPrompt = """
1. VLM食物识别结果后 check_archive
2. "能不能吃"
-
- //
- 尿
3. 1-5
4. PCI术后/
5. + +
6. ///
""";
private const string MedicationPrompt = """
1. manage_medication(action="query")
2. ///
3. "早饭后"
4. manage_medication(action="create")
5.
6.
""";
private const string ReportPrompt = """
1.
2. //
3.
4. "AI预解读待医生确认"
5. /CT"需医生人工审阅"
""";
private const string ExercisePrompt = """
1. //
2.
3.
4.
5.
""";
private const string UnifiedPrompt = """
AI
1. //// record_health_data
2. 使
3. manage_medication
4. manage_exercise
5. / check_archive / manage_archive
6. query_health_records
manage_exercise
- 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
-
-
- record_health_data98%
- record_health_data116/89
-
-
- record_health_data
-
- pendingConfirmation=true
-
- ///
- 2-5
-
-
""";
private static string NormalizePath(string path) =>
path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
}

View File

@@ -16,25 +16,32 @@ public sealed class AdminService(AppDbContext db) : IAdminService
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 phone = command.Phone.Trim();
var name = command.Name.Trim();
if (string.IsNullOrWhiteSpace(phone)) return Error(40001, "手机号不能为空");
if (string.IsNullOrWhiteSpace(name)) return Error(40002, "姓名不能为空");
if (await _db.Users.AnyAsync(x => x.Phone == phone, ct) ||
await _db.Doctors.AnyAsync(x => x.Phone == phone, ct))
return Error(40003, "该手机号已被其他账号使用");
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,
Id = Guid.NewGuid(), Name = name, Title = command.Title?.Trim(), Department = command.Department?.Trim(),
Phone = phone, ProfessionalDirection = command.ProfessionalDirection?.Trim(), IsActive = true, CreatedAt = DateTime.UtcNow,
};
var user = new User
{
Id = Guid.NewGuid(), Phone = phone, Role = "Doctor", Name = name,
CreatedAt = DateTime.UtcNow, UpdatedAt = 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,
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = name,
Title = doctor.Title, Department = doctor.Department, IsActive = true,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
});
}
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.Name });
}
@@ -43,11 +50,43 @@ public sealed class AdminService(AppDbContext db) : IAdminService
{
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;
var profile = await _db.DoctorProfiles.Include(x => x.User)
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
var user = profile.User;
if (command.Phone != null)
{
var phone = command.Phone.Trim();
if (phone.Length == 0) return Error(40001, "手机号不能为空");
if (await _db.Users.AnyAsync(x => x.Id != user.Id && x.Phone == phone, ct) ||
await _db.Doctors.AnyAsync(x => x.Id != id && x.Phone == phone, ct))
return Error(40003, "该手机号已被其他账号使用");
doctor.Phone = phone;
user.Phone = phone;
}
if (command.Name != null)
{
var name = command.Name.Trim();
if (name.Length == 0) return Error(40002, "姓名不能为空");
doctor.Name = name;
user.Name = name;
profile.Name = name;
}
if (command.Title != null)
{
doctor.Title = command.Title.Trim();
profile.Title = doctor.Title;
}
if (command.Department != null)
{
doctor.Department = command.Department.Trim();
profile.Department = doctor.Department;
}
if (command.ProfessionalDirection != null)
doctor.ProfessionalDirection = command.ProfessionalDirection.Trim();
user.UpdatedAt = DateTime.UtcNow;
profile.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id });
}
@@ -56,7 +95,11 @@ public sealed class AdminService(AppDbContext db) : IAdminService
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
var profile = await _db.DoctorProfiles.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
doctor.IsActive = !doctor.IsActive;
profile.IsActive = doctor.IsActive;
profile.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.IsActive });
}
@@ -65,7 +108,22 @@ public sealed class AdminService(AppDbContext db) : IAdminService
{
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);
if (await _db.Users.AnyAsync(x => x.Role == "User" && x.DoctorId == id, ct))
return Error(40004, "该医生仍有绑定患者,请先停用,不能直接删除");
if (await _db.Consultations.AnyAsync(x => x.DoctorId == id, ct))
return Error(40005, "该医生已有问诊记录,只能停用,不能删除");
var profile = await _db.DoctorProfiles.Include(x => x.User)
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
if (profile != null)
{
var user = profile.User;
var refreshTokens = await _db.RefreshTokens.Where(x => x.UserId == user.Id).ToListAsync(ct);
_db.RefreshTokens.RemoveRange(refreshTokens);
_db.DoctorProfiles.Remove(profile);
_db.Users.Remove(user);
}
_db.Doctors.Remove(doctor);
await _db.SaveChangesAsync(ct);
return Ok(new { success = true });

View File

@@ -88,14 +88,39 @@ public sealed class AuthService(
{
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } });
return new AuthResult(0, new
{
tokens.accessToken,
tokens.refreshToken,
user = new
{
id = AdminId,
phone = AdminPhone,
role = "Admin",
name = "管理员"
}
});
}
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 } });
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")
}
});
}
public async Task LogoutAsync(string refreshToken, CancellationToken ct)

View File

@@ -8,8 +8,9 @@ public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository
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);
// 日历的日期边界按北京时间计算,数据库仍统一使用 UTC。
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
var medications = await _db.Medications
.Where(m => m.UserId == userId && m.IsActive)

View File

@@ -956,6 +956,52 @@ namespace Health.Infrastructure.Data.Migrations
b.ToTable("VerificationCodes");
});
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiEntryDraftRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConversationId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntryType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MissingFields")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ConversationId", "EntryType", "Status", "ExpiresAt");
b.ToTable("AiEntryDrafts", (string)null);
});
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,19 @@
namespace Health.Infrastructure.Data.Records;
/// <summary>
/// Stores an incomplete AI-assisted entry until the user provides every required field.
/// This is deliberately separate from confirmed health, medication and exercise data.
/// </summary>
public sealed class AiEntryDraftRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid ConversationId { get; set; }
public string EntryType { get; set; } = string.Empty;
public string Payload { get; set; } = "{}";
public string MissingFields { get; set; } = "[]";
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

@@ -37,6 +37,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
public DbSet<AiEntryDraftRecord> AiEntryDrafts => Set<AiEntryDraftRecord>();
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
@@ -194,6 +195,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
e.Property(x => x.Arguments).HasColumnType("jsonb");
});
builder.Entity<AiEntryDraftRecord>(e =>
{
e.ToTable("AiEntryDrafts");
e.HasIndex(x => new { x.UserId, x.ConversationId, x.EntryType, x.Status, x.ExpiresAt });
e.Property(x => x.EntryType).HasMaxLength(32);
e.Property(x => x.Status).HasMaxLength(32);
e.Property(x => x.Payload).HasColumnType("jsonb");
e.Property(x => x.MissingFields).HasColumnType("jsonb");
});
builder.Entity<ReportAnalysisTaskRecord>(e =>
{
e.ToTable("ReportAnalysisTasks");

View File

@@ -19,16 +19,16 @@ public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository
.Take(limit)
.ToListAsync(ct);
public async Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) =>
await _db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.StartDate)
.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);

View File

@@ -0,0 +1,64 @@
namespace Health.Infrastructure.Files;
public static class UserUploadPathResolver
{
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf"
};
public static string? Resolve(Guid userId, string value)
{
if (userId == Guid.Empty || string.IsNullOrWhiteSpace(value)) return null;
try
{
var path = Uri.TryCreate(value, UriKind.Absolute, out var absolute)
? absolute.AbsolutePath
: value.Split('?', 2)[0];
path = Uri.UnescapeDataString(path);
string? fileName;
if (path == Path.GetFileName(path))
{
fileName = path;
}
else
{
fileName = ExtractAfter(path, "/api/files/content/");
if (fileName == null)
{
var legacyPrefix = $"/uploads/users/{userId:N}/";
fileName = ExtractAfter(path, legacyPrefix);
}
}
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
if (!AllowedExtensions.Contains(Path.GetExtension(fileName))) return null;
if (!Guid.TryParse(Path.GetFileNameWithoutExtension(fileName), out _)) return null;
var root = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"uploads",
"users",
userId.ToString("N")));
var candidate = Path.GetFullPath(Path.Combine(root, fileName));
return candidate.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
? candidate
: null;
}
catch (ArgumentException)
{
return null;
}
catch (UriFormatException)
{
return null;
}
}
private static string? ExtractAfter(string path, string prefix)
{
var index = path.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
return index < 0 ? null : path[(index + prefix.Length)..];
}
}

View File

@@ -17,6 +17,10 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="AI\Prompts\**\*.md" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -30,15 +30,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit
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

View File

@@ -19,8 +19,24 @@ public sealed class LocalReportFileStorage : IReportFileStorage
return new StoredReportFile($"/uploads/reports/{fileName}", filePath);
}
public string GetLocalFilePath(string fileUrl) =>
Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/'));
public string GetLocalFilePath(string fileUrl)
{
var reportsRoot = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"uploads",
"reports"));
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
? absolute.AbsolutePath
: fileUrl.Split('?', 2)[0];
var fileName = Path.GetFileName(Uri.UnescapeDataString(path));
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName))
return Path.Combine(reportsRoot, "__invalid__");
var candidate = Path.GetFullPath(Path.Combine(reportsRoot, fileName));
return candidate.StartsWith(reportsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
? candidate
: Path.Combine(reportsRoot, "__invalid__");
}
public bool Exists(string filePath) =>
File.Exists(filePath);

View File

@@ -1,3 +1,4 @@
using Health.Application.AI;
using Health.Application.Reports;
using Health.Application.Notifications;
using Health.Infrastructure.AI;
@@ -82,7 +83,7 @@ public sealed class ReportAnalysisService(
throw new InvalidOperationException("报告图片文件不存在或无法读取");
var prompt = """
@@ -128,7 +129,7 @@ public sealed class ReportAnalysisService(
}
var prompt = $$"""
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。
你是报告结构化提取助手。下面是用户上传 PDF 中提取出的文字。
PDF 文本:
{{text}}
@@ -180,31 +181,32 @@ public sealed class ReportAnalysisService(
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
{
var prompt = $"""
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读
你是报告整理助手。请根据以下检验指标,整理一份报告摘要
检测指标:{indicatorsJson}
要求:
1. 总字数200-300字
2. 先总结整体情况
3. 出异常指标及其可能原因,但不要给出确定诊断
4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议
5. 如指标明显异常或存在急症风险,优先建议及时就医
6. 末尾提醒"AI预解读"
2. 先总结整体情况(指标项数、异常项数)
3. 出异常指标及其数值和参考范围,客观说明"",不解释可能原因
4. /
5.
6. "以上为AI整理不能替代医生诊断和治疗建议"
7. 使 S-REPORT-01 S-REPORT-02
JSON格式
JSON格式
""";
var messages = new List<ChatMessage>
{
new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" },
new() { Role = "system", Content = "你是报告整理助手,不替代医生诊断。所有指标解读请用户咨询医生。\n\n" + MedicalCitationKnowledge.Prompt },
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
? $"{summary}\n\n{MedicalCitationKnowledge.FixedReportCitation.Trim()}"
: throw new InvalidOperationException("AI 未返回有效解读内容");
}

View File

@@ -0,0 +1,389 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Health.Application.Speech;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Speech;
public sealed class DashScopeSpeechRecognitionProxy : IRealtimeSpeechRecognitionProxy
{
private const int SampleRate = 16_000;
private const int BytesPerSecond = SampleRate * 2;
private readonly string _apiKey;
private readonly string _model;
private readonly string _workspaceId;
private readonly Uri? _endpoint;
private readonly int _maxDurationSeconds;
private readonly ILogger<DashScopeSpeechRecognitionProxy> _logger;
public DashScopeSpeechRecognitionProxy(
IConfiguration configuration,
ILogger<DashScopeSpeechRecognitionProxy> logger)
{
_apiKey = FirstConfigured(
configuration["DASHSCOPE_ASR_API_KEY"],
configuration["VLM_API_KEY"],
configuration["QWEN_API_KEY"]);
_model = configuration["DASHSCOPE_ASR_MODEL"]?.Trim() is { Length: > 0 } model
? model
: "fun-asr-realtime";
_workspaceId = configuration["DASHSCOPE_ASR_WORKSPACE_ID"]?.Trim() ?? "";
var configuredMaxDuration = int.TryParse(
configuration["DASHSCOPE_ASR_MAX_DURATION_SECONDS"],
out var parsedMaxDuration)
? parsedMaxDuration
: 60;
_maxDurationSeconds = Math.Clamp(configuredMaxDuration, 10, 180);
var configuredEndpoint = configuration["DASHSCOPE_ASR_WEBSOCKET_URL"]?.Trim();
var endpoint = !string.IsNullOrWhiteSpace(configuredEndpoint)
? configuredEndpoint
: !string.IsNullOrWhiteSpace(_workspaceId)
? $"wss://{_workspaceId}.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference"
: "wss://dashscope.aliyuncs.com/api-ws/v1/inference";
if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && uri.Scheme == "wss")
_endpoint = uri;
_logger = logger;
}
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(_apiKey) && _endpoint is not null;
private static string FirstConfigured(params string?[] values) =>
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? "";
public async Task ProxyAsync(
WebSocket clientSocket,
CancellationToken cancellationToken)
{
if (!IsConfigured)
throw new InvalidOperationException("实时语音识别服务尚未配置");
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(_maxDurationSeconds + 15));
var ct = timeoutCts.Token;
using var cloudSocket = new ClientWebSocket();
cloudSocket.Options.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
cloudSocket.Options.SetRequestHeader("User-Agent", "XiaomaiHealth/1.0");
if (!string.IsNullOrWhiteSpace(_workspaceId))
cloudSocket.Options.SetRequestHeader("X-DashScope-WorkSpace", _workspaceId);
await cloudSocket.ConnectAsync(_endpoint!, ct);
var taskId = Guid.NewGuid().ToString();
await SendJsonAsync(cloudSocket, new
{
header = new
{
action = "run-task",
task_id = taskId,
streaming = "duplex",
},
payload = new
{
task_group = "audio",
task = "asr",
function = "recognition",
model = _model,
parameters = new
{
format = "pcm",
sample_rate = SampleRate,
language_hints = new[] { "zh" },
semantic_punctuation_enabled = false,
max_sentence_silence = 800,
heartbeat = true,
},
input = new { },
},
}, ct);
await WaitForTaskStartedAsync(cloudSocket, taskId, ct);
await SendClientEventAsync(clientSocket, new { type = "ready" }, ct);
var clientPump = PumpClientAudioAsync(
clientSocket,
cloudSocket,
taskId,
_maxDurationSeconds,
ct);
var cloudPump = PumpCloudResultsAsync(cloudSocket, clientSocket, ct);
var completed = await Task.WhenAny(clientPump, cloudPump);
if (completed == cloudPump)
{
await cloudPump;
timeoutCts.Cancel();
}
else
{
var action = await clientPump;
if (action == ClientAction.Finish)
{
try
{
await cloudPump.WaitAsync(TimeSpan.FromSeconds(12), ct);
}
catch (TimeoutException)
{
await SendClientEventAsync(
clientSocket,
new { type = "error", message = "语音识别超时,请重试" },
CancellationToken.None);
}
catch (OperationCanceledException)
{
// 客户端断开或硬超时,无需再给客户端发消息
}
}
else
{
timeoutCts.Cancel();
}
}
await CloseSocketQuietlyAsync(cloudSocket);
await CloseSocketQuietlyAsync(clientSocket);
}
private static async Task WaitForTaskStartedAsync(
WebSocket cloudSocket,
string taskId,
CancellationToken ct)
{
while (true)
{
var message = await ReceiveMessageAsync(cloudSocket, ct);
if (message is null)
throw new InvalidOperationException("语音识别服务提前断开");
if (message.Value.Type != WebSocketMessageType.Text) continue;
using var json = JsonDocument.Parse(message.Value.Payload);
var header = json.RootElement.GetProperty("header");
var eventName = header.GetProperty("event").GetString();
var responseTaskId = header.TryGetProperty("task_id", out var id)
? id.GetString()
: null;
if (eventName == "task-started" && responseTaskId == taskId) return;
if (eventName == "task-failed")
{
var error = header.TryGetProperty("error_message", out var errorMessage)
? errorMessage.GetString()
: "语音识别启动失败";
throw new InvalidOperationException(error);
}
}
}
private static async Task<ClientAction> PumpClientAudioAsync(
WebSocket clientSocket,
WebSocket cloudSocket,
string taskId,
int maxDurationSeconds,
CancellationToken ct)
{
var maxBytes = (long)BytesPerSecond * maxDurationSeconds;
long audioBytes = 0;
while (!ct.IsCancellationRequested &&
clientSocket.State == WebSocketState.Open)
{
var message = await ReceiveMessageAsync(clientSocket, ct);
if (message is null) return ClientAction.Disconnect;
if (message.Value.Type == WebSocketMessageType.Binary)
{
audioBytes += message.Value.Payload.Length;
if (audioBytes > maxBytes)
{
// 达到时长上限:主动结束并让阿里云返回已识别的结果,
// 而不是直接断开。客户端松手后会拿到这段文字。
await SendJsonAsync(cloudSocket, new
{
header = new
{
action = "finish-task",
task_id = taskId,
streaming = "duplex",
},
payload = new { input = new { } },
}, ct);
return ClientAction.Finish;
}
await cloudSocket.SendAsync(
message.Value.Payload,
WebSocketMessageType.Binary,
true,
ct);
continue;
}
if (message.Value.Type != WebSocketMessageType.Text) continue;
using var json = JsonDocument.Parse(message.Value.Payload);
var action = json.RootElement.TryGetProperty("action", out var actionElement)
? actionElement.GetString()
: null;
if (action == "cancel") return ClientAction.Cancel;
if (action != "finish") continue;
await SendJsonAsync(cloudSocket, new
{
header = new
{
action = "finish-task",
task_id = taskId,
streaming = "duplex",
},
payload = new { input = new { } },
}, ct);
return ClientAction.Finish;
}
return ClientAction.Disconnect;
}
private async Task PumpCloudResultsAsync(
WebSocket cloudSocket,
WebSocket clientSocket,
CancellationToken ct)
{
var committed = new StringBuilder();
var latest = "";
while (!ct.IsCancellationRequested &&
cloudSocket.State == WebSocketState.Open)
{
var message = await ReceiveMessageAsync(cloudSocket, ct);
if (message is null) return;
if (message.Value.Type != WebSocketMessageType.Text) continue;
using var json = JsonDocument.Parse(message.Value.Payload);
var root = json.RootElement;
if (!root.TryGetProperty("header", out var header)) continue;
var eventName = header.TryGetProperty("event", out var eventElement)
? eventElement.GetString()
: null;
if (eventName == "result-generated")
{
if (!TryReadSentence(root, out var sentence, out var sentenceEnd))
continue;
if (sentenceEnd && !string.IsNullOrWhiteSpace(sentence))
committed.Append(sentence);
latest = sentenceEnd
? committed.ToString()
: committed + sentence;
await SendClientEventAsync(clientSocket, new
{
type = "partial",
text = latest,
sentenceEnd,
}, ct);
continue;
}
if (eventName == "task-finished")
{
await SendClientEventAsync(
clientSocket,
new { type = "done", text = latest.Trim() },
ct);
return;
}
if (eventName == "task-failed")
{
var error = header.TryGetProperty("error_message", out var errorElement)
? errorElement.GetString()
: "语音识别失败";
_logger.LogWarning("DashScope ASR task failed: {Error}", error);
await SendClientEventAsync(
clientSocket,
new { type = "error", message = "语音识别失败,请重试" },
ct);
return;
}
}
}
private static bool TryReadSentence(
JsonElement root,
out string text,
out bool sentenceEnd)
{
text = "";
sentenceEnd = false;
if (!root.TryGetProperty("payload", out var payload) ||
!payload.TryGetProperty("output", out var output) ||
!output.TryGetProperty("sentence", out var sentence))
return false;
text = sentence.TryGetProperty("text", out var textElement)
? textElement.GetString() ?? ""
: "";
sentenceEnd = sentence.TryGetProperty("sentence_end", out var endElement) &&
endElement.ValueKind == JsonValueKind.True;
return true;
}
private static Task SendClientEventAsync(
WebSocket socket,
object value,
CancellationToken ct) => SendJsonAsync(socket, value, ct);
private static async Task SendJsonAsync(
WebSocket socket,
object value,
CancellationToken ct)
{
if (socket.State != WebSocketState.Open) return;
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
}
private static async Task<(WebSocketMessageType Type, byte[] Payload)?>
ReceiveMessageAsync(WebSocket socket, CancellationToken ct)
{
var buffer = new byte[16 * 1024];
using var stream = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close) return null;
stream.Write(buffer, 0, result.Count);
if (stream.Length > 2 * 1024 * 1024)
throw new InvalidOperationException("WebSocket 消息过大");
if (result.EndOfMessage)
return (result.MessageType, stream.ToArray());
}
}
private static async Task CloseSocketQuietlyAsync(WebSocket socket)
{
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
return;
try
{
await socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"completed",
CancellationToken.None);
}
catch (WebSocketException)
{
// 对端可能已经主动断开。
}
}
private enum ClientAction
{
Finish,
Cancel,
Disconnect,
}
}

View File

@@ -9,14 +9,23 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
{
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase);
foreach (var metadataJson in references.ConversationMetadataJson)
AddMetadataUrls(fileUrls, metadataJson);
foreach (var fileUrl in fileUrls)
// 正式报告路径来自服务端生成的报告记录,只允许删除 reports 目录中的文件。
foreach (var fileUrl in references.FileUrls)
{
ct.ThrowIfCancellationRequested();
var localPath = ResolveLocalPath(fileUrl);
var localPath = ResolveReportPath(fileUrl);
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
}
// 对话元数据可能包含客户端传入的 URL只允许解析当前账号自己的目录。
var attachmentUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var metadataJson in references.ConversationMetadataJson)
AddMetadataUrls(attachmentUrls, metadataJson);
foreach (var fileUrl in attachmentUrls)
{
ct.ThrowIfCancellationRequested();
var localPath = ResolveOwnedAttachmentPath(userId, fileUrl);
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
}
@@ -52,31 +61,65 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value);
}
private string? ResolveLocalPath(string fileUrl)
private string? ResolveReportPath(string fileUrl)
{
var urlPath = fileUrl;
if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri))
urlPath = absoluteUri.AbsolutePath;
var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase);
if (uploadsIndex < 0) return null;
string relativePath;
try
{
relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]);
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
? absolute.AbsolutePath
: fileUrl.Split('?', 2)[0];
var prefixIndex = path.IndexOf("/uploads/reports/", StringComparison.OrdinalIgnoreCase);
if (prefixIndex < 0) return null;
var fileName = Uri.UnescapeDataString(path[(prefixIndex + "/uploads/reports/".Length)..]);
return ResolveInside(Path.Combine(_uploadsRoot, "reports"), fileName);
}
catch (ArgumentException)
{
return null;
}
catch (UriFormatException)
{
return null;
}
}
var queryIndex = relativePath.IndexOfAny(['?', '#']);
if (queryIndex >= 0) relativePath = relativePath[..queryIndex];
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
private string? ResolveOwnedAttachmentPath(Guid userId, string fileUrl)
{
try
{
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
? absolute.AbsolutePath
: fileUrl.Split('?', 2)[0];
path = Uri.UnescapeDataString(path);
var protectedPrefix = "/api/files/content/";
var protectedIndex = path.IndexOf(protectedPrefix, StringComparison.OrdinalIgnoreCase);
var legacyPrefix = $"/uploads/users/{userId:N}/";
var legacyIndex = path.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
var fileName = protectedIndex >= 0
? path[(protectedIndex + protectedPrefix.Length)..]
: legacyIndex >= 0
? path[(legacyIndex + legacyPrefix.Length)..]
: null;
return fileName == null
? null
: ResolveInside(Path.Combine(_uploadsRoot, "users", userId.ToString("N")), fileName);
}
catch (ArgumentException)
{
return null;
}
catch (UriFormatException)
{
return null;
}
}
var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath));
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
private static string? ResolveInside(string root, string fileName)
{
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
var fullRoot = Path.GetFullPath(root);
var fullPath = Path.GetFullPath(Path.Combine(fullRoot, fileName));
var rootPrefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,20 @@ public static class DoctorEndpoints
return (startUtc, startUtc.AddDays(1));
}
private static DateTime ParseBeijingDateTimeAsUtc(string value)
{
if (DateTimeOffset.TryParse(value, out var offset) &&
(value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) ||
value.LastIndexOf('+') > 9 ||
value.LastIndexOf('-') > 9))
return offset.UtcDateTime;
var beijing = DateTime.Parse(value);
return DateTime.SpecifyKind(
DateTime.SpecifyKind(beijing, DateTimeKind.Unspecified).AddHours(-8),
DateTimeKind.Utc);
}
public static void MapDoctorEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/doctor").RequireAuthorization();
@@ -105,6 +119,7 @@ public static class DoctorEndpoints
doctorHospital = profile.Hospital,
doctorAvatar = profile.AvatarUrl,
doctorOnline = profile.IsOnline,
doctorActive = profile.IsActive,
stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps },
pendingConsultations,
pendingReports = pendingReportList,
@@ -303,7 +318,7 @@ public static class DoctorEndpoints
query = query.Where(r => r.Status == s);
var reports = await query.OrderByDescending(r => r.CreatedAt)
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
.ToListAsync();
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
});
@@ -316,7 +331,7 @@ public static class DoctorEndpoints
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId)
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
.FirstOrDefaultAsync();
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
return Results.Ok(new { code = 0, data = report, message = (string?)null });
@@ -390,7 +405,7 @@ public static class DoctorEndpoints
Title = json.RootElement.GetProperty("title").GetString() ?? "",
DoctorName = profile.Name,
Department = profile.Department,
ScheduledAt = DateTime.Parse(json.RootElement.GetProperty("scheduledAt").GetString()!),
ScheduledAt = ParseBeijingDateTimeAsUtc(json.RootElement.GetProperty("scheduledAt").GetString()!),
Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null,
Status = FollowUpStatus.Upcoming,
CreatedAt = DateTime.UtcNow
@@ -414,7 +429,7 @@ public static class DoctorEndpoints
var body = await reader.ReadToEndAsync(ct);
var json = System.Text.Json.JsonDocument.Parse(body);
if (json.RootElement.TryGetProperty("title", out var t)) followUp.Title = t.GetString() ?? followUp.Title;
if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = DateTime.Parse(sa.GetString()!);
if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = ParseBeijingDateTimeAsUtc(sa.GetString()!);
if (json.RootElement.TryGetProperty("notes", out var no)) followUp.Notes = no.GetString();
if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse<FollowUpStatus>(st.GetString(), out var fs)) followUp.Status = fs;
await db.SaveChangesAsync(ct);

View File

@@ -1,3 +1,5 @@
using Health.Infrastructure.Files;
namespace Health.WebApi.Endpoints;
public static class FileEndpoints
@@ -47,24 +49,48 @@ public static class FileEndpoints
var storedName = $"{fileId}{ext}";
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
await using var stream = new FileStream(filePath, FileMode.Create);
await using var stream = new FileStream(filePath, FileMode.CreateNew);
await file.CopyToAsync(stream, ct);
results.Add(new
{
id = fileId,
name = file.FileName,
size = file.Length,
url = $"/uploads/users/{userDirectoryName}/{storedName}",
url = $"/api/files/content/{storedName}",
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
});
}
return Results.Ok(new { code = 0, data = results, message = (string?)null });
});
group.MapGet("/content/{fileName}", (string fileName, HttpContext http) =>
{
var userId = GetUserId(http);
var filePath = UserUploadPathResolver.Resolve(userId, fileName);
if (filePath == null || !File.Exists(filePath))
return Results.NotFound();
return Results.File(
filePath,
ContentTypeFor(filePath),
enableRangeProcessing: true);
});
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
? id
: Guid.Empty;
private static string ContentTypeFor(string path) =>
Path.GetExtension(path).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}

View File

@@ -42,15 +42,6 @@ public static class MedicationEndpoints
: Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" });
});
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var taken = await medications.ToggleTodayTakenAsync(userId, id, ct);
if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" });
return Results.Ok(new { code = 0, data = new { taken }, message = (string?)null });
});
group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);

View File

@@ -24,6 +24,47 @@ public static class ReportEndpoints
: Results.Ok(new { code = 0, data = report, message = (string?)null });
});
group.MapGet("/{id:guid}/file", async (
Guid id,
HttpContext http,
AppDbContext db,
IReportFileStorage fileStorage,
CancellationToken ct) =>
{
var currentUserId = GetUserId(http);
var report = await db.Reports
.Include(r => r.User)
.FirstOrDefaultAsync(r => r.Id == id, ct);
if (report == null)
return Results.NotFound();
var allowed = report.UserId == currentUserId;
if (!allowed && GetRole(http) == "Doctor")
{
var doctorId = await db.DoctorProfiles
.Where(profile => profile.UserId == currentUserId)
.Select(profile => profile.DoctorId)
.FirstOrDefaultAsync(ct);
allowed = doctorId != null && report.User.DoctorId == doctorId;
}
if (!allowed)
return Results.NotFound();
var filePath = fileStorage.GetLocalFilePath(report.FileUrl);
if (!fileStorage.Exists(filePath))
return Results.NotFound();
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
return Results.File(filePath, contentType, enableRangeProcessing: true);
});
group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
{
var userId = GetUserId(http);
@@ -66,4 +107,9 @@ public static class ReportEndpoints
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
private static string GetRole(HttpContext http) =>
http.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ??
http.User.FindFirst("Role")?.Value ??
"User";
}

View File

@@ -0,0 +1,69 @@
using System.Net.WebSockets;
using System.Text.Json;
using Health.Application.Speech;
namespace Health.WebApi.Endpoints;
public static class SpeechEndpoints
{
public static void MapSpeechEndpoints(this WebApplication app)
{
app.Map("/api/speech/realtime", async (
HttpContext http,
IRealtimeSpeechRecognitionProxy proxy,
ILoggerFactory loggerFactory) =>
{
if (!http.WebSockets.IsWebSocketRequest)
{
http.Response.StatusCode = StatusCodes.Status400BadRequest;
await http.Response.WriteAsJsonAsync(new
{
code = 40001,
data = (object?)null,
message = "该接口仅支持 WebSocket 连接",
});
return;
}
if (!proxy.IsConfigured)
{
http.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await http.Response.WriteAsJsonAsync(new
{
code = 50301,
data = (object?)null,
message = "实时语音识别服务尚未配置",
});
return;
}
using var socket = await http.WebSockets.AcceptWebSocketAsync();
try
{
await proxy.ProxyAsync(socket, http.RequestAborted);
}
catch (OperationCanceledException) when (http.RequestAborted.IsCancellationRequested)
{
// 客户端离开页面或断开网络。
}
catch (Exception ex)
{
loggerFactory.CreateLogger("SpeechEndpoints")
.LogError(ex, "Realtime speech proxy failed");
if (socket.State == WebSocketState.Open)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
type = "error",
message = "语音服务暂时不可用,请稍后重试",
});
await socket.SendAsync(
payload,
WebSocketMessageType.Text,
true,
CancellationToken.None);
}
}
}).RequireAuthorization(policy => policy.RequireRole("User"));
}
}

View File

@@ -20,11 +20,12 @@ public static class UserEndpoints
var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
var updated = await users.UpdateProfileAsync(
GetUserId(http),
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate),
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate, req.AvatarUrl),
ct);
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" });
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
var profile = await users.GetProfileAsync(GetUserId(http), ct);
return Results.Ok(new { code = 0, data = profile, message = (string?)null });
});
group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
@@ -66,7 +67,7 @@ public static class UserEndpoints
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate);
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate, string? AvatarUrl);
public sealed record UpdateArchiveRequest(
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
List<string>? Allergies, List<string>? DietRestrictions,

View File

@@ -16,6 +16,10 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
{
await _next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// 客户端或启动探针取消请求时,不再尝试写入错误响应。
}
catch (Health.Domain.ValidationException vex)
{
// 业务输入校验失败:返回 400message 为我们自己产生的提示,可安全展示
@@ -37,6 +41,7 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
{
// 生产环境不暴露内部异常详情
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
if (context.Response.HasStarted) return;
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/json";

View File

@@ -11,6 +11,7 @@ using Health.Application.Medications;
using Health.Application.Maintenance;
using Health.Application.Notifications;
using Health.Application.Reports;
using Health.Application.Speech;
using Health.Application.Users;
using Health.Infrastructure.AI;
using Health.Infrastructure.Admin;
@@ -25,6 +26,7 @@ using Health.Infrastructure.Medications;
using Health.Infrastructure.Maintenance;
using Health.Infrastructure.Notifications;
using Health.Infrastructure.Reports;
using Health.Infrastructure.Speech;
using Health.Infrastructure.Services;
using Health.Infrastructure.Users;
using Health.WebApi.BackgroundServices;
@@ -34,7 +36,6 @@ using Health.WebApi.Middleware;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens;
// 加载 .env 文件(开发环境)
@@ -111,6 +112,7 @@ builder.Services.AddSingleton<SmsService>();
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
builder.Services.AddSingleton<PromptManager>();
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
builder.Services.AddScoped<IAiEntryDraftStore, EfAiEntryDraftStore>();
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
builder.Services.AddScoped<IAdminService, AdminService>();
builder.Services.AddScoped<IAuthService, AuthService>();
@@ -151,6 +153,7 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
builder.Services.AddSingleton<IRealtimeSpeechRecognitionProxy, DashScopeSpeechRecognitionProxy>();
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
@@ -163,8 +166,10 @@ builder.Services.AddHttpClient<DeepSeekClient>(client =>
});
builder.Services.AddHttpClient<VisionClient>(client =>
{
client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
var baseUrl = builder.Configuration["VLM_BASE_URL"] ?? builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1";
var apiKey = builder.Configuration["VLM_API_KEY"] ?? builder.Configuration["QWEN_API_KEY"] ?? "";
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
@@ -206,18 +211,17 @@ app.UseMiddleware<ExceptionMiddleware>();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(20),
});
// 默认 wwwroot 静态文件(法律文档 H5 页面等)
app.UseDefaultFiles();
app.UseStaticFiles();
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadsPath);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploadsPath),
RequestPath = "/uploads"
});
// 用户上传文件不能作为静态目录公开;统一通过带鉴权和归属校验的 API 读取。
Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), "uploads"));
if (app.Environment.IsDevelopment())
app.MapOpenApi();
@@ -246,6 +250,7 @@ app.MapNotificationEndpoints();
app.MapDoctorEndpoints();
app.MapAdminEndpoints();
app.MapFollowUpEndpoints();
app.MapSpeechEndpoints();
// SignalR Hub
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");

View File

@@ -2,7 +2,8 @@
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -14,7 +14,8 @@
"JWT_AUDIENCE": "health-manager-app",
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
"DEEPSEEK_API_KEY": "sk-your-key-here",
"DEEPSEEK_MODEL": "deepseek-chat",
"DEEPSEEK_MODEL": "deepseek-v4-flash",
"ENABLE_MEDICAL_RAG": false,
"QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"QWEN_API_KEY": "sk-your-key-here",
"QWEN_VISION_MODEL": "qwen-vl-max",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -13,8 +13,10 @@ public sealed class AccountDeletionTests
var userId = Guid.NewGuid();
var userDirectory = Path.Combine(root, "users", userId.ToString("N"));
var reportPath = Path.Combine(root, "reports", "owned-report.pdf");
var chatImagePath = Path.Combine(root, "owned-chat.jpg");
var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().ToString("N"), "keep.jpg");
var chatFileName = $"{Guid.NewGuid()}.jpg";
var chatImagePath = Path.Combine(userDirectory, chatFileName);
var otherUserId = Guid.NewGuid();
var otherUserPath = Path.Combine(root, "users", otherUserId.ToString("N"), "keep.jpg");
var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt");
try
@@ -31,7 +33,7 @@ public sealed class AccountDeletionTests
var cleanup = new LocalAccountFileCleanup(root);
var references = new AccountFileReferences(
["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"],
["{\"imageUrl\":\"/uploads/owned-chat.jpg\"}"]);
[$"{{\"imageUrl\":\"/uploads/users/{userId:N}/{chatFileName}\"}}", $"{{\"imageUrl\":\"/uploads/users/{otherUserId:N}/keep.jpg\"}}"]);
await cleanup.DeleteAsync(userId, references, CancellationToken.None);

View File

@@ -0,0 +1,191 @@
using Health.Application.Admin;
using Health.Domain.Entities;
using Health.Infrastructure.Admin;
using Health.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Health.Tests;
public sealed class AdminServiceTests
{
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new AppDbContext(options);
}
[Fact]
public async Task AddDoctor_CreatesDoctorLoginAndProfileTogether()
{
await using var db = CreateDbContext();
var service = new AdminService(db);
var result = await service.AddDoctorAsync(
new AddDoctorCommand("13800138001", "王医生", "主任医师", "心内科", "高血压"),
CancellationToken.None);
Assert.Equal(0, result.Code);
var doctor = await db.Doctors.SingleAsync();
var user = await db.Users.SingleAsync();
var profile = await db.DoctorProfiles.SingleAsync();
Assert.Equal("Doctor", user.Role);
Assert.Equal(doctor.Id, profile.DoctorId);
Assert.Equal(user.Id, profile.UserId);
Assert.Equal(doctor.Name, user.Name);
Assert.Equal(doctor.Name, profile.Name);
Assert.Equal(doctor.Title, profile.Title);
Assert.Equal(doctor.Department, profile.Department);
}
[Fact]
public async Task AddDoctor_RejectsAPhoneAlreadyUsedByAnyAccount()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = Guid.NewGuid(),
Phone = "13800138002",
Role = "User",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var service = new AdminService(db);
var result = await service.AddDoctorAsync(
new AddDoctorCommand("13800138002", "李医生", null, null, null),
CancellationToken.None);
Assert.NotEqual(0, result.Code);
Assert.Empty(db.Doctors);
Assert.Empty(db.DoctorProfiles);
}
[Fact]
public async Task UpdateDoctor_SynchronizesDoctorLoginAndProfile()
{
await using var db = CreateDbContext();
var (doctor, user, profile) = await SeedDoctorAsync(db);
var service = new AdminService(db);
var result = await service.UpdateDoctorAsync(
doctor.Id,
new UpdateDoctorCommand("13800138999", "新姓名", "副主任医师", "神经内科", "脑血管"),
CancellationToken.None);
Assert.Equal(0, result.Code);
Assert.Equal("13800138999", doctor.Phone);
Assert.Equal("13800138999", user.Phone);
Assert.Equal("新姓名", doctor.Name);
Assert.Equal("新姓名", user.Name);
Assert.Equal("新姓名", profile.Name);
Assert.Equal("副主任医师", profile.Title);
Assert.Equal("神经内科", profile.Department);
}
[Fact]
public async Task ToggleDoctor_SynchronizesAvailabilityButKeepsLoginEnabled()
{
await using var db = CreateDbContext();
var (doctor, user, profile) = await SeedDoctorAsync(db);
var service = new AdminService(db);
var result = await service.ToggleDoctorAsync(doctor.Id, CancellationToken.None);
Assert.Equal(0, result.Code);
Assert.False(doctor.IsActive);
Assert.False(profile.IsActive);
Assert.Equal("Doctor", user.Role);
Assert.NotNull(await db.Users.FindAsync(user.Id));
}
[Fact]
public async Task DeleteDoctor_IsRejectedWhilePatientsAreBound()
{
await using var db = CreateDbContext();
var (doctor, user, profile) = await SeedDoctorAsync(db);
db.Users.Add(new User
{
Id = Guid.NewGuid(),
Phone = "13800138088",
Name = "患者",
Role = "User",
DoctorId = doctor.Id,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var service = new AdminService(db);
var result = await service.DeleteDoctorAsync(doctor.Id, CancellationToken.None);
Assert.NotEqual(0, result.Code);
Assert.NotNull(await db.Doctors.FindAsync(doctor.Id));
Assert.NotNull(await db.Users.FindAsync(user.Id));
Assert.NotNull(await db.DoctorProfiles.FindAsync(profile.Id));
}
[Fact]
public async Task DeleteUnusedDoctor_RemovesLoginProfileAndRefreshTokens()
{
await using var db = CreateDbContext();
var (doctor, user, _) = await SeedDoctorAsync(db);
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = "refresh-token",
ExpiresAt = DateTime.UtcNow.AddDays(1),
});
await db.SaveChangesAsync();
var service = new AdminService(db);
var result = await service.DeleteDoctorAsync(doctor.Id, CancellationToken.None);
Assert.Equal(0, result.Code);
Assert.Empty(db.Doctors);
Assert.Empty(db.DoctorProfiles);
Assert.Empty(db.Users);
Assert.Empty(db.RefreshTokens);
}
private static async Task<(Doctor Doctor, User User, DoctorProfile Profile)> SeedDoctorAsync(AppDbContext db)
{
var doctor = new Doctor
{
Id = Guid.NewGuid(),
Phone = "13800138003",
Name = "原姓名",
Title = "医师",
Department = "内科",
IsActive = true,
CreatedAt = DateTime.UtcNow,
};
var user = new User
{
Id = Guid.NewGuid(),
Phone = doctor.Phone,
Name = doctor.Name,
Role = "Doctor",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
var profile = new DoctorProfile
{
Id = Guid.NewGuid(),
DoctorId = doctor.Id,
UserId = user.Id,
Name = doctor.Name,
Title = doctor.Title,
Department = doctor.Department,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.AddRange(doctor, user, profile);
await db.SaveChangesAsync();
return (doctor, user, profile);
}
}

View File

@@ -6,6 +6,8 @@ using Health.Application.Exercises;
using Health.Domain;
using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Infrastructure.AI.AgentHandlers;
using System.Text.Json;
namespace Health.Tests;
@@ -95,6 +97,66 @@ public sealed class ApplicationServiceTests
Assert.Equal(1, repository.SaveCount);
}
[Fact]
public async Task HealthEntryBatch_ExecutesAllMetricsWithOneSave()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
using var arguments = JsonDocument.Parse(
"""
{
"metrics": [
{ "type": "blood_pressure", "systolic": 113, "diastolic": 86 },
{ "type": "heart_rate", "heart_rate": 80 },
{ "type": "glucose", "glucose": 5.6 }
]
}
""");
var result = await HealthDataAgentHandler.Execute(
"record_health_data_batch",
arguments.RootElement,
Guid.NewGuid(),
service,
CancellationToken.None);
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.True(resultJson.RootElement.GetProperty("success").GetBoolean());
Assert.Equal(3, resultJson.RootElement.GetProperty("record_ids").GetArrayLength());
Assert.Equal(3, resultJson.RootElement.GetProperty("items").GetArrayLength());
Assert.Equal(3, repository.Added.Count);
Assert.Equal(1, repository.SaveCount);
}
[Fact]
public async Task HealthEntryBatch_IncompleteMetricBlocksWholeBatch()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
using var arguments = JsonDocument.Parse(
"""
{
"metrics": [
{ "type": "blood_pressure", "systolic": 113 },
{ "type": "heart_rate", "heart_rate": 80 }
]
}
""");
var result = await HealthDataAgentHandler.Execute(
"record_health_data_batch",
arguments.RootElement,
Guid.NewGuid(),
service,
CancellationToken.None);
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.False(resultJson.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("舒张压", resultJson.RootElement.GetProperty("message").GetString());
Assert.Empty(repository.Added);
Assert.Equal(0, repository.SaveCount);
}
[Fact]
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
{
@@ -211,6 +273,24 @@ public sealed class ApplicationServiceTests
Assert.Equal(["medication", "exercise", "followup"], events);
}
[Fact]
public async Task Calendar_FollowUpUsesBeijingDateForUtcTimestamp()
{
var followUp = new FollowUp
{
Id = Guid.NewGuid(),
ScheduledAt = new DateTime(2026, 6, 18, 17, 0, 0, DateTimeKind.Utc),
Title = "北京时间复查"
};
var service = new CalendarService(new FakeCalendarRepository(
new CalendarDataSnapshot([], [], [followUp])));
var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None);
var target = Assert.Single(result);
Assert.Equal("2026-06-19", target.GetType().GetProperty("date")!.GetValue(target));
}
[Fact]
public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates()
{
@@ -252,6 +332,129 @@ public sealed class ApplicationServiceTests
Assert.Equal(2, current.Items.Count);
}
[Fact]
public async Task AiExerciseQuery_UpcomingScopeReturnsFuturePlanButTodayScopeDoesNot()
{
var userId = Guid.NewGuid();
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var repository = new FakeExerciseRepository();
var plan = new ExercisePlan
{
Id = Guid.NewGuid(),
UserId = userId,
StartDate = today.AddDays(2),
EndDate = today.AddDays(4),
ReminderTime = new TimeOnly(19, 0),
};
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(),
ScheduledDate = today.AddDays(2),
ExerciseType = "散步",
DurationMinutes = 30,
});
repository.SetPlan(plan);
var service = new ExerciseService(repository);
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
using var todayArgs = JsonDocument.Parse("""{"action":"query","scope":"today"}""");
var upcoming = await ExerciseAgentHandler.Execute(
"manage_exercise", upcomingArgs.RootElement, userId, service, CancellationToken.None);
var todayResult = await ExerciseAgentHandler.Execute(
"manage_exercise", todayArgs.RootElement, userId, service, CancellationToken.None);
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
using var todayJson = JsonDocument.Parse(JsonSerializer.Serialize(todayResult, jsonOptions));
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32());
}
[Fact]
public async Task AiExerciseQuery_AuthoritativeAnswerPreservesStoredExerciseType()
{
var userId = Guid.NewGuid();
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var repository = new FakeExerciseRepository();
var plan = new ExercisePlan
{
Id = Guid.NewGuid(),
UserId = userId,
StartDate = today,
EndDate = today.AddDays(6),
ReminderTime = new TimeOnly(15, 0),
};
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(),
ScheduledDate = today,
ExerciseType = "打篮球",
DurationMinutes = 30,
});
repository.SetPlan(plan);
var service = new ExerciseService(repository);
using var arguments = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
var result = await ExerciseAgentHandler.Execute(
"manage_exercise", arguments.RootElement, userId, service, CancellationToken.None);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
var answer = json.RootElement.GetProperty("authoritative_answer").GetString();
Assert.Contains("打篮球", answer);
Assert.DoesNotContain("打毽子", answer);
Assert.Contains("15:00", answer);
}
[Fact]
public void MedicationEntry_AcceptsPillCountAsDosage()
{
using var arguments = JsonDocument.Parse(
"""
{
"action": "create",
"name": "维生素D",
"dosage": "1粒",
"frequency": "Daily",
"time_of_day": ["12:00"],
"start_date": "2026-07-27",
"duration_days": 3
}
""");
var validationError = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
Assert.Null(validationError);
}
[Fact]
public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday()
{
var userId = Guid.NewGuid();
var service = new ExerciseService(new FakeExerciseRepository());
using var args = JsonDocument.Parse("""{"action":"query"}""");
var result = await ExerciseAgentHandler.Execute(
"manage_exercise", args.RootElement, userId, service, CancellationToken.None);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task AiFollowUpQuery_RequiresExplicitScopeInsteadOfAssumingNext()
{
using var args = JsonDocument.Parse("""{}""");
var result = await PatientReadAgentHandler.QueryFollowUpsAsync(
null!, Guid.NewGuid(), args.RootElement, CancellationToken.None);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task ExercisePlan_CheckInRejectsNonTodayItem()
{
@@ -360,9 +563,11 @@ public sealed class ApplicationServiceTests
public void SetPlan(ExercisePlan plan) => Plan = plan;
public Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []);
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>([]);
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
Plan != null && Plan.UserId == userId ? [Plan] : []);
public Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
Plan != null && Plan.UserId == userId ? [Plan] : []);
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan);
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) => Task.FromResult(Plan);
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId));
public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; }
public void Delete(ExercisePlan plan) { Plan = null; }

View File

@@ -1,4 +1,6 @@
using System.Text.Json;
using Health.Domain.Entities;
using Health.Infrastructure.Auth;
using Health.Infrastructure.Data;
using Health.Infrastructure.Services;
using Microsoft.EntityFrameworkCore;
@@ -118,6 +120,47 @@ public class AuthTests
Assert.NotNull(active);
}
[Fact]
public async Task Refresh_Should_Return_Stable_User_Identity()
{
using var db = CreateDbContext();
var config = CreateConfig();
var jwt = new JwtProvider(config);
var user = new User
{
Id = Guid.NewGuid(),
Phone = "13800138000",
Role = "User",
Name = "测试用户",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
var oldRefresh = jwt.GenerateRefreshToken();
db.Users.Add(user);
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = oldRefresh,
ExpiresAt = DateTime.UtcNow.AddDays(30)
});
await db.SaveChangesAsync();
var service = new AuthService(
db,
jwt,
new SmsService(),
new AppleTokenValidator(config));
var result = await service.RefreshAsync(oldRefresh, CancellationToken.None);
Assert.Equal(0, result.Code);
var data = JsonSerializer.SerializeToElement(result.Data);
var refreshedUser = data.GetProperty("user");
Assert.Equal(user.Id, refreshedUser.GetProperty("Id").GetGuid());
Assert.Equal(user.Phone, refreshedUser.GetProperty("Phone").GetString());
Assert.Equal(user.Role, refreshedUser.GetProperty("Role").GetString());
}
[Fact]
public async Task VerificationCode_Expired_Should_Fail_Login()
{

View File

@@ -0,0 +1,40 @@
using Health.Infrastructure.Files;
namespace Health.Tests;
public sealed class FilePathSecurityTests
{
private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private readonly string _fileName = "22222222-2222-2222-2222-222222222222.jpg";
[Fact]
public void ProtectedUrl_ResolvesInsideCurrentUserDirectory()
{
var path = UserUploadPathResolver.Resolve(
_userId,
$"/api/files/content/{_fileName}");
Assert.NotNull(path);
Assert.Contains(Path.Combine("users", _userId.ToString("N")), path);
Assert.EndsWith(_fileName, path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void LegacyUrl_OnlyResolvesForItsOwner()
{
var legacy = $"/uploads/users/{_userId:N}/{_fileName}";
Assert.NotNull(UserUploadPathResolver.Resolve(_userId, legacy));
Assert.Null(UserUploadPathResolver.Resolve(Guid.NewGuid(), legacy));
}
[Theory]
[InlineData("/api/files/content/../../secret.jpg")]
[InlineData("/api/files/content/not-a-guid.jpg")]
[InlineData("/api/files/content/22222222-2222-2222-2222-222222222222.exe")]
[InlineData("/uploads/reports/22222222-2222-2222-2222-222222222222.jpg")]
public void UnsafeOrUnownedPath_IsRejected(string value)
{
Assert.Null(UserUploadPathResolver.Resolve(_userId, value));
}
}

View File

@@ -1,5 +1,8 @@
using Health.Application.Medications;
using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Infrastructure.AI.AgentHandlers;
using System.Text.Json;
namespace Health.Tests;
@@ -32,21 +35,227 @@ public sealed class MedicationUpdateTests
Assert.True(repository.Saved);
}
private sealed class FakeMedicationRepository(Medication medication) : IMedicationRepository
[Fact]
public async Task AiOverview_DoesNotScheduleFutureMedicationForToday()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "未来用药",
IsActive = true,
StartDate = today.AddDays(2),
EndDate = today.AddDays(10),
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
};
var service = new MedicationService(new FakeMedicationRepository(medication));
var overview = await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None);
var plan = Assert.Single(overview);
Assert.Equal("upcoming", plan.Phase);
Assert.False(plan.ScheduledOnDate);
Assert.Empty(plan.Doses);
}
[Fact]
public async Task AiOverview_ReportsEachDoseInsteadOfAnyTakenAggregate()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "两顿药",
IsActive = true,
StartDate = today.AddDays(-1),
EndDate = today.AddDays(1),
Frequency = MedicationFrequency.TwiceDaily,
TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)],
};
var log = new MedicationLog
{
Id = Guid.NewGuid(),
UserId = medication.UserId,
MedicationId = medication.Id,
ScheduledTime = new TimeOnly(8, 0),
Status = MedicationLogStatus.Taken,
ConfirmedAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
};
var service = new MedicationService(new FakeMedicationRepository(medication, [log]));
var plan = Assert.Single(await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None));
Assert.Equal(2, plan.Doses.Count);
Assert.Equal("taken", plan.Doses.Single(dose => dose.ScheduledTime == "08:00").Status);
Assert.NotEqual("taken", plan.Doses.Single(dose => dose.ScheduledTime == "20:00").Status);
}
[Fact]
public async Task ConfirmDose_RejectsAClockTimeThatIsNotInTodaysPlan()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "阿司匹林",
IsActive = true,
StartDate = today,
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
};
var repository = new FakeMedicationRepository(medication);
var service = new MedicationService(repository);
var result = await service.ConfirmDoseAsync(
medication.UserId,
medication.Id,
new TimeOnly(8, 15),
MedicationLogStatus.Taken,
CancellationToken.None);
Assert.False(result);
Assert.Empty(repository.AddedLogs);
}
[Fact]
public void AiMedicationCreate_RejectsMissingFieldsInsteadOfUsingDefaults()
{
using var arguments = JsonDocument.Parse("""{"action":"create","name":""}""");
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
Assert.Equal("创建用药计划前需要确认每次剂量", error);
}
[Fact]
public void AiMedicationCreate_AcceptsCompleteFinitePlan()
{
using var arguments = JsonDocument.Parse("""
{
"action":"create",
"name":"阿司匹林",
"dosage":"100mg",
"frequency":"Daily",
"time_of_day":["08:00"],
"start_date":"2026-07-20",
"duration_days":30
}
""");
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
Assert.Null(error);
}
[Fact]
public async Task AiMedicationQuery_UpcomingScopeReturnsTomorrowPlanButCurrentScopeDoesNot()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "明天开始的药",
Dosage = "1片",
IsActive = true,
StartDate = today.AddDays(1),
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
};
var service = new MedicationService(new FakeMedicationRepository(medication));
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
using var currentArgs = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
var upcoming = await MedicationAgentHandler.Execute(
"manage_medication", upcomingArgs.RootElement, medication.UserId, service, CancellationToken.None);
var current = await MedicationAgentHandler.Execute(
"manage_medication", currentArgs.RootElement, medication.UserId, service, CancellationToken.None);
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
using var currentJson = JsonDocument.Parse(JsonSerializer.Serialize(current, jsonOptions));
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
Assert.True(upcomingJson.RootElement.GetProperty("has_upcoming_plans").GetBoolean());
Assert.Equal(1, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
Assert.Equal("明天开始的药", upcomingJson.RootElement.GetProperty("plans")[0].GetProperty("name").GetString());
Assert.Equal(0, currentJson.RootElement.GetProperty("count").GetInt32());
}
[Fact]
public async Task AiMedicationQuery_RequiresExplicitScopeInsteadOfAssumingToday()
{
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "测试药物",
IsActive = true,
StartDate = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
};
var service = new MedicationService(new FakeMedicationRepository(medication));
using var args = JsonDocument.Parse("""{"action":"query"}""");
var result = await MedicationAgentHandler.Execute(
"manage_medication", args.RootElement, medication.UserId, service, CancellationToken.None);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task AiMedicationQuery_SeparatesInactivePlansFromNaturallyEndedPlans()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var medication = new Medication
{
Id = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Name = "已停用药物",
IsActive = false,
StartDate = today.AddDays(-10),
EndDate = today.AddDays(10),
Frequency = MedicationFrequency.Daily,
TimeOfDay = [new TimeOnly(8, 0)],
};
var service = new MedicationService(new FakeMedicationRepository(medication));
using var inactiveArgs = JsonDocument.Parse("""{"action":"query","scope":"inactive_plans"}""");
using var endedArgs = JsonDocument.Parse("""{"action":"query","scope":"ended_plans"}""");
var inactive = await MedicationAgentHandler.Execute(
"manage_medication", inactiveArgs.RootElement, medication.UserId, service, CancellationToken.None);
var ended = await MedicationAgentHandler.Execute(
"manage_medication", endedArgs.RootElement, medication.UserId, service, CancellationToken.None);
using var inactiveJson = JsonDocument.Parse(JsonSerializer.Serialize(inactive));
using var endedJson = JsonDocument.Parse(JsonSerializer.Serialize(ended));
Assert.Equal(1, inactiveJson.RootElement.GetProperty("count").GetInt32());
Assert.Equal(0, endedJson.RootElement.GetProperty("count").GetInt32());
}
private sealed class FakeMedicationRepository(
Medication medication,
IReadOnlyList<MedicationLog>? logs = null) : IMedicationRepository
{
public bool Saved { get; private set; }
public List<MedicationLog> AddedLogs { get; } = [];
public Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<IReadOnlyList<MedicationLog>>([]);
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(logs ?? (IReadOnlyList<MedicationLog>)[]);
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null);
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId);
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false);
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask;
public Task AddLogAsync(MedicationLog log, CancellationToken ct) => Task.CompletedTask;
public Task AddLogAsync(MedicationLog log, CancellationToken ct) { AddedLogs.Add(log); return Task.CompletedTask; }
public void DeleteMedication(Medication value) { }
public void DeleteLog(MedicationLog log) { }
public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; }

View File

@@ -15,4 +15,33 @@ public sealed class PromptManagerTests
Assert.Contains("先回答问题,不调用 record_health_data", prompt);
Assert.Contains("帮我记录血压116/89", prompt);
}
[Fact]
public void UnifiedPrompt_RequiresFreshToolsForPersonalStatusAndKeepsArchiveReadOnly()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
Assert.Contains("必须先调用对应只读工具", prompt);
Assert.Contains("当前工具结果是最高优先级", prompt);
Assert.Contains("聊天中不修改健康档案", prompt);
Assert.DoesNotContain("健康档案查询/修改", prompt);
}
[Fact]
public void UnifiedPrompt_UsesACommonTemporalIntentModelForPlanQueries()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
Assert.Contains("时间意图 + 业务对象", prompt);
Assert.Contains("某一天的任务", prompt);
Assert.Contains("计划所处的生命周期", prompt);
Assert.Contains("不依赖某一句固定问法或某个单独关键词", prompt);
Assert.Contains("scope=\"upcoming_plans\"", prompt);
Assert.Contains("scope=\"ended_plans\"", prompt);
Assert.Contains("scope=\"inactive_plans\"", prompt);
Assert.Contains("scope=\"scheduled_date\"", prompt);
Assert.Contains("复查使用同一语义映射", prompt);
Assert.Contains("scope 必须明确传入", prompt);
Assert.DoesNotContain("患者背景只包含当前日期范围内的部分计划", prompt);
}
}

View File

@@ -1,233 +0,0 @@
# 小脉健康项目交接文档
更新时间2026-07-16
工作目录:`D:\health_project`
当前分支:`main`
## 1. 项目当前阶段
项目主体已经完成,包括 Flutter App 和 .NET 后端。目前处于:
> Android 本地真机测试、功能稳定优化、前端 UI 统一、合规准备,以及等待正式环境和资质流程。
当前不是“马上提交应用商店审核”的状态。
- Android 一直以本地真机为主要测试平台。
- iOS 代码已经合入并做过基础适配,但尚未完成本地真实 iPhone 的完整回归。
- 后端及 H5 合规页面仍以本地环境为主,正式服务器、域名和 HTTPS 尚未最终切换。
- 正式 API、数据库、JWT、短信、文件存储和 CORS 要在服务器及域名确定后统一配置。
- 正式 Android keystore、发布签名、APK/AAB 仍取决于外部资质和正式配置交付。
- 苹果开发者账号、证书、Bundle ID、签名和 TestFlight 权限仍取决于外部流程。
- ICP、公安备案、软著、安全评估和应用市场账号等由外部继续推进。
- 项目明确不使用 Docker不要围绕 Docker 增加工作。
## 2. Git 与代码状态
- 当前分支:`main`,不要创建额外分支。
- 当前提交:`5288596 merge: integrate sccsbc release preparation safely`
- `origin/main` 当前为 `fade61a`,本地 `main` 比远端多 6 个提交。
- `sccsbc` 的 Apple 登录、上线准备和 Android 签名相关提交已经通过合并提交进入本地 `main`
- 合并采用“保留当前新版业务和 UI、吸收对方上线准备”的方式没有用对方旧快照覆盖现有代码。
- 当前工作区有大量未提交修改。这些包含用户现阶段 UI、iOS 适配和交互调整,不能执行 `git reset --hard``git checkout --` 或用旧分支覆盖。
- 开始新修改前必须先查看 `git status` 和相关文件差异,只改当前任务涉及的代码。
当前主要未跟踪正式资源:
- `health_app/assets/branding/login_background_v2.png`
- `health_app/assets/branding/drawer_background_v2.png`
- `health_app/assets/branding/health_login_character_transparent.png`
- `health_app/test/native_navigation_test.dart`
## 3. 用户的协作习惯
- 先用用户视角解释,不要堆技术术语。
- 一次讨论一个明确问题,说明表现、原因和修改结果。
- 用户说“先讨论”时不能直接修改。
- 用户说“开始改”“动手”“去做”后直接实现,不要反复确认。
- 不要擅自扩大范围,不要增加与当前目标无关的工作量。
- 用户可能同时修改其他文件,必须保留并兼容用户改动。
- 用户通常自己构建和安装;除非明确要求,不要运行耗时构建或安装。
- 简单改动只做快速格式或静态检查,不要反复跑长测试。
- 数据删除、注销、历史对话等操作必须对应真实后端结果,不能只改前端显示。
- 修改聊天相关代码时,必须保证恢复历史对话后仍可继续聊天。
## 4. 已确认的 UI 方向
### 整体基调
- 首页现有品牌化紫色背景和整体结构基本保留。
- 普通二级页面以白色内容区为主,使用极浅中性灰建立页面与内容组边界。
- 不允许把模块颜色铺满整个页面、标题栏或大卡片。
- 风格要求干净、克制、专业、柔和,避免大面积紫色、重橙色和过亮颜色。
### 列表
- 同一内容组内连续排列,不要每行单独套白卡片。
- 分隔线只从文字区域开始,不贯穿左侧图标区域。
- 删除背景默认完全隐藏,左滑后才显示。
- 删除失败时不能提前从前端移除真实数据。
### 图标
- 全 App 图标样式必须统一,颜色可以按模块变化。
- 优先使用同一套线性、圆润图标。
- 同类列表的图标底座尺寸、圆角、图标尺寸和对齐保持一致。
- 运动模块已统一使用更合适的跑步图标,修改时要同步首页今日健康、胶囊和运动页面等所有入口。
### 色彩
- 通用输入焦点、选中和品牌操作继续使用 App 紫色体系。
- 模块色只用于模块识别、图标、状态和少量关键数字。
- 成功、警告、错误和信息颜色必须按语义使用,不能与模块色混用。
- 当前页面背景为 `#F3F5F8`,白色内容区用于建立层次。
- 当前健康模块为克制青绿色体系;运动模块为蓝靛体系;用药模块保持原有蓝青色体系。
- 今日健康异常状态已从偏黑的棕红色改为鲜明红橙色 `#E8562A`
### 圆角、阴影和字体
- 所有圆角优先使用项目已有的 `AppRadius`,不要随手写不同圆角。
- 卡片不能层层嵌套,也不能同时堆重描边和重阴影。
- 不要随意缩小字体;列表紧凑但必须清楚可读。
- 选中状态不能改变文字大小、字重或位置。
- 为避免 Android 跟随系统楷书时粗体失控,项目已减少 `w800/w900` 的使用,主要层级使用 `w600/w700`
完整基础规范见:`docs/ui-design-system.md`。实际逐页精修仍以真机截图和用户确认优先。
## 5. 最近完成的界面与交互修改
### 登录页
- 登录页使用新背景:`login_background_v2.png`
- 新背景去掉旧版气泡、玻璃方块和心电线,改为低饱和蓝紫柔光,中央保持安静。
- App 图标继续使用原来的白底人物版本,没有继续使用错误生成的紫色底图标。
- 登录页单独使用透明人物资源:`health_login_character_transparent.png`
- 登录键盘掉帧已针对性优化:
- 背景使用独立重绘边界,键盘弹出时背景不重复重绘。
- 删除原来每次键盘高度变化都会重启的 `AnimatedPadding`
- 表单改为轻量 `Transform.translate` 位移,最大抬升 150 px。
- 下滑表单可以关闭键盘。
- 这项键盘优化只做了格式和差异检查,尚未经过用户重新安装后的真机确认。
- 透明人物由原人物参考生成并去除背景。用户非常重视人物不变形;需要真机确认其造型是否与原人物完全一致。如果仍有偏差,应使用确定性的抠图方式处理原图,不能再次重新设计人物。
### App 图标与启动页
- Android 和 iOS 已恢复原来的白底人物图标及周围小元素。
- 已撤销错误的紫色底图标和重新生成的人物衍生图。
- Flutter 启动页恢复使用 `health_splash_master.png`
- Android 原生启动图恢复使用原 `launch_brand.png`
- iOS AppIcon 和 LaunchImage 配置恢复引用原文件。
### 侧边栏
- 使用新背景:`drawer_background_v2.png`
- 背景改为顶部淡蓝紫、向下过渡白色,遮罩同步调轻。
- 对话记录区域继续保留当前结构,不要因为“区域转换突然”擅自重排;用户认为类似蚂蚁阿福的结构可以接受。
- 当前侧边栏仍是:系统整页拖拽关闭,只在中间对话流区域使用自定义右滑触发;智能体胶囊和输入区不应触发侧边栏。
- 顶部菜单按钮仍可打开侧边栏。
- 不要再次擅自改成推页式侧栏或另一套 Drawer 手势,除非用户重新明确要求。
### 首页和今日健康
- 首页整体结构用户基本满意,不要进行大面积重构。
- 今日健康加载策略已调整,避免返回首页时先显示两行再闪成三行。
- 健康概览图标已统一为“记数据”中使用的图标风格。
- 跑步、用药等入口图标需与智能体胶囊保持同样样式。
- 今日健康异常文字和右侧警告图标当前统一为 `#E8562A`
- 首页键盘布局保持原来的结构;上一轮误加的首页键盘悬浮布局已经撤回。
### 通知中心
- 通知列表重新压缩并调整信息布局。
- 用户指定单行最低高度约 82 px不要再次压到 68 px。
- 标签和时间已重新安排,时间位于右侧区域,减少不必要的独立行。
- 列表内容要保持视觉垂直居中。
- 未读提醒区域不能使用大面积强紫色。
### 用药、运动、报告和设备
- 用药与运动列表的红色删除背景“提前露出”问题已处理过;后续修改需继续检查右侧红底不能浮出。
- 运动计划列表已向用药、报告的连续列表形式统一,并补充前置运动图标。
- 报告页面以靛紫色为模块识别,但“查看原始报告”不能使用突兀的大紫色实心按钮。
- 蓝牙设备管理仅允许修改 UI不得改变扫描、连接、自动读取、上传和解绑逻辑。
- 蓝牙页右下角自动扫描动画必须保留,用于表达页面正在持续检测设备。
## 6. 功能与平台适配状态
### 已合并/已处理
- `sccsbc` 分支的 Apple 登录和部分上线准备代码已经合并。
- Apple 登录前端按钮、服务端身份映射和相关迁移已进入当前代码历史。
- Android 签名相关配置结构已合入,但真实 keystore、密码和正式证书不应写入 Git。
- iOS 页面导航已加入原生风格返回适配相关测试文件,但尚未在真实 iPhone 全量验证。
- iOS 与 Android 共用同一套 Flutter 业务页面平台差异主要在权限、签名、Apple 登录和系统交互。
- Android 本地电脑与手机同 Wi-Fi 的开发 API 地址方式继续保留iPhone 真机调试时需要配置为电脑局域网 IP而不是 `localhost`
### 暂不投入的功能
- 医生端尚未正式完成。
- 医患实时交流、问诊和随访目前不是近期上线重点。
- 未启用的医生端、问诊和随访页面不需要继续做 UI 精修,也不要为它们扩大工作量。
- 但已有页面和入口不能伪装成已经可用的正式医疗服务。
## 7. Apple 审核与医疗合规风险
苹果反馈涉及 Guideline 1.4.1 和 2.1,要求:
- 外接医疗硬件的监管批准材料。
- 能证明 App 与硬件按描述工作的硬件测试报告或同行评议研究。
- 在真实 Apple 设备上完成硬件首次配对和完整流程的演示视频。
- 涉及医疗数据、诊断或治疗建议时提供相应监管批准文件。
当前本地测试主要使用欧姆龙 J735 血压计,但代码定位为通用 BLE 设备。后续必须在产品表述和审核材料之间作出一致选择:
- 如果继续声明支持医疗硬件,需要设备授权、监管文件、测试材料和真实 iPhone 演示。
- 如果定位为个人健康记录工具,需要削弱“医疗服务、诊断、治疗建议、恢复健康”等可能触发医疗器械审查的表述,并明确 AI 仅提供一般健康信息,不替代医生。
- 不要在没有材料时直接回复苹果称已经满足要求。
## 8. 正式环境前仍需完成
- 正式服务器、域名、HTTPS。
- 正式数据库与备份方案。
- 正式 JWT 密钥和密钥轮换。
- 正式短信服务。
- 正式文件/对象存储及删除策略。
- 正式 CORS 白名单。
- Android 正式 keystore、release 签名、APK/AAB。
- Apple 开发者证书、Bundle ID、签名、TestFlight 真机流程。
- 隐私政策、服务协议、个人信息清单、第三方 SDK 清单和关于我们的最终定稿。
- ICP、公安备案、软著、安全评估及各应用市场账号。
- Apple 医疗硬件与医疗建议相关审核材料或产品定位调整。
## 9. 当前验证情况
- 最近图片和 UI 修改只做了 `dart format``git diff --check`、资源引用和配置文件检查。
- 用户明确要求不要反复运行耗时测试,因此最近没有执行完整 Flutter build、安装或全量回归。
- Android 和 iOS 图标恢复后尚需用户重新构建查看系统桌面实际裁切。
- 登录页透明人物、新背景和键盘流畅度尚需 Android 真机确认。
- iOS 尚需真实 iPhone 验证 Apple 登录、蓝牙权限、页面侧滑返回、键盘和 TestFlight 包。
## 10. 下一窗口建议的第一步
1. 先运行 `git status --short`,确认用户是否又修改了文件。
2. 不要回退当前工作区,不要重新合并 `sccsbc`
3. 让用户先安装当前 Android 包,优先确认:
- 登录页透明人物是否仍保持原人物造型。
- 登录键盘弹出是否明显更流畅,输入框是否始终可见。
- App 图标是否恢复白底且人物显示完整。
- 新登录背景和侧边栏背景在真机上的亮度与边界。
- 今日健康异常红橙色是否清楚但不过重。
4. 根据用户真机截图逐项微调,每次只改明确页面。
5. 用户确认当前视觉效果后,再整理未使用的项目图片并提交代码;删除前必须确认没有 Android、iOS 或 Flutter 配置引用。
## 11. 禁止事项
- 不使用 Docker。
- 不创建其他分支。
- 不覆盖或回退用户正在修改的文件。
- 不用旧快照替换当前 main。
- 不在用户说“先讨论”时直接修改。
- 不随意扩大到未启用的医生端、问诊和随访页面。
- 不重新设计“小脉健康”人物形象,只允许适配背景、留白、尺寸和系统裁切。
- 不为了 UI 修改蓝牙、聊天、删除、注销和历史恢复等业务逻辑。
- 不声称构建、安装或真机验证成功,除非实际执行并看到结果。

View File

@@ -0,0 +1,526 @@
# 小脉健康医疗引用资料库 V3
> 仅用于 AI 医疗信息和健康建议的来源引用。
>
> 当前 App 相关主题:手动健康数据记录、饮食识别、运动计划、用药提醒、医学报告预整理、危险症状安全提醒。
---
## 血压和血压记录
### [SOURCE:S-BP-01]
来源标题2024 ESC Guidelines for the management of elevated blood pressure and hypertension
来源机构European Society of Cardiology Scientific Document Group
PubMedhttps://pubmed.ncbi.nlm.nih.gov/39210715/
PMID39210715
可引用内容:
- 血压解释应结合多次记录、测量条件、症状和临床评估;
- 单次血压记录不能单独诊断高血压;
- 家庭或院外血压记录可以帮助观察一段时间内的变化;
- 血压记录应交由医生结合个人情况解释;
- 不应根据一次读数自行改变降压药物。
适用回答:
- “这次血压记录怎么看”;
- “血压偏高/偏低怎么办”;
- “需要不要复测”;
- “血压趋势如何理解”。
不支持:
- 根据一次读数确诊高血压;
- 给出个人治疗目标;
- 建议加药、减药或停药。
推荐引用格式:
```text
参考来源:
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
https://pubmed.ncbi.nlm.nih.gov/39210715/
```
---
## 血糖和低血糖
### [SOURCE:S-GLU-01]
来源标题6. Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes-2025
来源机构American Diabetes Association Professional Practice Committee
PubMedhttps://pubmed.ncbi.nlm.nih.gov/39651981/
PMID39651981
可引用内容:
- 血糖目标需要结合个人情况、低血糖风险、支持条件和治疗目标;
- 不能把同一个血糖目标直接套用到所有用户;
- 血糖结果应结合测量时间、空腹或餐后状态、既往疾病和用药情况理解;
- 血糖异常记录应交给医生结合个人情况评估;
- AI 不能根据一次血糖记录诊断糖尿病或决定调整药物。
适用回答:
- “这次血糖值怎么理解”;
- “空腹/餐后血糖记录”;
- “血糖目标是否适合我”;
- “血糖偏低或偏高”。
不支持:
- 给所有用户设定统一目标;
- 诊断糖尿病;
- 指导自行调整降糖药。
推荐引用格式:
```text
参考来源:
[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes-2025
https://pubmed.ncbi.nlm.nih.gov/39651981/
```
---
## 血氧
### [SOURCE:S-OXY-01]
来源标题Pulse Oximeter Basics
来源机构U.S. Food and Drug Administration
来源https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
可引用内容:
- 血氧仪读数是对血氧水平的估计,不是完整的临床诊断;
- 读数应结合症状和变化趋势理解,不能只看单个数字;
- 手部温度、活动、循环、皮肤色素、指甲油和设备因素可能影响读数;
- 呼吸困难、胸痛、嘴唇或指甲发青等症状需要及时联系医疗服务;
- 不能仅凭血氧读数自行调整药物或氧疗。
适用回答:
- “血氧记录怎么理解”;
- “血氧偏低是否需要复测”;
- “血氧数值和症状的关系”;
- “为什么同一个人不同时间读数不同”。
不支持:
- 仅凭一个血氧值诊断或排除疾病;
- 指导自行吸氧、调氧或调药;
- 宣称某个单独数值对所有人都绝对安全或危险。
推荐引用格式:
```text
参考来源:
[S-OXY-01] Pulse Oximeter Basics, U.S. FDA
https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
```
---
## 饮食和饮食图片识别
### [SOURCE:S-DIET-01]
来源标题Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
来源机构American Heart Association Council on Lifestyle and Cardiometabolic Health
PubMedhttps://pubmed.ncbi.nlm.nih.gov/37128940/
PMID37128940
可引用内容:
- 地中海饮食、DASH 饮食、素食和鱼素饮食等模式总体上与心血管健康饮食原则较为一致;
- 饮食模式应考虑个人偏好、文化、预算和长期可执行性;
- 饮食图片不能准确确定食物重量、实际份量、烹调油、调味料和完整配方;
- AI 识别出的盐、糖、脂肪和热量只能作为一般估计;
- 一般饮食信息不能替代针对疾病、过敏或用药情况的营养评估。
适用回答:
- “这张图片里有什么食物”;
- “这餐可能含盐/糖/脂肪较多吗”;
- “这餐热量为什么只能估算”;
- “一般心血管健康饮食有哪些原则”。
不支持:
- 根据用户疾病档案制定饮食处方;
- 说某个食物对所有患者都“绝对不能吃”;
- 声称某种饮食可以治疗疾病;
- 根据一张图片精确计算摄入量。
推荐引用格式:
```text
参考来源:
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
https://pubmed.ncbi.nlm.nih.gov/37128940/
```
### [SOURCE:S-DIET-02]
来源标题Diet and nutrition in cardiovascular disease prevention
来源机构European Society of Cardiology 相关预防心脏病学组织
PubMedhttps://pubmed.ncbi.nlm.nih.gov/40504596/
PMID40504596
可引用内容:
- 饮食是心血管疾病预防的重要组成部分;
- 富含蔬菜水果、较少加工食品的饮食模式总体上更符合心血管预防原则;
- 盐、糖、饱和脂肪和高度加工食品应作为饮食评估时的客观因素;
- 饮食信息应作为一般健康信息,不应直接替代个体化营养治疗。
适用回答:
- 食物营养特征说明;
- 饮食记录总结;
- 一般心血管饮食知识。
不支持:
- 针对某个患者制定每日热量、盐量或营养素处方;
- 根据疾病和药物直接判断某道食物“禁止食用”。
推荐引用格式:
```text
参考来源:
[S-DIET-02] Diet and nutrition in cardiovascular disease prevention
https://pubmed.ncbi.nlm.nih.gov/40504596/
```
---
## 运动计划
### [SOURCE:S-EX-01]
来源标题Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
来源机构American Heart Association
PubMedhttps://pubmed.ncbi.nlm.nih.gov/38059362/
PMID38059362
可引用内容:
- 抗阻训练可以作为成年人健康活动的一部分;
- 运动计划应考虑个人健康状态、运动能力和安全性;
- 普通健康信息不能直接变成心血管疾病患者的医学运动处方;
- 有胸痛、晕厥、明显呼吸困难或医生限制运动时,应先进行医疗评估。
适用回答:
- 创建或记录运动计划;
- 解释运动计划的记录内容;
- 一般运动安全提醒。
不支持:
- 针对心血管疾病制定具体训练处方;
- 在有危险症状时建议继续运动;
- 根据一次运动记录判断治疗效果。
推荐引用格式:
```text
参考来源:
[S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
https://pubmed.ncbi.nlm.nih.gov/38059362/
```
### [SOURCE:S-EX-02]
来源标题Core Components of Cardiac Rehabilitation Programs: 2024 Update
来源机构American Heart Association / American Association of Cardiovascular and Pulmonary Rehabilitation
PubMedhttps://pubmed.ncbi.nlm.nih.gov/39315436/
PMID39315436
可引用内容:
- 心脏康复是结构化、专业管理的过程;
- 运动安排需要结合评估、教育、危险因素管理和随访;
- AI 的运动计划记录功能不能替代心脏康复团队。
适用回答:
- 心脏康复相关问题;
- 已知心血管疾病用户的运动安全提醒;
- 建议用户咨询康复专业人员。
不支持:
- AI 自行制定心脏康复处方;
- 根据聊天内容判断用户可以进行何种运动强度。
推荐引用格式:
```text
参考来源:
[S-EX-02] Core Components of Cardiac Rehabilitation Programs: 2024 Update
https://pubmed.ncbi.nlm.nih.gov/39315436/
```
---
## 用药提醒
### [SOURCE:S-MED-01]
来源标题Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
来源机构National Institute for Health and Care Excellence
PubMedhttps://pubmed.ncbi.nlm.nih.gov/39480983/
PMID39480983
可引用内容:
- 用药依从性需要了解患者的实际困难、偏好和需求;
- 共同决策和清晰沟通有助于支持患者执行已经确定的用药方案;
- App 可以帮助记录用药计划、查询任务和提供提醒;
- 是否开始、停止、更换或调整药物,应由医生或药师决定。
适用回答:
- 用药计划记录;
- 服药提醒;
- 查询某日已保存的用药任务;
- 建议用户对药物疑问咨询医生或药师。
不支持:
- 停药、换药、加药、减药;
- 自行决定漏服后的补服方法;
- 根据症状猜测药物副作用或替代治疗。
推荐引用格式:
```text
参考来源:
[S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
https://pubmed.ncbi.nlm.nih.gov/39480983/
```
---
## 医学报告预整理
### [SOURCE:S-REPORT-01]
来源标题Interpretating Normal Values and Reference Ranges for Laboratory Tests
PubMedhttps://pubmed.ncbi.nlm.nih.gov/40268322/
PMID40268322
可引用内容:
- 实验室参考范围会受到检测方法、实验室、人群和生理因素影响;
- 报告标注为高或低,不等于已经诊断疾病;
- 参考范围内也不能单独排除疾病;
- 报告应结合症状、病史和其他检查结果由医生解释;
- AI 可以整理报告已有的项目、数值、单位和原始参考范围。
适用回答:
- 报告指标提取;
- 报告中的高/低标记说明;
- 提醒用户核对原报告;
- 提醒用户让医生解释报告。
不支持:
- 根据一项异常指标推断病因;
- 根据影像或检验图片诊断疾病;
- 根据 AI 摘要决定治疗。
推荐引用格式:
```text
参考来源:
[S-REPORT-01] Interpretating Normal Values and Reference Ranges for Laboratory Tests
https://pubmed.ncbi.nlm.nih.gov/40268322/
```
### [SOURCE:S-REPORT-02]
来源标题Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
PubMedhttps://pubmed.ncbi.nlm.nih.gov/29729142/
PMID29729142
可引用内容:
- 参考区间与检测方法和目标人群有关;
- 不同实验室的参考区间不能不加判断地互相替换;
- 解释化验结果时应优先使用报告本身提供的参考范围。
适用回答:
- “为什么不同报告的参考范围不同”;
- “为什么不能直接套用网上正常值”;
- “为什么 AI 只能整理而不能诊断”。
不支持:
- 把通用范围直接作为 App 对所有用户的诊断标准。
推荐引用格式:
```text
参考来源:
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
https://pubmed.ncbi.nlm.nih.gov/29729142/
```
---
## 危险症状和就医提醒
### [SOURCE:S-EM-01]
来源标题2024 American Heart Association and American Red Cross Guidelines for First Aid
来源机构American Heart Association / American Red Cross
来源https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
可引用内容:
- 胸部不适或压迫感、呼吸困难、上肢/背部/颈部/下颌不适、恶心、出汗和头晕可能是需要及时评估的危险信号;
- 面部下垂、单侧手臂或腿无力、言语障碍、突然视物困难、行走困难、失去平衡或突发严重头痛可能提示卒中危险信号;
- 出现突发危险症状时,应立即启动当地急救服务,不要等待 AI 判断;
- AI 不能远程确认心梗、卒中或其他急症。
适用回答:
- 用户描述胸痛、呼吸困难、单侧无力、言语不清、意识异常等情况;
- 健康记录异常同时伴随危险症状;
- 固定安全提醒。
不支持:
- 远程诊断心梗或卒中;
- 用“没有某个症状”排除急症;
- 指导用户自行用药处理急症。
推荐引用格式:
```text
参考来源:
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
```
### [SOURCE:S-EM-02]
来源标题Know the warning signs of heart attack and stroke
来源机构American Heart Association
来源https://international.heart.org/en/-/media/International/Files/Heart-Attack-Stroke-Warning-Signs/HeartAttackandStrokeWarningSignsInternationalEnglish.pdf?sc_lang=en
可引用内容:
- 心脏危险信号包括胸部不适、呼吸困难、上肢/背部/颈部/下颌不适、冷汗、恶心或头晕;
- 卒中可以使用 FAST 思路识别:面部下垂、手臂无力、言语困难、立即呼叫急救;
- 症状即使短暂消失,也应及时寻求医疗帮助。
适用回答:
- 心脏危险信号;
- 卒中危险信号;
- “是否应该立即就医”的安全提醒。
不支持:
- 仅凭文字排除急症;
- 让用户等待 AI 继续分析;
- 把当地急救号码固定写成 911。
推荐引用格式:
```text
参考来源:
[S-EM-02] Know the warning signs of heart attack and stroke
https://international.heart.org/en/-/media/International/Files/Heart-Attack-Stroke-Warning-Signs/HeartAttackandStrokeWarningSignsInternationalEnglish.pdf?sc_lang=en
```
---
## 引用使用示例
### 示例一:血压记录
```text
这条记录是收缩压 X mmHg、舒张压 Y mmHg。单次记录不能单独用于诊断建议结合连续记录和身体症状交给医生评估。
参考来源:
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
https://pubmed.ncbi.nlm.nih.gov/39210715/
```
### 示例二:饮食图片
```text
根据图片,这餐可能含有较多盐或脂肪,但图片无法准确判断实际份量、调味料和烹调油,因此只能作为一般营养估计,不是个性化饮食处方。
参考来源:
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
https://pubmed.ncbi.nlm.nih.gov/37128940/
```
### 示例三:报告结果
```text
报告中该项目被标记为偏高。不同实验室和检测方法的参考范围可能不同,报告中的异常标记不等于疾病诊断,请结合原报告和医生意见判断。
参考来源:
[S-REPORT-01] Interpretating Normal Values and Reference Ranges for Laboratory Tests
https://pubmed.ncbi.nlm.nih.gov/40268322/
```
### 示例四:危险症状
```text
你描述的症状可能需要及时进行医疗评估。请立即联系当地急救服务或前往急诊,不要等待 AI 继续分析,也不要自行开车。
参考来源:
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
```
---
版本V3
整理日期2026-07-26

View File

@@ -0,0 +1,73 @@
# 医生端与管理员端 UI 统一设计
## 目标
在不改变现有业务流程、接口协议和导航结构的前提下,使医生端与管理员端的全部现有页面统一到患者端当前的视觉语言,并在修改过程中进行源码级逻辑检查。
## 范围
- 覆盖 `health_app/lib/pages/doctor/` 下全部现有页面。
- 覆盖 `health_app/lib/pages/admin/` 下全部现有页面。
- 覆盖医生端和管理员端侧边栏及其直接使用的共用展示组件。
- 复用患者端已有的 `AppColors`、主题、卡片阴影、圆角、渐变和页面背景。
- 检查前端状态处理、异步调用、空数据、错误反馈、导航和字段映射中的明显逻辑错误。
- 检查后端与医生端、管理员端直接相关的鉴权、数据归属、参数验证和异常处理。
## 不在范围内
- 不新增医生端或管理员端业务功能。
- 不修改 API 协议、数据库结构或现有权限规则,除非发现明确的高风险缺陷并经用户另行确认。
- 不重做患者端。
- 不截图、不做视觉回归平台、不进行真机测试或发布。
- 不为了视觉统一进行无关的大规模重构。
## 视觉方案
### 页面骨架
- 页面使用患者端的浅灰背景和统一安全区处理。
- 顶部标题栏保持轻量、透明或白色表面,标题、返回和菜单图标统一使用患者端文字颜色。
- 页面内容统一使用 16 像素水平边距;主要区块之间保持稳定的 1216 像素间距。
### 卡片与操作
- 内容容器统一为白色圆角卡片,使用 `AppColors.borderLight` 和轻阴影。
- 主操作使用患者端紫蓝主渐变;次操作使用浅色表面和边框;危险操作使用现有错误色。
- 列表项、统计卡片、筛选控件、表单和弹窗使用同一套圆角、文字层级和状态色。
- 医疗业务的警告、成功、待处理和异常状态继续保持语义颜色,不用装饰色覆盖含义。
### 状态反馈
- 加载状态保留明确进度反馈,避免空白页面。
- 空状态说明当前没有数据,并保留用户可执行的下一步操作(如果原流程已有操作)。
- 错误状态显示可理解的中文信息和重试入口(如果原页面已有重载能力)。
- 长文本和动态数据使用弹性布局与省略处理,降低窄屏溢出风险。
## 组件边界
- 优先使用现有 `AppColors``AppTheme`,不建立第二套颜色系统。
- 仅在多个医生端和管理员端页面确实重复时,新增小型共用展示组件,例如页面标题、区块卡片、状态占位和标签。
- 共用组件只负责展示与交互外观,不持有业务状态、不直接调用服务。
- 页面继续通过现有 Riverpod Provider 和 Service 获取数据,避免视觉改造改变数据流。
## 逻辑检查原则
- UI 改造过程中检查 `mounted`、重复提交、加载状态复位、空集合、空字段、分页/筛选和导航返回后的刷新。
- 检查医生与管理员 API 是否要求正确角色,并验证用户或患者数据归属。
- 明确的低风险页面错误可随 UI 修改修正;涉及鉴权、删除、隐私、医疗数据或接口行为的缺陷先记录并向用户说明,不擅自改变规则。
- 已知的全局上线风险单独列入最终检查报告,不与本次视觉修改混在一起扩大范围。
## 验证
- 运行 `flutter analyze`
- 运行受修改页面影响的现有相关测试;不新增复杂测试框架。
- 运行一次 Android 编译验证,确认 Flutter 与原生依赖能完成编译。
- 不运行截图测试、真机测试、iOS 构建或发布流程。
- 最终说明修改过的页面、发现的逻辑问题、验证结果和仍需人工确认的事项。
## 完成标准
- 医生端和管理员端全部现有页面在颜色、卡片、间距、按钮、文字层级和状态反馈上与患者端一致。
- 原有页面入口、导航、数据加载和操作流程不被视觉改造改变。
- 修改代码通过静态分析、相关测试和一次 Android 编译验证。
- 逻辑检查结果按严重程度清晰列出,高风险业务问题没有未经确认的行为变更。

View File

@@ -36,7 +36,7 @@ android {
versionName = flutter.versionName
}
// 正式签名配置
// 正式签名配置key.properties 不存在时回退到 debug keystore仅用于本地 release 测试
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
@@ -46,6 +46,11 @@ android {
file(path)
}
storePassword = keystoreProperties.getProperty("storePassword")
} else {
keyAlias = "androiddebugkey"
keyPassword = "android"
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
storePassword = "android"
}
}
}

View File

@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -70,9 +70,9 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
buildConfiguration = "Release"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"

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