9 Commits

Author SHA1 Message Date
MingNian
b4bc15b9b9 feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新
后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
2026-07-28 15:03:38 +08:00
MingNian
25a4078688 feat: 启用百度云 SMS 真实验证码发送
- SmsService: 从开发桩改为调用 BceSmsClient.SendAsync 真实发送
- AuthService.SendCodeAsync: 加 60 秒冷却 + 发送结果检查(失败返回 40010)
- Program.cs: 注册 BceSmsClient 到 DI(AddHttpClient + 10s 超时)
- auth_tests.cs: 加 CreateSmsService helper 适配构造注入

移动号实测可收到验证码,联通号运营商环节待报备
2026-07-28 15:01:00 +08:00
MingNian
32aae40b12 feat: 新增百度云 SMS v3 签名算法与 HttpClient 直连实现
- BceSigner: BCE v1 签名算法(HMAC-SHA256),与 baidubce-sdk 输出 byte-for-byte 一致
- BceSmsClient: HttpClient 直连百度云 SMS v3 API,自写签名,无第三方 SDK
- BceSignerTests: 7 个单测,包括真实 AK/SK 对比百度 SDK debug 输出

暂未启用:SmsService 仍为开发桩,等签名运营商实名报备后接入真实发送
2026-07-23 18:58:33 +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 12993 additions and 3001 deletions

5
.gitignore vendored
View File

@@ -11,6 +11,10 @@ health_app/android/key.properties
# .NET build outputs # .NET build outputs
backend/**/bin/ backend/**/bin/
backend/**/obj/ backend/**/obj/
backend/artifacts/
# Generated local audit reports
audit/*.html
# PostgreSQL local data # PostgreSQL local data
backend/pgdata/ backend/pgdata/
@@ -46,6 +50,7 @@ backend/src/Health.WebApi/data-protection-keys/
*.txt *.txt
*.jpg *.jpg
*.png *.png
!backend/src/Health.WebApi/wwwroot/app-icon.png
!health_app/assets/branding/*.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/launch_brand.png
!health_app/android/app/src/main/res/drawable-v21/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

@@ -9,13 +9,21 @@ JWT_AUDIENCE=health-manager-app
# DeepSeek LLM # DeepSeek LLM
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
DEEPSEEK_API_KEY=sk-your-key-here DEEPSEEK_API_KEY=sk-your-key-here
DEEPSEEK_MODEL=deepseek-chat DEEPSEEK_MODEL=deepseek-v4-flash
# 千问 VLM # 千问 VLM
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
QWEN_API_KEY=sk-your-key-here QWEN_API_KEY=sk-your-key-here
QWEN_VISION_MODEL=qwen-vl-max 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
MINIO_ENDPOINT=localhost:9000 MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin 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> /// <summary>
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。 /// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
/// </summary> /// </summary>
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct); Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct);
} }

View File

@@ -1,24 +1,20 @@
using System.Text; using System.Text;
using Health.Application.HealthArchives; using Health.Application.HealthArchives;
using Health.Application.HealthRecords; using Health.Application.HealthRecords;
using Health.Application.Medications;
namespace Health.Application.AI; namespace Health.Application.AI;
public sealed class PatientContextService( public sealed class PatientContextService(
IHealthArchiveService archives, IHealthArchiveService archives,
IHealthRecordService healthRecords, IHealthRecordService healthRecords) : IPatientContextService
IMedicationService medications) : IPatientContextService
{ {
private readonly IHealthArchiveService _archives = archives; private readonly IHealthArchiveService _archives = archives;
private readonly IHealthRecordService _healthRecords = healthRecords; private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IMedicationService _medications = medications;
public async Task<string> BuildAsync(Guid userId, CancellationToken ct) public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
{ {
var archive = await _archives.GetAsync(userId, ct); var archive = await _archives.GetAsync(userId, ct);
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList(); var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
var activeMedications = await _medications.ListActiveAsync(userId, ct);
var sb = new StringBuilder(); var sb = new StringBuilder();
if (archive != null) if (archive != null)
@@ -36,23 +32,11 @@ public sealed class PatientContextService(
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}"); 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) if (recentRecords.Count > 0)
{ {
sb.AppendLine("近期健康数据:"); sb.AppendLine("近期健康数据:");
foreach (var record in recentRecords) 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(); return sb.ToString();
@@ -67,4 +51,15 @@ public sealed class PatientContextService(
"Weight" => $"{record.Value}kg", "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 }) .Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
.ToList(); .ToList();
var followUps = snapshot.FollowUps 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() }) .Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
.ToList(); .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 })); 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 entries.Add(new CalendarEntry("followup", new
{ {
@@ -88,5 +88,14 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
&& (medication.StartDate == null || medication.StartDate <= date) && (medication.StartDate == null || medication.StartDate <= date)
&& (medication.EndDate == null || medication.EndDate >= 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); 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<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct); Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, 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<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
Task<bool> DeleteAsync(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?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
Task<bool> CheckInAsync(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 public interface IExerciseRepository
{ {
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct); Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, 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?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct); Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
Task AddAsync(ExercisePlan plan, CancellationToken ct); Task AddAsync(ExercisePlan plan, CancellationToken ct);
void Delete(ExercisePlan plan); void Delete(ExercisePlan plan);

View File

@@ -33,8 +33,11 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
{ {
plan.Items.Add(new ExercisePlanItem plan.Items.Add(new ExercisePlanItem
{ {
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType, Id = Guid.NewGuid(),
DurationMinutes = duration, IsRestDay = false, ScheduledDate = date,
ExerciseType = exerciseType,
DurationMinutes = duration,
IsRestDay = false,
}); });
} }
await SaveNewAsync(plan, ct); 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) public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
{ {
var plans = await _exercises.ListAsync(userId, 20, ct); var plans = await _exercises.ListAsync(userId, 20, ct);
return plans.Select(p => new ExercisePlanSummaryDto( return plans.Select(ToSummaryDto).ToList();
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt, }
p.Items.Count, p.Items.Count(i => i.IsCompleted),
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList(); public async Task<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) public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
@@ -103,12 +109,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
return true; 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) private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
{ {
await _exercises.AddAsync(plan, 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() 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, ReminderTime = reminderTime ?? DefaultReminderTime,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
}; };
private static ExercisePlanDto ToDto(ExercisePlan plan) => new( private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt, plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList()); 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( private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes, item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
item.IsCompleted, item.CompletedAt, item.IsRestDay); item.IsCompleted, item.CompletedAt, item.IsRestDay);

View File

@@ -45,6 +45,21 @@ public sealed record MedicationReminderDto(
string ScheduledTime, string ScheduledTime,
string Status); 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( public sealed record MedicationReminderTask(
Guid TaskId, Guid TaskId,
Guid UserId, Guid UserId,
@@ -60,12 +75,10 @@ public interface IMedicationService
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct); Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest 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> 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<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, 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<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct); Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(Guid userId, DateOnly date, CancellationToken ct);
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
} }
public interface IMedicationRepository public interface IMedicationRepository
@@ -74,8 +87,6 @@ public interface IMedicationRepository
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct); Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct); Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, 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<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<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct); Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);

View File

@@ -96,23 +96,6 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
return true; 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) public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
{ {
var beijingNow = DateTime.UtcNow.AddHours(8); 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) 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 (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct); var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
@@ -168,18 +158,48 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
return true; return true;
} }
public Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) => public async Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(
ListAsync(userId, "active", ct); Guid userId,
DateOnly date,
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct) CancellationToken ct)
{ {
var result = await ConfirmDoseAsync( var medications = await _medications.ListAsync(userId, null, ct);
userId, var (startUtc, endUtc) = GetBeijingDateUtcWindow(date);
medicationId, var logs = await _medications.ListLogsInWindowAsync(userId, startUtc, endUtc, ct);
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), var today = BeijingToday();
MedicationLogStatus.Taken, var now = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
ct);
return result == true; 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) 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() private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
{ {
var beijingToday = DateTime.UtcNow.AddHours(8).Date; return GetBeijingDateUtcWindow(BeijingToday());
var todayStartUtc = beijingToday.AddHours(-8); }
return (todayStartUtc, todayStartUtc.AddDays(1));
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) 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( public static ReportDto ToDto(Report report) => new(
report.Id, report.Id,
report.UserId, report.UserId,
report.FileUrl, $"/api/reports/{report.Id}/file",
report.FileType.ToString(), report.FileType.ToString(),
report.Category.ToString(), report.Category.ToString(),
report.Status.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( public sealed record UserProfileUpdateRequest(
string? Name, string? Name,
string? Gender, string? Gender,
DateOnly? BirthDate); DateOnly? BirthDate,
string? AvatarUrl);
public sealed record AccountFileReferences( public sealed record AccountFileReferences(
IReadOnlyList<string> FileUrls, IReadOnlyList<string> FileUrls,

View File

@@ -28,6 +28,7 @@ public sealed class UserService(
if (request.Name != null) user.Name = request.Name; if (request.Name != null) user.Name = request.Name;
if (request.Gender != null) user.Gender = request.Gender; if (request.Gender != null) user.Gender = request.Gender;
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value; if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl;
user.UpdatedAt = DateTime.UtcNow; user.UpdatedAt = DateTime.UtcNow;
await _users.SaveChangesAsync(ct); await _users.SaveChangesAsync(ct);
return true; return true;

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> /// </summary>
public static class CommonAgentHandler 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() public static readonly ToolDefinition QueryHealthRecordsTool = new()
{ {
Function = new() Function = new()
{ {
Name = "query_health_records", Description = "查询近期健康数据", Name = "query_health_records",
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } } 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) 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 rawType = args.TryGetProperty("type", out var t) ? t.GetString() : null;
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7; if (!TryNormalizeMetricType(rawType, out var metricType))
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct); return new { success = false, message = $"无法识别健康指标类型: {rawType}" };
return new { count = records.Count, records };
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) 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 }; if (archive == null) return new { found = false };
return new return new
{ {
found = true, archive.Diagnosis, archive.SurgeryType, found = true,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries, archive.Diagnosis,
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, 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 = "管理用户健康档案", null or "" or "all" or "全部" => null,
Parameters = new "bloodpressure" or "blood_pressure" or "血压" => "BloodPressure",
{ "heartrate" or "heart_rate" or "心率" or "脉搏" => "HeartRate",
type = "object", "glucose" or "blood_glucose" or "血糖" => "Glucose",
properties = new "spo2" or "blood_oxygen" or "血氧" or "血氧饱和度" => "SpO2",
{ "weight" or "体重" => "Weight",
action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" }, _ => "__invalid__",
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" }
}
}
}; };
return normalized != "__invalid__";
}
public static Task<object> ExecuteManageArchive( private static bool TryReadDate(JsonElement args, string name, out DateOnly date)
IHealthArchiveService archives,
Guid userId,
JsonElement args,
CancellationToken ct = default)
{ {
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; date = default;
var request = new HealthArchiveUpdateRequest( return args.TryGetProperty(name, out var value) && DateOnly.TryParse(value.GetString(), out date);
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);
} }
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) => private static int DaysSince(DateTime startUtc) =>
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array Math.Clamp((int)Math.Ceiling((DateTime.UtcNow - startUtc).TotalDays) + 1, 1, 3650);
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
: null;
} }

View File

@@ -15,7 +15,9 @@ public static class ExerciseAgentHandler
type = "object", type = "object",
properties = new 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默认今天" }, start_date = new { type = "string", description = "开始日期 yyyy-MM-dd默认今天" },
duration_days = new { type = "integer", description = "连续运动天数如一周为7天" }, duration_days = new { type = "integer", description = "连续运动天数如一周为7天" },
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" }, exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
@@ -30,20 +32,50 @@ public static class ExerciseAgentHandler
public static List<ToolDefinition> Tools => [ManageExerciseTool]; 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( public static async Task<object> Execute(
string toolName, string toolName,
JsonElement args, JsonElement args,
AppDbContext db,
Guid userId, Guid userId,
IExerciseService? exercises = null, IExerciseService exercises,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (toolName != "manage_exercise" || exercises == null) if (toolName != "manage_exercise")
return new { success = false, message = $"未知工具: {toolName}" }; 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") 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) var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
? parsedStart ? parsedStart
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
@@ -67,17 +99,111 @@ public static class ExerciseAgentHandler
return success ? new { success = true } : new { success = false, message = "条目不存在" }; return success ? new { success = true } : new { success = false, message = "条目不存在" };
} }
var plan = await exercises.GetLatestAsync(userId, ct); if (action != "query")
return plan == null return new { success = false, message = $"未知操作: {action}" };
? new { found = false }
: new 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_id = plan.Id,
plan.StartDate, plan.StartDate,
plan.EndDate, plan.EndDate,
phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active",
plan.ReminderTime, 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> /// </summary>
public static class HealthDataAgentHandler public static class HealthDataAgentHandler
{ {
private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"];
public static readonly ToolDefinition RecordHealthDataTool = new() public static readonly ToolDefinition RecordHealthDataTool = new()
{ {
Function = new() Function = new()
{ {
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)", Name = "record_health_data",
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" } } 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 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}" } _ => 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 type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args); var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null) if (metricType == null)
return new { success = false, message = $"未知指标类型: {type}" }; return new { success = false, message = $"未知指标类型: {type}" };
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) var recordedAt = args.TryGetProperty("recorded_at", out var ra) &&
? dt AiDateTime.TryParseUserDateTime(ra.GetString(), out var parsedRecordedAt)
? parsedRecordedAt
: DateTime.UtcNow; : DateTime.UtcNow;
var request = new HealthRecordUpsertRequest( var request = new HealthRecordUpsertRequest(
@@ -47,7 +174,7 @@ public static class HealthDataAgentHandler
HealthRecordSource.AiEntry, HealthRecordSource.AiEntry,
recordedAt); 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() ?? ""; var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
return new 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) private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
{ {
return type switch return type switch

View File

@@ -12,19 +12,23 @@ public static class MedicationAgentHandler
Function = new() Function = new()
{ {
Name = "manage_medication", Name = "manage_medication",
Description = "用药管理(创建/查询/确认服药)", Description = "用药管理(创建计划、按日期查询计划与逐顿服药状态、确认某一顿服药)",
Parameters = new Parameters = new
{ {
type = "object", type = "object",
properties = new 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确认服药时使用" }, medication_id = new { type = "string", description = "药品 ID确认服药时使用" },
scheduled_time = new { type = "string", description = "要确认的计划服药时间 HH:mm" },
name = new { type = "string", description = "药品名称" }, name = new { type = "string", description = "药品名称" },
dosage = new { type = "string", description = "剂量,如 100mg" }, 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\"]" }, time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
duration_days = new { type = "integer", description = "服用天数" }, duration_days = new { type = "integer", description = "服用天数" },
long_term = new { type = "boolean", description = "是否明确为长期服用;与 duration_days 二选一" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" }, start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
}, },
required = new[] { "action" } 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) 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 return action switch
{ {
"create" => await CreateMedication(medications, userId, args, ct), "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), "confirm" => await ConfirmMedication(medications, userId, args, ct),
_ => new { success = false, message = $"未知操作: {action}" } _ => 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) 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 name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null; var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequency = ReadFrequency(args); var frequency = ReadFrequency(args);
var times = ReadTimes(args); var times = ReadTimes(args);
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0; 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); var startDate = ReadStartDate(args);
// 结束日为包含式IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1) // 结束日为包含式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( var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
name, name,
@@ -91,27 +100,146 @@ public static class MedicationAgentHandler
time = timeLabels, time = timeLabels,
start_date = startDate.ToString("yyyy-MM-dd"), start_date = startDate.ToString("yyyy-MM-dd"),
duration_days = durationDays, 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); var scope = args.TryGetProperty("scope", out var scopeValue)
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) }; ? 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) 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 validationError = ValidateWriteArguments(args);
var success = await medications.ConfirmNowAsync(userId, medId, ct); if (validationError != null)
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" }; 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) private static MedicationFrequency ReadFrequency(JsonElement args)
{ {
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily"; 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) 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

@@ -1,9 +1,12 @@
using System.Text.Json; using System.Text.Json;
using Health.Application.AI; using Health.Application.AI;
using Health.Application.Exercises; using Health.Application.Exercises;
using Health.Application.Diets;
using Health.Application.HealthArchives; using Health.Application.HealthArchives;
using Health.Application.HealthRecords; using Health.Application.HealthRecords;
using Health.Application.Medications; using Health.Application.Medications;
using Health.Application.Notifications;
using Health.Application.Reports;
using Health.Infrastructure.AI.AgentHandlers; using Health.Infrastructure.AI.AgentHandlers;
namespace Health.Infrastructure.AI; namespace Health.Infrastructure.AI;
@@ -15,6 +18,9 @@ public sealed class AiToolExecutionService(
IHealthRecordService healthRecords, IHealthRecordService healthRecords,
IExerciseService exercises, IExerciseService exercises,
IMedicationService medications, IMedicationService medications,
IDietService diets,
IReportService reports,
IInAppNotificationService notifications,
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
{ {
private readonly AppDbContext _db = db; private readonly AppDbContext _db = db;
@@ -23,6 +29,9 @@ public sealed class AiToolExecutionService(
private readonly IHealthRecordService _healthRecords = healthRecords; private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IExerciseService _exercises = exercises; private readonly IExerciseService _exercises = exercises;
private readonly IMedicationService _medications = medications; 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; private readonly IAiWriteConfirmationStore _confirmations = confirmations;
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct) 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; var root = json.RootElement;
return await (toolName switch 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), "query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"check_archive" => 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), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient), "analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct), "manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, userId, _exercises, ct),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, 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}" }), _ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
}); });
} }

View File

@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Health.Application.AI; using Health.Application.AI;
using Health.Infrastructure.Files;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using UglyToad.PdfPig; using UglyToad.PdfPig;
@@ -19,19 +20,19 @@ public sealed class AttachmentContextBuilder(
private readonly VisionClient _vision = vision; private readonly VisionClient _vision = vision;
private readonly ILogger<AttachmentContextBuilder> _logger = logger; 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)) if (!string.IsNullOrWhiteSpace(imageUrl))
return await BuildImageAsync(imageUrl!, ct); return await BuildImageAsync(userId, imageUrl!, ct);
if (!string.IsNullOrWhiteSpace(pdfUrl)) if (!string.IsNullOrWhiteSpace(pdfUrl))
return await BuildPdfAsync(pdfUrl!, ct); return await BuildPdfAsync(userId, pdfUrl!, ct);
return null; return null;
} }
// ── 图片:调 VLM 输出结构化 JSON ── // ── 图片:调 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)) if (filePath == null || !File.Exists(filePath))
{ {
_logger.LogWarning("Image file not found for {Url}", imageUrl); _logger.LogWarning("Image file not found for {Url}", imageUrl);
@@ -105,9 +106,9 @@ public sealed class AttachmentContextBuilder(
} }
// ── PDFPdfPig 抽取文本 ── // ── 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); var fileName = Path.GetFileName(pdfUrl);
if (filePath == null || !File.Exists(filePath)) 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) private static string StripCodeFence(string raw)
{ {
var t = raw.Trim(); 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) public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{ {
private readonly HttpClient _http = http; 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() private readonly JsonSerializerOptions _jsonOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
@@ -28,8 +28,13 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{ {
var request = new ChatCompletionRequest var request = new ChatCompletionRequest
{ {
Model = _model, Messages = messages, Stream = true, Model = _model,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools, Messages = messages,
Stream = true,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
Thinking = new { type = "disabled" },
}; };
if (tools?.Count > 0) request.ToolChoice = "auto"; if (tools?.Count > 0) request.ToolChoice = "auto";
@@ -64,14 +69,20 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
List<ToolDefinition>? tools = null, List<ToolDefinition>? tools = null,
int maxTokens = 2048, int maxTokens = 2048,
float temperature = 0.7f, float temperature = 0.7f,
string? toolChoice = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var request = new ChatCompletionRequest var request = new ChatCompletionRequest
{ {
Model = _model, Messages = messages, Stream = false, Model = _model,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools, Messages = messages,
Stream = false,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
Thinking = new { type = "disabled" },
}; };
if (tools?.Count > 0) request.ToolChoice = "auto"; if (tools?.Count > 0) request.ToolChoice = toolChoice ?? "auto";
var json = JsonSerializer.Serialize(request, _jsonOptions); var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json"); var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -117,8 +128,12 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
var request = new ChatCompletionRequest var request = new ChatCompletionRequest
{ {
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false, Model = _model,
Temperature = 0.1f, VlHighResolutionImages = true, Messages = messages,
MaxTokens = maxTokens,
Stream = false,
Temperature = 0.1f,
VlHighResolutionImages = true,
EnableThinking = false, EnableThinking = false,
}; };

View File

@@ -10,6 +10,7 @@ public sealed class ChatCompletionRequest
public float? TopP { get; set; } public float? TopP { get; set; }
public List<ToolDefinition>? Tools { get; set; } public List<ToolDefinition>? Tools { get; set; }
public string? ToolChoice { get; set; } public string? ToolChoice { get; set; }
public object? Thinking { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")] [System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
public bool VlHighResolutionImages { get; set; } public bool VlHighResolutionImages { get; set; }

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

@@ -6,7 +6,7 @@ namespace Health.Infrastructure.AI;
/// <summary> /// <summary>
/// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。 /// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。
/// 我们后端的 DeepSeek 依旧负责对话生成与工具调用; /// 我们后端的 DeepSeek 依旧负责对话生成与工具调用;
/// 这里只把检索到的医学知识片段拼回 system prompt,提升回答准确度。 /// 检索结果作为按需工具结果返回给主对话,提升回答准确度。
/// </summary> /// </summary>
public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger<FastGptKnowledgeClient> logger) public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger<FastGptKnowledgeClient> logger)
{ {

View File

@@ -1,199 +1,123 @@
using System.Reflection;
namespace Health.Infrastructure.AI; namespace Health.Infrastructure.AI;
/// <summary> /// <summary>
/// System Prompt 模板管理 /// Loads small, responsibility-focused prompt resources and composes only the
/// sections required by the current intent route.
/// </summary> /// </summary>
public sealed class PromptManager 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");
}
return Compose(sections);
}
/// <summary> /// <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> /// </summary>
public string GetSystemPrompt(AgentType agentType) public string GetSystemPrompt(AgentType agentType) => agentType switch
{ {
var prompt = agentType switch AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]),
{ AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]),
AgentType.Default => DefaultPrompt, AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]),
AgentType.Consultation => ConsultationPrompt, AgentType.Consultation => ComposeForIntents(["medical_consultation"]),
AgentType.Health => HealthDataPrompt, AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]),
AgentType.Diet => DietPrompt, AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]),
AgentType.Medication => MedicationPrompt, AgentType.Unified => ComposeForIntents([
AgentType.Report => ReportPrompt, "health_entry",
AgentType.Exercise => ExercisePrompt, "medication_entry",
AgentType.Unified => UnifiedPrompt, "exercise_entry",
_ => DefaultPrompt "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 = """ private static string NormalizePath(string path) =>
path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
- / 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
-
-
""";
} }

View File

@@ -16,25 +16,32 @@ public sealed class AdminService(AppDbContext db) : IAdminService
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct) public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空"); var phone = command.Phone.Trim();
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空"); 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 var doctor = new Doctor
{ {
Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department, Id = Guid.NewGuid(), Name = name, Title = command.Title?.Trim(), Department = command.Department?.Trim(),
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow, 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); _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.Users.Add(user);
_db.DoctorProfiles.Add(new DoctorProfile _db.DoctorProfiles.Add(new DoctorProfile
{ {
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = command.Name, Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = name,
Title = command.Title, Department = command.Department, IsActive = true, Title = doctor.Title, Department = doctor.Department, IsActive = true,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
}); });
}
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.Name }); 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); var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在"); if (doctor == null) return Error(404, "医生不存在");
if (command.Name != null) doctor.Name = command.Name; var profile = await _db.DoctorProfiles.Include(x => x.User)
if (command.Title != null) doctor.Title = command.Title; .FirstOrDefaultAsync(x => x.DoctorId == id, ct);
if (command.Department != null) doctor.Department = command.Department; if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
if (command.Phone != null) doctor.Phone = command.Phone;
if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection; 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); await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id }); return Ok(new { doctor.Id });
} }
@@ -56,7 +95,11 @@ public sealed class AdminService(AppDbContext db) : IAdminService
{ {
var doctor = await _db.Doctors.FindAsync([id], ct); var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在"); 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; doctor.IsActive = !doctor.IsActive;
profile.IsActive = doctor.IsActive;
profile.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.IsActive }); 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); var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在"); 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); _db.Doctors.Remove(doctor);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return Ok(new { success = true }); return Ok(new { success = true });

View File

@@ -20,6 +20,17 @@ public sealed class AuthService(
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct) public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
{ {
// 60 秒冷却AdminPhone 豁免)
if (phone != AdminPhone)
{
var last = await _db.VerificationCodes
.Where(x => x.Phone == phone)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (last != null && DateTime.UtcNow - last.CreatedAt < TimeSpan.FromSeconds(60))
return Error(40009, "请求过于频繁,请 1 分钟后重试");
}
var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode(); var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode();
await _db.VerificationCodes.AddAsync(new VerificationCode await _db.VerificationCodes.AddAsync(new VerificationCode
{ {
@@ -27,7 +38,12 @@ public sealed class AuthService(
ExpiresAt = DateTime.UtcNow.AddMinutes(5), ExpiresAt = DateTime.UtcNow.AddMinutes(5),
}, ct); }, ct);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
if (phone != AdminPhone) await _sms.SendCodeAsync(phone, code);
if (phone != AdminPhone)
{
var sent = await _sms.SendCodeAsync(phone, code);
if (!sent) return Error(40010, "短信发送失败,请稍后重试");
}
return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null }); return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null });
} }
@@ -88,14 +104,39 @@ public sealed class AuthService(
{ {
var tokens = AddTokens(AdminId, AdminPhone, "Admin"); var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct); 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); var user = await _db.Users.FindAsync([oldToken.UserId], ct);
if (user == null) return Error(40002, "用户不存在"); if (user == null) return Error(40002, "用户不存在");
var userTokens = AddTokens(user.Id, user.Phone, user.Role); var userTokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct); 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) 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) public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct)
{ {
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); // 日历的日期边界按北京时间计算,数据库仍统一使用 UTC。
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.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 var medications = await _db.Medications
.Where(m => m.UserId == userId && m.IsActive) .Where(m => m.UserId == userId && m.IsActive)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddAiEntryDrafts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AiEntryDrafts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
EntryType = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Payload = table.Column<string>(type: "jsonb", nullable: false),
MissingFields = table.Column<string>(type: "jsonb", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AiEntryDrafts", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_AiEntryDrafts_UserId_ConversationId_EntryType_Status_Expire~",
table: "AiEntryDrafts",
columns: new[] { "UserId", "ConversationId", "EntryType", "Status", "ExpiresAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AiEntryDrafts");
}
}
}

View File

@@ -956,6 +956,52 @@ namespace Health.Infrastructure.Data.Migrations
b.ToTable("VerificationCodes"); 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 => modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b =>
{ {
b.Property<Guid>("Id") 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<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>(); public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>(); public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
public DbSet<AiEntryDraftRecord> AiEntryDrafts => Set<AiEntryDraftRecord>();
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>(); public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>(); public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>(); 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"); 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 => builder.Entity<ReportAnalysisTaskRecord>(e =>
{ {
e.ToTable("ReportAnalysisTasks"); e.ToTable("ReportAnalysisTasks");

View File

@@ -19,16 +19,16 @@ public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository
.Take(limit) .Take(limit)
.ToListAsync(ct); .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) => public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) =>
_db.ExercisePlans.Include(p => p.Items) _db.ExercisePlans.Include(p => p.Items)
.FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct); .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) => public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) =>
_db.ExercisePlanItems.Include(i => i.Plan) _db.ExercisePlanItems.Include(i => i.Plan)
.FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct); .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" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="AI\Prompts\**\*.md" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <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) => public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
_db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, 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) => public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.AnyAsync(l => _db.MedicationLogs.AnyAsync(l =>
l.MedicationId == medicationId && l.UserId == userId l.MedicationId == medicationId && l.UserId == userId

View File

@@ -19,8 +19,24 @@ public sealed class LocalReportFileStorage : IReportFileStorage
return new StoredReportFile($"/uploads/reports/{fileName}", filePath); return new StoredReportFile($"/uploads/reports/{fileName}", filePath);
} }
public string GetLocalFilePath(string fileUrl) => public string GetLocalFilePath(string fileUrl)
Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/')); {
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) => public bool Exists(string filePath) =>
File.Exists(filePath); File.Exists(filePath);

View File

@@ -0,0 +1,49 @@
using System.Globalization;
using System.Security.Cryptography;
namespace Health.Infrastructure.Services;
/// <summary>
/// 百度云 BCE v1 签名算法(与 baidubce-sdk 算法一致)
/// </summary>
public static class BceSigner
{
private const int ExpirySeconds = 1800;
private const string SignedHeaders = "content-length;content-type;host;x-bce-date";
public static string BuildAuthorization(
string accessKeyId,
string secretAccessKey,
string method,
string path,
string host,
DateTime utcNow,
int contentLength,
string contentType)
{
var timestamp = utcNow.ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture);
var canonicalHeaders = string.Join("\n",
$"content-length:{contentLength}",
$"content-type:{Normalize(contentType)}",
$"host:{Normalize(host)}",
$"x-bce-date:{Normalize(timestamp)}");
var canonicalRequest = $"{method}\n{path}\n\n{canonicalHeaders}";
var authStringPrefix = $"bce-auth-v1/{accessKeyId}/{timestamp}/{ExpirySeconds}";
var signingKeyDigest = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secretAccessKey),
Encoding.UTF8.GetBytes(authStringPrefix));
var signingKeyHex = Convert.ToHexString(signingKeyDigest).ToLowerInvariant();
var signature = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(signingKeyHex),
Encoding.UTF8.GetBytes(canonicalRequest));
var signatureHex = Convert.ToHexString(signature).ToLowerInvariant();
return $"{authStringPrefix}/{SignedHeaders}/{signatureHex}";
}
private static string Normalize(string s) => Uri.EscapeDataString(s);
}

View File

@@ -0,0 +1,64 @@
using System.Globalization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Services;
/// <summary>
/// 百度云 SMS v3 短信发送客户端HttpClient 直连 + BCE v1 签名)
/// </summary>
public sealed class BceSmsClient(HttpClient http, IConfiguration cfg, ILogger<BceSmsClient> log)
{
private const string Path = "/api/v3/sendSms";
private const string ContentType = "application/json; charset=utf-8";
private readonly HttpClient _http = http;
private readonly ILogger<BceSmsClient> _log = log;
private readonly string _ak = cfg["BAIDU_SMS_AK"] ?? "";
private readonly string _sk = cfg["BAIDU_SMS_SK"] ?? "";
private readonly string _host = cfg["BAIDU_SMS_HOST"] ?? "smsv3.bj.baidubce.com";
private readonly string _signatureId = cfg["BAIDU_SMS_SIGNATURE_ID"] ?? "";
private readonly string _templateId = cfg["BAIDU_SMS_TEMPLATE_ID"] ?? "";
public async Task<bool> SendAsync(string phone, string code, CancellationToken ct)
{
var payload = new
{
mobile = $"+86{phone}",
template = _templateId,
signatureId = _signatureId,
contentVar = new { SMSvCode = code }
};
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
using var req = new HttpRequestMessage(HttpMethod.Post, Path)
{
Content = new ByteArrayContent(body)
};
req.Content.Headers.TryAddWithoutValidation("Content-Type", ContentType);
req.Headers.Host = _host;
var now = DateTime.UtcNow;
var timestamp = now.ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture);
req.Headers.Add("x-bce-date", timestamp);
var auth = BceSigner.BuildAuthorization(_ak, _sk, "POST", Path, _host, now, body.Length, ContentType);
req.Headers.TryAddWithoutValidation("Authorization", auth);
using var resp = await _http.SendAsync(req, ct);
var respBody = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
{
_log.LogError("百度短信发送失败 HTTP {Status}: {Body}", resp.StatusCode, respBody);
return false;
}
using var doc = JsonDocument.Parse(respBody);
var respCode = doc.RootElement.GetProperty("code").GetString();
if (respCode != "1000")
{
_log.LogError("百度短信发送失败 code={Code}: {Body}", respCode, respBody);
return false;
}
return true;
}
}

View File

@@ -1,26 +1,27 @@
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Services; namespace Health.Infrastructure.Services;
/// <summary> /// <summary>
/// 短信验证码服务(开发阶段直接返回成功 /// 短信验证码服务(调用百度云 SMS v3 真实发送
/// </summary> /// </summary>
public sealed class SmsService public sealed class SmsService(BceSmsClient bce, ILogger<SmsService> log)
{ {
/// <summary> private readonly BceSmsClient _bce = bce;
/// 发送验证码(开发阶段不做真实发送) private readonly ILogger<SmsService> _log = log;
/// </summary>
public Task<bool> SendCodeAsync(string phone, string code) public async Task<bool> SendCodeAsync(string phone, string code)
{ {
// 开发阶段:直接在控制台输出,不做真实发送 try
Console.WriteLine($"[SMS DEV] 发送验证码到 {phone}: {code}"); {
return Task.FromResult(true); return await _bce.SendAsync(phone, code, CancellationToken.None);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_log.LogError(ex, "百度短信发送异常 phone={Phone}", phone);
return false;
}
} }
/// <summary> public string GenerateCode() => Random.Shared.Next(100000, 1000000).ToString();
/// 生成 6 位随机数字验证码
/// </summary>
public string GenerateCode()
{
// Next(min, max) 的 max 是 exclusive 的,所以用 1000000 保证 6 位
return Random.Shared.Next(100000, 1000000).ToString();
}
} }

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) public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
{ {
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase); // 正式报告路径来自服务端生成的报告记录,只允许删除 reports 目录中的文件。
foreach (var metadataJson in references.ConversationMetadataJson) foreach (var fileUrl in references.FileUrls)
AddMetadataUrls(fileUrls, metadataJson);
foreach (var fileUrl in fileUrls)
{ {
ct.ThrowIfCancellationRequested(); 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); 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); 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 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) catch (UriFormatException)
{ {
return null; return null;
} }
}
var queryIndex = relativePath.IndexOfAny(['?', '#']); private string? ResolveOwnedAttachmentPath(Guid userId, string fileUrl)
if (queryIndex >= 0) relativePath = relativePath[..queryIndex]; {
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar); 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)); private static string? ResolveInside(string root, string fileName)
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) {
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; + Path.DirectorySeparatorChar;
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null; 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)); 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) public static void MapDoctorEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("/api/doctor").RequireAuthorization(); var group = app.MapGroup("/api/doctor").RequireAuthorization();
@@ -105,6 +119,7 @@ public static class DoctorEndpoints
doctorHospital = profile.Hospital, doctorHospital = profile.Hospital,
doctorAvatar = profile.AvatarUrl, doctorAvatar = profile.AvatarUrl,
doctorOnline = profile.IsOnline, doctorOnline = profile.IsOnline,
doctorActive = profile.IsActive,
stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps }, stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps },
pendingConsultations, pendingConsultations,
pendingReports = pendingReportList, pendingReports = pendingReportList,
@@ -303,7 +318,7 @@ public static class DoctorEndpoints
query = query.Where(r => r.Status == s); query = query.Where(r => r.Status == s);
var reports = await query.OrderByDescending(r => r.CreatedAt) 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(); .ToListAsync();
return Results.Ok(new { code = 0, data = reports, message = (string?)null }); 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 = "医生档案未关联" }); return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId) 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(); .FirstOrDefaultAsync();
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
return Results.Ok(new { code = 0, data = report, message = (string?)null }); 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() ?? "", Title = json.RootElement.GetProperty("title").GetString() ?? "",
DoctorName = profile.Name, DoctorName = profile.Name,
Department = profile.Department, 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, Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null,
Status = FollowUpStatus.Upcoming, Status = FollowUpStatus.Upcoming,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
@@ -414,7 +429,7 @@ public static class DoctorEndpoints
var body = await reader.ReadToEndAsync(ct); var body = await reader.ReadToEndAsync(ct);
var json = System.Text.Json.JsonDocument.Parse(body); 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("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("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; if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse<FollowUpStatus>(st.GetString(), out var fs)) followUp.Status = fs;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);

View File

@@ -1,3 +1,5 @@
using Health.Infrastructure.Files;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
public static class FileEndpoints public static class FileEndpoints
@@ -47,24 +49,48 @@ public static class FileEndpoints
var storedName = $"{fileId}{ext}"; var storedName = $"{fileId}{ext}";
var filePath = Path.Combine(uploadsDir, $"{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); await file.CopyToAsync(stream, ct);
results.Add(new results.Add(new
{ {
id = fileId, id = fileId,
name = file.FileName, name = file.FileName,
size = file.Length, size = file.Length,
url = $"/uploads/users/{userDirectoryName}/{storedName}", url = $"/api/files/content/{storedName}",
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
}); });
} }
return Results.Ok(new { code = 0, data = results, message = (string?)null }); 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) => private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
? id ? id
: Guid.Empty; : 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 = "用药记录不存在" }); : 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) => group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);

View File

@@ -24,6 +24,47 @@ public static class ReportEndpoints
: Results.Ok(new { code = 0, data = report, message = (string?)null }); : 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) => group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
@@ -66,4 +107,9 @@ public static class ReportEndpoints
private static Guid GetUserId(HttpContext http) => private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; 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 birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
var updated = await users.UpdateProfileAsync( var updated = await users.UpdateProfileAsync(
GetUserId(http), GetUserId(http),
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate), new UserProfileUpdateRequest(req.Name, req.Gender, birthDate, req.AvatarUrl),
ct); ct);
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" }); 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) => 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; 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( public sealed record UpdateArchiveRequest(
string? Diagnosis, string? SurgeryType, string? SurgeryDate, string? Diagnosis, string? SurgeryType, string? SurgeryDate,
List<string>? Allergies, List<string>? DietRestrictions, List<string>? Allergies, List<string>? DietRestrictions,

View File

@@ -16,8 +16,19 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
{ {
await _next(context); await _next(context);
} }
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// SSE/流式请求由客户端主动取消时,响应通常已经开始。
// 这是正常断开,不应再尝试改写状态码或错误正文。
_logger.LogDebug("客户端已取消请求: {Path}", context.Request.Path);
}
catch (Health.Domain.ValidationException vex) catch (Health.Domain.ValidationException vex)
{ {
if (context.Response.HasStarted)
{
_logger.LogWarning(vex, "响应开始后发生业务校验异常: {Path}", context.Request.Path);
return;
}
// 业务输入校验失败:返回 400message 为我们自己产生的提示,可安全展示 // 业务输入校验失败:返回 400message 为我们自己产生的提示,可安全展示
context.Response.StatusCode = (int)HttpStatusCode.BadRequest; context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
context.Response.ContentType = "application/json"; context.Response.ContentType = "application/json";
@@ -26,6 +37,11 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
} }
catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex) catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex)
{ {
if (context.Response.HasStarted)
{
_logger.LogWarning(ex, "响应开始后发生请求解析异常: {Path}", context.Request.Path);
return;
}
// 请求体无法解析(非法 JSON、编码错误、请求体过大等返回其携带的状态码通常 400而不是 500 // 请求体无法解析(非法 JSON、编码错误、请求体过大等返回其携带的状态码通常 400而不是 500
_logger.LogWarning(ex, "请求解析失败: {Path}", context.Request.Path); _logger.LogWarning(ex, "请求解析失败: {Path}", context.Request.Path);
context.Response.StatusCode = ex.StatusCode; context.Response.StatusCode = ex.StatusCode;
@@ -37,6 +53,8 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
{ {
// 生产环境不暴露内部异常详情 // 生产环境不暴露内部异常详情
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path); _logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
if (context.Response.HasStarted)
return;
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/json"; context.Response.ContentType = "application/json";

View File

@@ -11,6 +11,7 @@ using Health.Application.Medications;
using Health.Application.Maintenance; using Health.Application.Maintenance;
using Health.Application.Notifications; using Health.Application.Notifications;
using Health.Application.Reports; using Health.Application.Reports;
using Health.Application.Speech;
using Health.Application.Users; using Health.Application.Users;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Health.Infrastructure.Admin; using Health.Infrastructure.Admin;
@@ -25,6 +26,7 @@ using Health.Infrastructure.Medications;
using Health.Infrastructure.Maintenance; using Health.Infrastructure.Maintenance;
using Health.Infrastructure.Notifications; using Health.Infrastructure.Notifications;
using Health.Infrastructure.Reports; using Health.Infrastructure.Reports;
using Health.Infrastructure.Speech;
using Health.Infrastructure.Services; using Health.Infrastructure.Services;
using Health.Infrastructure.Users; using Health.Infrastructure.Users;
using Health.WebApi.BackgroundServices; using Health.WebApi.BackgroundServices;
@@ -34,7 +36,6 @@ using Health.WebApi.Middleware;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
// 加载 .env 文件(开发环境) // 加载 .env 文件(开发环境)
@@ -107,10 +108,17 @@ builder.Services.AddAuthorization();
// ---- 业务服务 ---- // ---- 业务服务 ----
builder.Services.AddSingleton<JwtProvider>(); builder.Services.AddSingleton<JwtProvider>();
builder.Services.AddHttpClient<BceSmsClient>(client =>
{
var host = builder.Configuration["BAIDU_SMS_HOST"] ?? "smsv3.bj.baidubce.com";
client.BaseAddress = new Uri($"https://{host}");
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddSingleton<SmsService>(); builder.Services.AddSingleton<SmsService>();
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证 builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
builder.Services.AddSingleton<PromptManager>(); builder.Services.AddSingleton<PromptManager>();
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>(); builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
builder.Services.AddScoped<IAiEntryDraftStore, EfAiEntryDraftStore>();
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>(); builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
builder.Services.AddScoped<IAdminService, AdminService>(); builder.Services.AddScoped<IAdminService, AdminService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
@@ -151,6 +159,7 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>(); builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserRepository, EfUserRepository>(); builder.Services.AddScoped<IUserRepository, EfUserRepository>();
builder.Services.AddSingleton<IRealtimeSpeechRecognitionProxy, DashScopeSpeechRecognitionProxy>();
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup( builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
Path.Combine(Directory.GetCurrentDirectory(), "uploads"))); Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
@@ -206,18 +215,17 @@ app.UseMiddleware<ExceptionMiddleware>();
app.UseCors(); app.UseCors();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(20),
});
// 默认 wwwroot 静态文件(法律文档 H5 页面等) // 默认 wwwroot 静态文件(法律文档 H5 页面等)
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); // 用户上传文件不能作为静态目录公开;统一通过带鉴权和归属校验的 API 读取。
Directory.CreateDirectory(uploadsPath); Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), "uploads"));
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploadsPath),
RequestPath = "/uploads"
});
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
app.MapOpenApi(); app.MapOpenApi();
@@ -246,6 +254,7 @@ app.MapNotificationEndpoints();
app.MapDoctorEndpoints(); app.MapDoctorEndpoints();
app.MapAdminEndpoints(); app.MapAdminEndpoints();
app.MapFollowUpEndpoints(); app.MapFollowUpEndpoints();
app.MapSpeechEndpoints();
// SignalR Hub // SignalR Hub
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation"); app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");

View File

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

View File

@@ -14,7 +14,7 @@
"JWT_AUDIENCE": "health-manager-app", "JWT_AUDIENCE": "health-manager-app",
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1", "DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
"DEEPSEEK_API_KEY": "sk-your-key-here", "DEEPSEEK_API_KEY": "sk-your-key-here",
"DEEPSEEK_MODEL": "deepseek-chat", "DEEPSEEK_MODEL": "deepseek-v4-flash",
"QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1", "QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"QWEN_API_KEY": "sk-your-key-here", "QWEN_API_KEY": "sk-your-key-here",
"QWEN_VISION_MODEL": "qwen-vl-max", "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 userId = Guid.NewGuid();
var userDirectory = Path.Combine(root, "users", userId.ToString("N")); var userDirectory = Path.Combine(root, "users", userId.ToString("N"));
var reportPath = Path.Combine(root, "reports", "owned-report.pdf"); var reportPath = Path.Combine(root, "reports", "owned-report.pdf");
var chatImagePath = Path.Combine(root, "owned-chat.jpg"); var chatFileName = $"{Guid.NewGuid()}.jpg";
var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().ToString("N"), "keep.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"); var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt");
try try
@@ -31,7 +33,7 @@ public sealed class AccountDeletionTests
var cleanup = new LocalAccountFileCleanup(root); var cleanup = new LocalAccountFileCleanup(root);
var references = new AccountFileReferences( var references = new AccountFileReferences(
["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"], ["/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); 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;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums; using Health.Domain.Enums;
using Health.Infrastructure.AI.AgentHandlers;
using System.Text.Json;
namespace Health.Tests; namespace Health.Tests;
@@ -95,6 +97,66 @@ public sealed class ApplicationServiceTests
Assert.Equal(1, repository.SaveCount); 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] [Fact]
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery() public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
{ {
@@ -211,6 +273,24 @@ public sealed class ApplicationServiceTests
Assert.Equal(["medication", "exercise", "followup"], events); 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] [Fact]
public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates() public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates()
{ {
@@ -252,6 +332,130 @@ public sealed class ApplicationServiceTests
Assert.Equal(2, current.Items.Count); 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.Contains("散步", upcomingJson.RootElement.GetProperty("authoritative_answer").GetString());
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] [Fact]
public async Task ExercisePlan_CheckInRejectsNonTodayItem() public async Task ExercisePlan_CheckInRejectsNonTodayItem()
{ {
@@ -360,9 +564,11 @@ public sealed class ApplicationServiceTests
public void SetPlan(ExercisePlan plan) => Plan = plan; public void SetPlan(ExercisePlan plan) => Plan = plan;
public Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>( 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] : []); 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?> 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<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 Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; }
public void Delete(ExercisePlan plan) { Plan = null; } public void Delete(ExercisePlan plan) { Plan = null; }

View File

@@ -1,8 +1,11 @@
using System.Text.Json;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Infrastructure.Auth;
using Health.Infrastructure.Data; using Health.Infrastructure.Data;
using Health.Infrastructure.Services; using Health.Infrastructure.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace Health.Tests; namespace Health.Tests;
@@ -30,12 +33,16 @@ public class AuthTests
return new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); return new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
} }
private static SmsService CreateSmsService() =>
new(new BceSmsClient(new HttpClient(), new ConfigurationBuilder().Build(), NullLogger<BceSmsClient>.Instance),
NullLogger<SmsService>.Instance);
[Fact] [Fact]
public async Task SendSms_Should_Create_VerificationCode() public async Task SendSms_Should_Create_VerificationCode()
{ {
// Arrange // Arrange
using var db = CreateDbContext(); using var db = CreateDbContext();
var sms = new SmsService(); var sms = CreateSmsService();
// Act // Act
var code = sms.GenerateCode(); var code = sms.GenerateCode();
@@ -118,6 +125,47 @@ public class AuthTests
Assert.NotNull(active); 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,
CreateSmsService(),
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] [Fact]
public async Task VerificationCode_Expired_Should_Fail_Login() public async Task VerificationCode_Expired_Should_Fail_Login()
{ {

View File

@@ -0,0 +1,111 @@
using Health.Infrastructure.Services;
namespace Health.Tests;
/// <summary>
/// BCE v1 签名算法测试
/// Golden value 由 Python 标准库 hmac + hashlib 按 baidubce-sdk 算法手算,
/// 并与 baidubce-sdk 0.9.72 的实际 debug 输出对比验证一致
/// </summary>
public class BceSignerTests
{
private const string Ak = "test-ak";
private const string Sk = "test-sk";
private const string Host = "smsv3.bj.baidubce.com";
private const string Method = "POST";
private const string ApiPath = "/api/v3/sendSms";
private const string ContentType = "application/json;charset=utf-8";
private const int ContentLength = 199;
private static readonly DateTime Ts1 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Ts2 = new(2026, 7, 23, 8, 0, 0, DateTimeKind.Utc);
// Golden 1: 固定输入
private const string ExpectedSig1 = "7592321b147186724a0194344aa274b4875fbc3442b4a5a07db4fd00cf44e580";
private const string ExpectedAuth1 = "bce-auth-v1/test-ak/2026-01-01T00:00:00Z/1800/content-length;content-type;host;x-bce-date/" + ExpectedSig1;
// Golden 2: 不同 timestamp
private const string ExpectedSig2 = "5c058909c04e8a6964fc22f9ae00cd5aae9c9182cb21fca7a76eb834eaa47c0b";
// Golden 3: 不同 SK
private const string ExpectedSig3 = "b944339bad2635d0eb41344427c1328f9785335a64baaabced658fa487fb228a";
// Golden 4: 不同 content_length
private const string ExpectedSig4 = "7b2f8bfdda2255b2331992a4adb9fb7365aa83c62453b61380cc7be350e6105f";
[Fact]
public void BuildAuthorization_FixedInput_MatchesGoldenValue()
{
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
Assert.Equal(ExpectedAuth1, auth);
}
[Fact]
public void BuildAuthorization_DifferentTimestamp_ProducesDifferentSignature()
{
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
var auth2 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts2, ContentLength, ContentType);
Assert.NotEqual(auth1, auth2);
Assert.EndsWith(ExpectedSig2, auth2);
}
[Fact]
public void BuildAuthorization_DifferentSecretKey_ProducesDifferentSignature()
{
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
var auth3 = BceSigner.BuildAuthorization(Ak, "test-sk-2", Method, ApiPath, Host, Ts1, ContentLength, ContentType);
Assert.NotEqual(auth1, auth3);
Assert.EndsWith(ExpectedSig3, auth3);
}
[Fact]
public void BuildAuthorization_DifferentContentLength_ProducesDifferentSignature()
{
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
var auth4 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, 100, ContentType);
Assert.NotEqual(auth1, auth4);
Assert.EndsWith(ExpectedSig4, auth4);
}
[Fact]
public void BuildAuthorization_Signature_Is64CharLowercaseHex()
{
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
var sig = auth.Split('/')[^1];
Assert.Equal(64, sig.Length);
Assert.Matches("^[0-9a-f]{64}$", sig);
}
[Fact]
public void BuildAuthorization_Format_HasCorrectStructure()
{
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
var parts = auth.Split('/');
Assert.Equal(6, parts.Length);
Assert.Equal("bce-auth-v1", parts[0]);
Assert.Equal(Ak, parts[1]);
Assert.Equal("2026-01-01T00:00:00Z", parts[2]);
Assert.Equal("1800", parts[3]);
Assert.Equal("content-length;content-type;host;x-bce-date", parts[4]);
Assert.Equal(ExpectedSig1, parts[5]);
}
[Fact]
public void BuildAuthorization_RealCredentials_MatchesSdkDebugOutput()
{
// 用百度 SDK 0.9.72 实际 debug 日志里的真实输入,验证 C# 输出和 SDK 完全一致
var auth = BceSigner.BuildAuthorization(
"ALTAKnJn8wtdLtBPgl7bD1x3N3",
"c5ca05a967d04a69bbc0e12f08062dc8",
"POST",
"/api/v3/sendSms",
"smsv3.bj.baidubce.com",
new DateTime(2026, 7, 23, 9, 3, 42, DateTimeKind.Utc),
199,
"application/json;charset=utf-8");
Assert.Equal(
"bce-auth-v1/ALTAKnJn8wtdLtBPgl7bD1x3N3/2026-07-23T09:03:42Z/1800/content-length;content-type;host;x-bce-date/0fd388266f63e9036de09981d7312ce3b7e279c8789823cb80b6c1800005fe13",
auth);
}
}

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.Application.Medications;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Infrastructure.AI.AgentHandlers;
using System.Text.Json;
namespace Health.Tests; namespace Health.Tests;
@@ -32,21 +35,227 @@ public sealed class MedicationUpdateTests
Assert.True(repository.Saved); 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 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>> 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<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<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<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<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<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask; 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 DeleteMedication(Medication value) { }
public void DeleteLog(MedicationLog log) { } public void DeleteLog(MedicationLog log) { }
public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; } public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; }

View File

@@ -1,18 +1,69 @@
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Health.Domain.Enums;
namespace Health.Tests; namespace Health.Tests;
public sealed class PromptManagerTests public sealed class PromptManagerTests
{ {
[Fact] private readonly PromptManager _prompts = new();
public void UnifiedPrompt_DistinguishesQuestionsFromEntryRequests()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
Assert.Contains("先判断用户是在咨询数值,还是希望记录数据", prompt); [Fact]
Assert.Contains("血氧98%是不是太高了", prompt); public void RouterPrompt_DistinguishesEntryConsultationAndDraftContinuation()
Assert.Contains("先回答问题,不调用 record_health_data", prompt); {
var prompt = _prompts.GetIntentRouterPrompt();
Assert.Contains("只负责识别意图,不回答用户问题", prompt);
Assert.Contains("帮我记录血压 116/89", prompt); Assert.Contains("帮我记录血压 116/89", prompt);
Assert.Contains("血压 116/89 正常吗", prompt);
Assert.Contains("同时包含 health_entry 和 medical_consultation", prompt);
Assert.Contains("存在草稿不代表所有后续消息都必须继续草稿", prompt);
}
[Fact]
public void HealthEntryPrompt_RequiresWholeBatchDraftAndOneConfirmationCard()
{
var prompt = _prompts.ComposeForIntents(["health_entry"]);
Assert.Contains("113/86、113-86、113—86", prompt);
Assert.Contains("多指标属于同一录入批次", prompt);
Assert.Contains("record_health_data_batch", prompt);
Assert.Contains("每个录入批次只调用一次", prompt);
Assert.Contains("先根据后端结果追问缺失或错误部分", prompt);
Assert.Contains("全部补齐后再生成整批确认卡", prompt);
Assert.Contains("只有后端明确提供的待补充草稿", prompt);
Assert.DoesNotContain("医学知识库使用规则", prompt);
}
[Fact]
public void EntryPrompts_DoNotInventRequiredPlanFields()
{
var medication = _prompts.ComposeForIntents(["medication_entry"]);
var exercise = _prompts.ComposeForIntents(["exercise_entry"]);
Assert.Contains("药品名称、每次剂量、频率、具体服药时间、开始日期", medication);
Assert.Contains("不自行新增药品、猜测剂量", medication);
Assert.Contains("运动项目、每天运动时长、持续天数、开始日期和具体提醒时间", exercise);
Assert.Contains("当前产品不自动补默认", exercise);
}
[Fact]
public void RagRules_AreOnlyComposedForMedicalKnowledgeModules()
{
var consultation = _prompts.ComposeForIntents(["medical_consultation"]);
var query = _prompts.ComposeForIntents(["personal_query"]);
var chat = _prompts.ComposeForIntents(["general_chat"]);
Assert.Contains("医学知识库使用规则", consultation);
Assert.DoesNotContain("医学知识库使用规则", query);
Assert.DoesNotContain("医学知识库使用规则", chat);
}
[Fact]
public void MixedIntentPrompt_ComposesBothEntryAndConsultationRules()
{
var prompt = _prompts.ComposeForIntents(["health_entry", "medical_consultation"]);
Assert.Contains("健康指标录入模块", prompt);
Assert.Contains("医疗咨询与健康建议模块", prompt);
Assert.Contains("医学知识库使用规则", 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 修改蓝牙、聊天、删除、注销和历史恢复等业务逻辑。
- 不声称构建、安装或真机验证成功,除非实际执行并看到结果。

219
docs/HANDOFF-2026-07-17.md Normal file
View File

@@ -0,0 +1,219 @@
# 小脉健康项目交接报告2026-07-17
## 1. 本轮工作范围
本轮工作集中在以下三项:
1. 对医生端和管理员端进行患者端风格的轻量统一。
2. 检查并修复医生、登录账号、医生资料之间的同步逻辑。
3. 进行源码级逻辑检查和小范围自动化验证。
本轮没有生成 APK、没有进行真机或截图测试、没有启动 Web API、没有部署、没有推送远程代码也没有修改真实数据库数据。
## 2. 医生端与管理员端 UI 改造
### 2.1 共用视觉层
新增后台共用展示组件:
- `health_app/lib/widgets/backoffice_ui.dart`
- `health_app/lib/utils/backoffice_formatters.dart`
- `health_app/lib/providers/backoffice_refresh_providers.dart`
统一内容包括:
- 使用患者端现有的紫蓝渐变和浅色页面背景。
- 使用白色圆角卡片、轻边框和统一阴影。
- 统一加载、空数据、加载失败和重试状态。
- 统一管理员端、医生端侧边栏的选中态。
- 对空姓名头像和不完整时间字符串进行安全展示。
### 2.2 医生端
已覆盖的主要页面:
- 工作台
- 患者列表与患者详情
- 问诊列表
- 报告列表与报告详情
- 随访列表与新增/编辑随访
- 医生资料
- 医生设置
- 医生侧边栏
已修复的页面问题:
- “新建随访”以前会被路由器当成缺少 ID 的详情页,现在可以正常进入新建模式。
- 新增或编辑随访后,返回列表会自动刷新。
- 随访和报告日期不再直接强制截取 16 个字符,避免空值或短字符串导致 `RangeError`
- 空患者姓名不再因为直接读取第一个字符而导致崩溃。
- 患者列表加载失败后不再反复自动请求。
- 医生资料页面重建时不再反复覆盖用户正在编辑的输入内容。
- 停用医生登录后,工作台显示“账号已停用,新患者暂时无法选择您”的提示。
### 2.3 管理员端
已覆盖的主要页面:
- 医生管理
- 患者管理
- 新增医生
- 编辑医生
- 管理员侧边栏
已修复的页面问题:
- 管理员接口失败时不再无限显示加载状态。
- 医生列表增加“编辑”入口,复用新增医生表单。
- 新增或编辑医生后,返回列表会自动刷新。
- 停用和删除失败时显示后端返回的具体原因,不再静默失败。
- 删除确认文案改为安全规则:有绑定患者或问诊记录时只能停用,不能删除。
- 停用医生在管理员列表中继续显示“已停用”状态。
## 3. 医生账号三层数据同步
医生相关数据由以下三部分组成:
- `Doctor`:患者选择、医生展示和业务归属。
- `User(Role=Doctor)`:医生登录账号。
- `DoctorProfile`:医生端资料和医生实体关联。
本轮修改了 `backend/src/Health.Infrastructure/Admin/AdminService.cs`,规则如下。
### 3.1 新增医生
- 一次性创建 `Doctor`、医生登录 `User``DoctorProfile`
- 三者使用相同的手机号和姓名,并建立正确 ID 关联。
- 手机号已被患者、医生或其他账号使用时拒绝新增。
### 3.2 编辑医生
- 手机号同步更新 `Doctor` 和登录 `User`
- 姓名同步更新 `Doctor`、登录 `User``DoctorProfile`
- 职称、科室同步更新 `Doctor``DoctorProfile`
- 专业方向更新到 `Doctor`
- 修改手机号前检查其他账号和医生是否已经占用。
- 医生登录资料缺失时拒绝局部修改,避免继续扩大不一致数据。
### 3.3 停用医生
- 同步更新 `Doctor.IsActive``DoctorProfile.IsActive`
- 医生登录账号保持 `Role=Doctor`,仍可正常登录并服务已绑定患者。
- 患者与医生的原有绑定关系保持不变。
- 公开医生列表原本已经只查询 `Doctor.IsActive == true`,因此新患者注册时不会再看到已停用医生。
- 注册接口原本已经再次校验医生必须有效且启用。
### 3.4 删除医生
- 医生仍有绑定患者时拒绝删除。
- 医生已有问诊记录时拒绝删除。
- 无绑定患者和问诊记录时,删除 `Doctor`、对应登录 `User``DoctorProfile` 和刷新令牌。
- 有历史数据的医生应使用“停用”,不应通过物理删除清理。
## 4. 自动化验证
本轮最终验证结果:
- `flutter analyze`:通过,零问题。
- 后端 `Health.Tests`56 项通过0 项失败0 项跳过。
- 医生/管理员 UI、路由和刷新相关测试通过。
新增或更新的测试包括:
- `backend/tests/Health.Tests/admin_service_tests.cs`
- `health_app/test/backoffice_ui_test.dart`
- `health_app/test/backoffice_formatters_test.dart`
- `health_app/test/backoffice_refresh_test.dart`
- `health_app/test/app_router_test.dart`
说明:`dotnet test` 会自动编译测试程序集,但本轮没有执行发布构建,没有生成 Android APK/AAB也没有执行 iOS 构建。
## 5. 数据库核对结果
本轮只进行了只读连接检查,没有修改数据库。
- 项目本地配置指向 `localhost:5432 / health_manager`
- 本机 PostgreSQL 没有运行,因此无法读取本地实际数据。
- 开发 API `http://10.4.237.12:5000` 当前无法连接。
- 正式 API `https://erpapi.datalumina.cn/xiaomai/api/doctors` 可以访问,但公开接口返回的启用医生数量为 0。
- 交接资料显示正式数据库和备份方案尚未最终配置。
因此目前无法确认数据库中是否已经存在以下旧数据问题:
- `Doctor` 存在但没有医生登录 `User`
- `DoctorProfile` 缺失或关联错误。
- 三层手机号、姓名或启用状态不一致。
- 重复医生手机号。
- 患者 `DoctorId` 指向不存在的医生。
新代码可以防止以后继续产生这些不一致,但不会自动修改已有数据。获得生产 PostgreSQL 只读连接或实际数据库备份后,应单独执行数据核对。
## 6. 仍未解决的高优先级问题
### P0固定管理员验证码
`backend/src/Health.Infrastructure/Auth/AuthService.cs` 仍保留固定管理员手机号和固定验证码 `000000`。上线前必须移除,管理员需要使用受控的创建和认证方式。
### P0短信服务仍是开发实现
`backend/src/Health.Infrastructure/Services/sms_service.cs` 只向服务端控制台输出验证码,没有接入真实短信服务。生产环境不会把验证码返回 App因此普通用户无法正常接收验证码。
### P0问诊 SignalR Hub 未鉴权
`backend/src/Health.WebApi/Hubs/ConsultationHub.cs` 没有强制 JWT 身份验证,也没有校验用户或医生是否属于指定问诊。调用者可以传入 `senderType``senderName`,存在加入其他问诊、伪造医生消息和写入数据库的风险。
### P1医生聊天错误复用患者聊天流程
医生问诊列表进入的 `DoctorChatPage` 复用了患者端 `consultationChatProvider`
- 把问诊 ID 当成医生 ID。
- 调用患者配额接口。
- 尝试创建新问诊。
- 发送消息时使用患者身份。
- 医生需要回复的 `WaitingDoctor` 状态反而不能发送。
医生实时问诊目前不能视为可用功能。需要拆分独立医生聊天 Provider 和页面,并与 Hub 鉴权一起处理。
### P1AI SSE 鉴权和上传文件隐私
此前检查发现:
- AI SSE 接口允许从 query token 中直接读取 JWT claims没有完整验证签名、签发者、受众和有效期。
- 上传的医疗图片和报告通过 `/uploads` 静态公开访问。
- 附件本地路径解析缺少完整的目录边界和文件归属校验。
- CORS 当前允许任意来源并携带凭据。
这些问题应在正式上线前统一修复。
### P1iOS 蓝牙流程仍需修复和真机验证
此前检查发现 iOS 页面仍请求 Android 风格蓝牙权限,并调用仅 Android 支持的 `FlutterBluePlus.turnOn()`。iOS Pods 也需要在 macOS 上重新安装并验证。当前 Windows 环境不能证明 iOS 构建和真机蓝牙可用。
## 7. Apple 医疗硬件审核准备
如果首版明确只支持欧姆龙 J735建议准备
- J735 当前有效的医疗器械注册或批准材料。
- 小脉健康 App 与 J735 的联调测试报告。
- 真实 iPhone、J735 和当前版本 App 的完整配对及测量演示视频。
- App、审核说明、兼容设备清单和宣传文案全部明确首版支持范围。
注册证只能证明血压计本身合规,不能替代 App 与硬件联调测试报告和演示视频。
## 8. 建议后续顺序
1. 修复固定管理员验证码并接入正式短信服务。
2. 拆分医生聊天流程并为 SignalR Hub 增加 JWT、角色和问诊归属校验。
3. 修复 AI SSE 鉴权、上传文件访问控制、路径边界和 CORS。
4. 获得真实数据库只读连接,核对并修复旧医生账号数据。
5. 在 macOS 和真实 iPhone 上修复并验证 iOS 蓝牙流程。
6. 准备 J735 监管材料、联调报告和真实设备演示视频。
7. 完成正式服务器、数据库、短信、对象存储、域名和 HTTPS 配置后,再进入发布构建和应用商店提交。
## 9. 当前操作边界
- 本轮代码修改和本交接报告均未进行新的 Git 提交或远程推送。
- 没有执行数据库迁移。
- 没有写入、更新或删除真实数据库数据。
- 没有生成或上传发布安装包。
- 没有启动需要额外关闭的前端或后端常驻服务。

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

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.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 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

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