Compare commits
16 Commits
fade61ac21
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4bc15b9b9 | ||
|
|
25a4078688 | ||
|
|
32aae40b12 | ||
|
|
28f704c98e | ||
|
|
0cb5b8e85a | ||
|
|
9cea41705e | ||
|
|
0d4fd88ce7 | ||
|
|
ae94ced2d5 | ||
|
|
e1f4a4b91f | ||
|
|
6e5d3e64cd | ||
|
|
528859686f | ||
|
|
1a9d56b07c | ||
|
|
e52e21d295 | ||
| ec5af19d30 | |||
|
|
bd4350c17f | ||
|
|
fe4c81c54f |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -4,9 +4,17 @@
|
||||
*.key
|
||||
*.pfx
|
||||
|
||||
# Android release keystore
|
||||
health_app/android/**/*.jks
|
||||
health_app/android/key.properties
|
||||
|
||||
# .NET build outputs
|
||||
backend/**/bin/
|
||||
backend/**/obj/
|
||||
backend/artifacts/
|
||||
|
||||
# Generated local audit reports
|
||||
audit/*.html
|
||||
|
||||
# PostgreSQL local data
|
||||
backend/pgdata/
|
||||
@@ -42,6 +50,7 @@ backend/src/Health.WebApi/data-protection-keys/
|
||||
*.txt
|
||||
*.jpg
|
||||
*.png
|
||||
!backend/src/Health.WebApi/wwwroot/app-icon.png
|
||||
!health_app/assets/branding/*.png
|
||||
!health_app/android/app/src/main/res/drawable/launch_brand.png
|
||||
!health_app/android/app/src/main/res/drawable-v21/launch_brand.png
|
||||
|
||||
57
AGENTS.md
Normal file
57
AGENTS.md
Normal 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. 执行后进行完整验证并报告结果。
|
||||
|
||||
用户在具体任务中的直接指令始终优先于本文件。
|
||||
1
audit/2026-07-18-ui/00-current.xml
Normal file
1
audit/2026-07-18-ui/00-current.xml
Normal file
File diff suppressed because one or more lines are too long
@@ -9,13 +9,21 @@ JWT_AUDIENCE=health-manager-app
|
||||
# DeepSeek LLM
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
DEEPSEEK_API_KEY=sk-your-key-here
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
|
||||
# 千问 VLM
|
||||
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
QWEN_API_KEY=sk-your-key-here
|
||||
QWEN_VISION_MODEL=qwen-vl-max
|
||||
|
||||
# 阿里云百炼实时语音输入(Fun-ASR)
|
||||
DASHSCOPE_ASR_API_KEY=sk-your-dashscope-key-here
|
||||
DASHSCOPE_ASR_WORKSPACE_ID=your-workspace-id
|
||||
DASHSCOPE_ASR_MODEL=fun-asr-realtime
|
||||
DASHSCOPE_ASR_MAX_DURATION_SECONDS=60
|
||||
# 可选;不填时按 Workspace ID 自动生成北京地域专属地址
|
||||
# DASHSCOPE_ASR_WEBSOCKET_URL=wss://your-workspace-id.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
|
||||
24
backend/src/Health.Application/AI/AiEntryDraftContracts.cs
Normal file
24
backend/src/Health.Application/AI/AiEntryDraftContracts.cs
Normal 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);
|
||||
}
|
||||
@@ -16,5 +16,5 @@ public interface IAttachmentContextBuilder
|
||||
/// <summary>
|
||||
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
|
||||
/// </summary>
|
||||
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
|
||||
Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
using System.Text;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
using Health.Application.Medications;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed class PatientContextService(
|
||||
IHealthArchiveService archives,
|
||||
IHealthRecordService healthRecords,
|
||||
IMedicationService medications) : IPatientContextService
|
||||
IHealthRecordService healthRecords) : IPatientContextService
|
||||
{
|
||||
private readonly IHealthArchiveService _archives = archives;
|
||||
private readonly IHealthRecordService _healthRecords = healthRecords;
|
||||
private readonly IMedicationService _medications = medications;
|
||||
|
||||
public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await _archives.GetAsync(userId, ct);
|
||||
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
|
||||
var activeMedications = await _medications.ListActiveAsync(userId, ct);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (archive != null)
|
||||
@@ -36,23 +32,11 @@ public sealed class PatientContextService(
|
||||
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
|
||||
}
|
||||
|
||||
if (activeMedications.Count > 0)
|
||||
{
|
||||
sb.AppendLine("当前用药:");
|
||||
foreach (var medication in activeMedications.Take(20))
|
||||
{
|
||||
var times = medication.TimeOfDay.Count > 0
|
||||
? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm")))
|
||||
: "未设置时间";
|
||||
sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}");
|
||||
}
|
||||
}
|
||||
|
||||
if (recentRecords.Count > 0)
|
||||
{
|
||||
sb.AppendLine("近期健康数据:");
|
||||
foreach (var record in recentRecords)
|
||||
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})");
|
||||
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({ToBeijing(record.RecordedAt):MM-dd HH:mm} 北京时间)");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
@@ -67,4 +51,15 @@ public sealed class PatientContextService(
|
||||
"Weight" => $"{record.Value}kg",
|
||||
_ => "—"
|
||||
};
|
||||
|
||||
private static DateTime ToBeijing(DateTime value)
|
||||
{
|
||||
var utc = value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
|
||||
};
|
||||
return utc.AddHours(8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ namespace Health.Application.Auth;
|
||||
|
||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct);
|
||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
||||
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
|
||||
.ToList();
|
||||
var followUps = snapshot.FollowUps
|
||||
.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)
|
||||
.Where(f => BeijingDate(f.ScheduledAt) == date)
|
||||
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
|
||||
.ToList();
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
||||
entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate }));
|
||||
}
|
||||
|
||||
foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date))
|
||||
foreach (var followUp in snapshot.FollowUps.Where(f => BeijingDate(f.ScheduledAt) == date))
|
||||
{
|
||||
entries.Add(new CalendarEntry("followup", new
|
||||
{
|
||||
@@ -88,5 +88,14 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
||||
&& (medication.StartDate == null || medication.StartDate <= date)
|
||||
&& (medication.EndDate == null || medication.EndDate >= date);
|
||||
|
||||
private static DateOnly BeijingDate(DateTime value)
|
||||
{
|
||||
// EF 从 timestamptz 读取的是 UTC;单元测试及旧内存对象可能是 Unspecified。
|
||||
var beijing = value.Kind == DateTimeKind.Unspecified
|
||||
? value
|
||||
: value.ToUniversalTime().AddHours(8);
|
||||
return DateOnly.FromDateTime(beijing);
|
||||
}
|
||||
|
||||
private sealed record CalendarEntry(string Type, object Value);
|
||||
}
|
||||
|
||||
@@ -42,19 +42,19 @@ public interface IExerciseService
|
||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IExerciseRepository
|
||||
{
|
||||
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task AddAsync(ExercisePlan plan, CancellationToken ct);
|
||||
void Delete(ExercisePlan plan);
|
||||
|
||||
@@ -33,8 +33,11 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType,
|
||||
DurationMinutes = duration, IsRestDay = false,
|
||||
Id = Guid.NewGuid(),
|
||||
ScheduledDate = date,
|
||||
ExerciseType = exerciseType,
|
||||
DurationMinutes = duration,
|
||||
IsRestDay = false,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
@@ -52,10 +55,13 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plans = await _exercises.ListAsync(userId, 20, ct);
|
||||
return plans.Select(p => new ExercisePlanSummaryDto(
|
||||
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt,
|
||||
p.Items.Count, p.Items.Count(i => i.IsCompleted),
|
||||
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList();
|
||||
return plans.Select(ToSummaryDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plans = await _exercises.ListAllAsync(userId, ct);
|
||||
return plans.Select(ToSummaryDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
|
||||
@@ -103,12 +109,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetLatestAsync(userId, ct);
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
|
||||
{
|
||||
await _exercises.AddAsync(plan, ct);
|
||||
@@ -117,15 +117,24 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
|
||||
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate,
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
ReminderTime = reminderTime ?? DefaultReminderTime,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
|
||||
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
|
||||
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
|
||||
|
||||
private static ExercisePlanSummaryDto ToSummaryDto(ExercisePlan plan) => new(
|
||||
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt,
|
||||
plan.Items.Count, plan.Items.Count(i => i.IsCompleted),
|
||||
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
|
||||
|
||||
private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
|
||||
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
|
||||
item.IsCompleted, item.CompletedAt, item.IsRestDay);
|
||||
|
||||
@@ -45,6 +45,21 @@ public sealed record MedicationReminderDto(
|
||||
string ScheduledTime,
|
||||
string Status);
|
||||
|
||||
public sealed record AiMedicationDoseDto(
|
||||
string ScheduledTime,
|
||||
string Status);
|
||||
|
||||
public sealed record AiMedicationPlanDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Dosage,
|
||||
string Frequency,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
string Phase,
|
||||
bool ScheduledOnDate,
|
||||
IReadOnlyList<AiMedicationDoseDto> Doses);
|
||||
|
||||
public sealed record MedicationReminderTask(
|
||||
Guid TaskId,
|
||||
Guid UserId,
|
||||
@@ -60,12 +75,10 @@ public interface IMedicationService
|
||||
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct);
|
||||
Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationRepository
|
||||
@@ -74,8 +87,6 @@ public interface IMedicationRepository
|
||||
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
|
||||
|
||||
@@ -96,23 +96,6 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
{
|
||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
||||
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct);
|
||||
if (existing != null)
|
||||
{
|
||||
_medications.DeleteLog(existing);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||
@@ -147,7 +130,14 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
|
||||
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
|
||||
{
|
||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
||||
var medication = await _medications.GetOwnedAsync(userId, medicationId, ct);
|
||||
if (medication == null) return null;
|
||||
|
||||
var today = BeijingToday();
|
||||
if (!IsActiveOn(medication, today) ||
|
||||
!GetDosesForToday(medication, today) ||
|
||||
!medication.TimeOfDay.Contains(scheduledTime))
|
||||
return false;
|
||||
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
|
||||
@@ -168,18 +158,48 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) =>
|
||||
ListAsync(userId, "active", ct);
|
||||
|
||||
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
public async Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(
|
||||
Guid userId,
|
||||
DateOnly date,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await ConfirmDoseAsync(
|
||||
userId,
|
||||
medicationId,
|
||||
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
MedicationLogStatus.Taken,
|
||||
ct);
|
||||
return result == true;
|
||||
var medications = await _medications.ListAsync(userId, null, ct);
|
||||
var (startUtc, endUtc) = GetBeijingDateUtcWindow(date);
|
||||
var logs = await _medications.ListLogsInWindowAsync(userId, startUtc, endUtc, ct);
|
||||
var today = BeijingToday();
|
||||
var now = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
|
||||
return medications.Select(medication =>
|
||||
{
|
||||
var phase = ResolvePhase(medication, date);
|
||||
var scheduled = phase == "active" && GetDosesForToday(medication, date);
|
||||
var doses = scheduled
|
||||
? medication.TimeOfDay.OrderBy(time => time).Select(time =>
|
||||
{
|
||||
var log = logs.FirstOrDefault(item =>
|
||||
item.MedicationId == medication.Id && item.ScheduledTime == time);
|
||||
var status = log != null
|
||||
? log.Status.ToString().ToLowerInvariant()
|
||||
: date < today
|
||||
? "missed"
|
||||
: date > today
|
||||
? "scheduled"
|
||||
: MedicationScheduleStatus.Resolve(time, now, null);
|
||||
return new AiMedicationDoseDto(time.ToString("HH:mm"), status);
|
||||
}).ToList()
|
||||
: [];
|
||||
|
||||
return new AiMedicationPlanDto(
|
||||
medication.Id,
|
||||
medication.Name,
|
||||
medication.Dosage,
|
||||
medication.Frequency.ToString(),
|
||||
medication.StartDate,
|
||||
medication.EndDate,
|
||||
phase,
|
||||
scheduled,
|
||||
doses);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct)
|
||||
@@ -217,9 +237,28 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
|
||||
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
|
||||
{
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
return (todayStartUtc, todayStartUtc.AddDays(1));
|
||||
return GetBeijingDateUtcWindow(BeijingToday());
|
||||
}
|
||||
|
||||
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingDateUtcWindow(DateOnly date)
|
||||
{
|
||||
var startUtc = DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue).AddHours(-8), DateTimeKind.Utc);
|
||||
return (startUtc, startUtc.AddDays(1));
|
||||
}
|
||||
|
||||
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
|
||||
private static bool IsActiveOn(Medication medication, DateOnly date) =>
|
||||
medication.IsActive &&
|
||||
(!medication.StartDate.HasValue || medication.StartDate.Value <= date) &&
|
||||
(!medication.EndDate.HasValue || medication.EndDate.Value >= date);
|
||||
|
||||
private static string ResolvePhase(Medication medication, DateOnly date)
|
||||
{
|
||||
if (!medication.IsActive) return "inactive";
|
||||
if (medication.StartDate.HasValue && medication.StartDate.Value > date) return "upcoming";
|
||||
if (medication.EndDate.HasValue && medication.EndDate.Value < date) return "ended";
|
||||
return "active";
|
||||
}
|
||||
|
||||
private static bool GetDosesForToday(Medication med, DateOnly today)
|
||||
|
||||
@@ -94,7 +94,7 @@ public sealed class ReportService(
|
||||
public static ReportDto ToDto(Report report) => new(
|
||||
report.Id,
|
||||
report.UserId,
|
||||
report.FileUrl,
|
||||
$"/api/reports/{report.Id}/file",
|
||||
report.FileType.ToString(),
|
||||
report.Category.ToString(),
|
||||
report.Status.ToString(),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace Health.Application.Speech;
|
||||
|
||||
public interface IRealtimeSpeechRecognitionProxy
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
|
||||
Task ProxyAsync(WebSocket clientSocket, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace Health.Application.Users;
|
||||
|
||||
public sealed record UserProfileDto(
|
||||
Guid Id,
|
||||
string Phone,
|
||||
string? Phone, // Apple 用户无手机号
|
||||
string Role,
|
||||
string? Name,
|
||||
string? Gender,
|
||||
@@ -14,7 +14,8 @@ public sealed record UserProfileDto(
|
||||
public sealed record UserProfileUpdateRequest(
|
||||
string? Name,
|
||||
string? Gender,
|
||||
DateOnly? BirthDate);
|
||||
DateOnly? BirthDate,
|
||||
string? AvatarUrl);
|
||||
|
||||
public sealed record AccountFileReferences(
|
||||
IReadOnlyList<string> FileUrls,
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class UserService(
|
||||
if (request.Name != null) user.Name = request.Name;
|
||||
if (request.Gender != null) user.Gender = request.Gender;
|
||||
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
|
||||
if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _users.SaveChangesAsync(ct);
|
||||
return true;
|
||||
|
||||
@@ -6,7 +6,8 @@ namespace Health.Domain.Entities;
|
||||
public sealed class User
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
public string? Phone { get; set; } // Apple 用户无手机号
|
||||
public string? AppleUserId { get; set; } // Apple Sign-In 唯一标识
|
||||
public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
|
||||
public Guid? DoctorId { get; set; } // 患者选择的医生
|
||||
public string? Name { get; set; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -8,12 +8,47 @@ namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
/// </summary>
|
||||
public static class CommonAgentHandler
|
||||
{
|
||||
public static readonly ToolDefinition SearchMedicalKnowledgeTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "search_medical_knowledge",
|
||||
Description = "按需检索医学知识库。仅用于症状分析、健康建议、疾病/药品/检查指标解释等需要医学资料支撑的回答;健康数据录入、用药或运动计划创建/查询/打卡、普通寒暄不得调用。",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
query = new
|
||||
{
|
||||
type = "string",
|
||||
description = "需要检索的医学问题,保留关键症状、疾病、药品或指标名称",
|
||||
},
|
||||
},
|
||||
required = new[] { "query" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly ToolDefinition QueryHealthRecordsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_health_records", Description = "查询近期健康数据",
|
||||
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } }
|
||||
Name = "query_health_records",
|
||||
Description = "查询近期健康数据",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight", "all" }, description = "健康指标类型" },
|
||||
scope = new { type = "string", @enum = new[] { "today", "yesterday", "date", "recent_days", "range" }, description = "日期范围,默认 recent_days" },
|
||||
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
|
||||
start_date = new { type = "string", description = "scope=range 时的北京时间开始日期 yyyy-MM-dd" },
|
||||
end_date = new { type = "string", description = "scope=range 时的北京时间结束日期 yyyy-MM-dd(包含)" },
|
||||
days = new { type = "integer", description = "scope=recent_days 时的天数,1到365" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,10 +77,67 @@ public static class CommonAgentHandler
|
||||
|
||||
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
||||
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
|
||||
return new { count = records.Count, records };
|
||||
var rawType = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
if (!TryNormalizeMetricType(rawType, out var metricType))
|
||||
return new { success = false, message = $"无法识别健康指标类型: {rawType}" };
|
||||
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant()
|
||||
: "recent_days";
|
||||
DateTime? startUtc = null;
|
||||
DateTime? endUtc = null;
|
||||
int days;
|
||||
|
||||
switch (scope)
|
||||
{
|
||||
case "today":
|
||||
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
|
||||
days = 2;
|
||||
break;
|
||||
case "yesterday":
|
||||
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday.AddDays(-1));
|
||||
days = 3;
|
||||
break;
|
||||
case "date":
|
||||
if (!TryReadDate(args, "date", out var date))
|
||||
return new { success = false, message = "查询指定日期时必须提供有效的 date" };
|
||||
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
|
||||
days = DaysSince(startUtc.Value);
|
||||
break;
|
||||
case "range":
|
||||
if (!TryReadDate(args, "start_date", out var startDate) ||
|
||||
!TryReadDate(args, "end_date", out var endDate) || endDate < startDate)
|
||||
return new { success = false, message = "必须提供有效的 start_date 和 end_date,且结束日期不能早于开始日期" };
|
||||
startUtc = AiDateTime.BeijingDateWindowUtc(startDate).StartUtc;
|
||||
endUtc = AiDateTime.BeijingDateWindowUtc(endDate).EndUtc;
|
||||
days = DaysSince(startUtc.Value);
|
||||
break;
|
||||
default:
|
||||
days = args.TryGetProperty("days", out var d) && d.TryGetInt32(out var parsedDays)
|
||||
? Math.Clamp(parsedDays, 1, 365)
|
||||
: 7;
|
||||
scope = "recent_days";
|
||||
break;
|
||||
}
|
||||
|
||||
var records = await healthRecords.GetRecordsAsync(userId, metricType, days, ct);
|
||||
var filtered = records
|
||||
.Where(record => !startUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) >= startUtc.Value)
|
||||
.Where(record => !endUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) < endUtc.Value)
|
||||
.Select(record => new
|
||||
{
|
||||
record.Id,
|
||||
record.Type,
|
||||
record.Systolic,
|
||||
record.Diastolic,
|
||||
record.Value,
|
||||
record.Unit,
|
||||
record.Source,
|
||||
record.IsAbnormal,
|
||||
recorded_at_beijing = AiDateTime.ToBeijingText(record.RecordedAt),
|
||||
})
|
||||
.ToList();
|
||||
return new { success = true, scope, count = filtered.Count, records = filtered };
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
|
||||
@@ -54,55 +146,39 @@ public static class CommonAgentHandler
|
||||
if (archive == null) return new { found = false };
|
||||
return new
|
||||
{
|
||||
found = true, archive.Diagnosis, archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries,
|
||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
||||
found = true,
|
||||
archive.Diagnosis,
|
||||
archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||
archive.Surgeries,
|
||||
archive.Allergies,
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
};
|
||||
}
|
||||
|
||||
public static readonly ToolDefinition ManageArchiveTool = new()
|
||||
private static bool TryNormalizeMetricType(string? value, out string? normalized)
|
||||
{
|
||||
Function = new()
|
||||
normalized = value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
Name = "manage_archive", Description = "管理用户健康档案",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" },
|
||||
diagnosis = new { type = "string" },
|
||||
surgery_type = new { type = "string" },
|
||||
surgery_date = new { type = "string" },
|
||||
allergies = new { type = "array", items = new { type = "string" } },
|
||||
chronic_diseases = new { type = "array", items = new { type = "string" } },
|
||||
diet_restrictions = new { type = "array", items = new { type = "string" } },
|
||||
},
|
||||
required = new[] { "action" }
|
||||
}
|
||||
}
|
||||
null or "" or "all" or "全部" => null,
|
||||
"bloodpressure" or "blood_pressure" or "血压" => "BloodPressure",
|
||||
"heartrate" or "heart_rate" or "心率" or "脉搏" => "HeartRate",
|
||||
"glucose" or "blood_glucose" or "血糖" => "Glucose",
|
||||
"spo2" or "blood_oxygen" or "血氧" or "血氧饱和度" => "SpO2",
|
||||
"weight" or "体重" => "Weight",
|
||||
_ => "__invalid__",
|
||||
};
|
||||
|
||||
public static Task<object> ExecuteManageArchive(
|
||||
IHealthArchiveService archives,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
var request = new HealthArchiveUpdateRequest(
|
||||
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
|
||||
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
|
||||
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
|
||||
ReadStringArray(args, "allergies"),
|
||||
ReadStringArray(args, "diet_restrictions"),
|
||||
ReadStringArray(args, "chronic_diseases"),
|
||||
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
|
||||
return archives.ExecuteAiActionAsync(userId, action, request, ct);
|
||||
return normalized != "__invalid__";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
|
||||
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
|
||||
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
|
||||
: null;
|
||||
private static bool TryReadDate(JsonElement args, string name, out DateOnly date)
|
||||
{
|
||||
date = default;
|
||||
return args.TryGetProperty(name, out var value) && DateOnly.TryParse(value.GetString(), out date);
|
||||
}
|
||||
|
||||
private static int DaysSince(DateTime startUtc) =>
|
||||
Math.Clamp((int)Math.Ceiling((DateTime.UtcNow - startUtc).TotalDays) + 1, 1, 3650);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ public static class ExerciseAgentHandler
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "create/query/checkin" },
|
||||
action = new { type = "string", @enum = new[] { "create", "query", "checkin" }, description = "create/query/checkin" },
|
||||
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "all_plans" }, description = "查询时必填:今日任务/指定日期任务/当前计划/即将开始/已结束/全部计划" },
|
||||
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
|
||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd,默认今天" },
|
||||
duration_days = new { type = "integer", description = "连续运动天数,如一周为7天" },
|
||||
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
|
||||
@@ -30,20 +32,50 @@ public static class ExerciseAgentHandler
|
||||
|
||||
public static List<ToolDefinition> Tools => [ManageExerciseTool];
|
||||
|
||||
public static string? ValidateWriteArguments(JsonElement args)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var actionValue)
|
||||
? actionValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (action != "create") return null;
|
||||
|
||||
if (!args.TryGetProperty("exercise_type", out var typeValue) ||
|
||||
string.IsNullOrWhiteSpace(typeValue.GetString()))
|
||||
return "创建运动计划前需要确认运动项目";
|
||||
if (!args.TryGetProperty("duration_minutes", out var minutesValue) ||
|
||||
!minutesValue.TryGetInt32(out var minutes) || minutes is <= 0 or > 300)
|
||||
return "创建运动计划前需要确认合理的单次运动时长";
|
||||
if (!args.TryGetProperty("duration_days", out var daysValue) ||
|
||||
!daysValue.TryGetInt32(out var days) || days is <= 0 or > 366)
|
||||
return "创建运动计划前需要确认计划持续天数";
|
||||
if (!args.TryGetProperty("start_date", out var startValue) ||
|
||||
!DateOnly.TryParse(startValue.GetString(), out _))
|
||||
return "创建运动计划前需要确认开始日期";
|
||||
if (!args.TryGetProperty("reminder_time", out var reminderValue) ||
|
||||
!TimeOnly.TryParse(reminderValue.GetString(), out _))
|
||||
return "创建运动计划前需要确认具体提醒时间";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
AppDbContext db,
|
||||
Guid userId,
|
||||
IExerciseService? exercises = null,
|
||||
IExerciseService exercises,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (toolName != "manage_exercise" || exercises == null)
|
||||
if (toolName != "manage_exercise")
|
||||
return new { success = false, message = $"未知工具: {toolName}" };
|
||||
|
||||
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
|
||||
var action = args.TryGetProperty("action", out var actionValue)
|
||||
? actionValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (action == "create")
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
|
||||
? parsedStart
|
||||
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
@@ -67,17 +99,111 @@ public static class ExerciseAgentHandler
|
||||
return success ? new { success = true } : new { success = false, message = "条目不存在" };
|
||||
}
|
||||
|
||||
var plan = await exercises.GetLatestAsync(userId, ct);
|
||||
return plan == null
|
||||
? new { found = false }
|
||||
: new
|
||||
if (action != "query")
|
||||
return new { success = false, message = $"未知操作: {action}" };
|
||||
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "all_plans"))
|
||||
return new
|
||||
{
|
||||
success = false,
|
||||
message = "查询运动计划时必须明确 scope:today、scheduled_date、current_plans、upcoming_plans、ended_plans 或 all_plans",
|
||||
};
|
||||
if (scope == "scheduled_date" &&
|
||||
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
|
||||
return new { success = false, message = "查询指定日期的运动安排时必须提供有效的 date" };
|
||||
var queryDate = scope == "scheduled_date"
|
||||
? DateOnly.Parse(args.GetProperty("date").GetString()!)
|
||||
: AiDateTime.BeijingToday;
|
||||
var isDateScope = scope is "today" or "scheduled_date";
|
||||
var plans = await exercises.ListAllAsync(userId, ct);
|
||||
var overview = plans.Select(plan => new
|
||||
{
|
||||
found = true,
|
||||
plan_id = plan.Id,
|
||||
plan.StartDate,
|
||||
plan.EndDate,
|
||||
phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active",
|
||||
plan.ReminderTime,
|
||||
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
|
||||
total_items = plan.Items.Count,
|
||||
completed_items = plan.Items.Count(item => item.IsCompleted),
|
||||
returned_items = isDateScope
|
||||
? plan.Items.Count(item => item.ScheduledDate == queryDate)
|
||||
: Math.Min(plan.Items.Count, 14),
|
||||
items_truncated = !isDateScope && plan.Items.Count > 14,
|
||||
items = plan.Items
|
||||
.Where(item => !isDateScope || item.ScheduledDate == queryDate)
|
||||
.OrderBy(item => item.ScheduledDate)
|
||||
.Take(isDateScope ? int.MaxValue : 14)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.ScheduledDate,
|
||||
item.ExerciseType,
|
||||
item.DurationMinutes,
|
||||
item.IsCompleted,
|
||||
item.IsRestDay,
|
||||
completed_at_beijing = item.CompletedAt.HasValue ? AiDateTime.ToBeijingText(item.CompletedAt.Value) : null,
|
||||
})
|
||||
.ToList(),
|
||||
});
|
||||
var filtered = scope switch
|
||||
{
|
||||
"all_plans" => overview,
|
||||
"current_plans" => overview.Where(plan => plan.phase == "active"),
|
||||
"upcoming_plans" => overview
|
||||
.Where(plan => plan.phase == "upcoming")
|
||||
.OrderBy(plan => plan.StartDate),
|
||||
"ended_plans" => overview
|
||||
.Where(plan => plan.phase == "ended")
|
||||
.OrderByDescending(plan => plan.EndDate),
|
||||
_ => overview.Where(plan => plan.phase == "active" && plan.items.Count > 0),
|
||||
};
|
||||
var result = filtered.ToList();
|
||||
var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList();
|
||||
var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault();
|
||||
string authoritativeAnswer;
|
||||
if (result.Count == 0)
|
||||
{
|
||||
authoritativeAnswer = isDateScope
|
||||
? $"没有查到 {queryDate:yyyy-MM-dd} 的运动安排。"
|
||||
: "当前没有查到符合条件的运动计划。";
|
||||
}
|
||||
else if (isDateScope)
|
||||
{
|
||||
var taskLines = scheduledTasks.Select(item =>
|
||||
$"- {item.ExerciseType}:{item.DurationMinutes} 分钟,{(item.IsCompleted ? "已完成" : "未完成")}");
|
||||
authoritativeAnswer =
|
||||
$"{queryDate:yyyy-MM-dd} 的运动安排:\n{string.Join('\n', taskLines)}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var planLines = result.Select(plan =>
|
||||
{
|
||||
var representative = plan.items.FirstOrDefault(item => !item.IsRestDay);
|
||||
var exerciseType = representative?.ExerciseType ?? "运动";
|
||||
var durationMinutes = representative?.DurationMinutes ?? 0;
|
||||
return $"- {exerciseType}:{plan.StartDate:yyyy-MM-dd} 至 {plan.EndDate:yyyy-MM-dd},"
|
||||
+ $"每天 {durationMinutes} 分钟,提醒时间 {plan.ReminderTime:HH:mm}";
|
||||
});
|
||||
authoritativeAnswer =
|
||||
$"查到 {result.Count} 个符合条件的运动计划:\n{string.Join('\n', planLines)}";
|
||||
}
|
||||
return new
|
||||
{
|
||||
found = result.Count > 0,
|
||||
query_date = queryDate.ToString("yyyy-MM-dd"),
|
||||
scope,
|
||||
count = result.Count,
|
||||
all_tasks_completed = isDateScope
|
||||
? scheduledTasks.Count > 0 && scheduledTasks.All(item => item.IsCompleted)
|
||||
: (bool?)null,
|
||||
has_upcoming_plans = nextPlan != null,
|
||||
next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"),
|
||||
days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber,
|
||||
plans = result,
|
||||
authoritative_answer = authoritativeAnswer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,35 +7,162 @@ namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
/// </summary>
|
||||
public static class HealthDataAgentHandler
|
||||
{
|
||||
private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"];
|
||||
|
||||
public static readonly ToolDefinition RecordHealthDataTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
|
||||
Parameters = new { type = "object", properties = new { type = new { type = "string" }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" } }, required = new[] { "type" } }
|
||||
Name = "record_health_data",
|
||||
Description = "记录健康数据(血压/心率/血糖/血氧/体重)。用户说“脉搏”时按“心率”处理,type 使用 heart_rate,数值写入 heart_rate。",
|
||||
Parameters = new { type = "object", properties = new { type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight" } }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" }, recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" } }, required = new[] { "type" } }
|
||||
}
|
||||
};
|
||||
|
||||
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||
public static readonly ToolDefinition RecordHealthDataBatchTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "record_health_data_batch",
|
||||
Description = "提交当前这一轮要录入的全部健康指标。一次只调用一次;即使只有一个指标也放入 metrics。血压可先只提供已知的一侧,脉搏按心率 heart_rate 处理。后端会整批校验、追问或生成一张确认卡。",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
metrics = new
|
||||
{
|
||||
type = "array",
|
||||
minItems = 1,
|
||||
description = "当前录入批次的全部指标,不得遗漏;不要带入普通历史对话或上一张已生成卡片的数据",
|
||||
items = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
type = new { type = "string", @enum = MetricTypes },
|
||||
systolic = new { type = "integer" },
|
||||
diastolic = new { type = "integer" },
|
||||
heart_rate = new { type = "number" },
|
||||
glucose = new { type = "number" },
|
||||
spo2 = new { type = "number" },
|
||||
weight = new { type = "number" },
|
||||
recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" },
|
||||
},
|
||||
required = new[] { "type" },
|
||||
},
|
||||
},
|
||||
},
|
||||
required = new[] { "metrics" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
|
||||
public static List<ToolDefinition> Tools => [RecordHealthDataBatchTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||
|
||||
public static string? ValidateWriteArguments(JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
|
||||
return type switch
|
||||
{
|
||||
"blood_pressure" => ValidateBloodPressure(args),
|
||||
"heart_rate" => ValidateValue(args, "heart_rate", "心率", 1m, 250m),
|
||||
"glucose" => ValidateValue(args, "glucose", "血糖", 0.1m, 50m),
|
||||
"spo2" => ValidateValue(args, "spo2", "血氧", 1m, 100m),
|
||||
"weight" => ValidateValue(args, "weight", "体重", 1m, 500m),
|
||||
_ => "缺少有效的健康指标类型",
|
||||
};
|
||||
}
|
||||
|
||||
public static string? GetUrgentWarning(JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
|
||||
return type switch
|
||||
{
|
||||
"blood_pressure"
|
||||
when args.TryGetProperty("systolic", out var systolic) &&
|
||||
systolic.TryGetInt32(out var systolicValue) &&
|
||||
args.TryGetProperty("diastolic", out var diastolic) &&
|
||||
diastolic.TryGetInt32(out var diastolicValue) &&
|
||||
(systolicValue >= 180 || diastolicValue >= 120)
|
||||
=> "这次血压数值已达到明显危险范围,请不要继续等待录入,尽快联系医生或前往急诊评估。",
|
||||
"heart_rate" when IsValueOutside(args, "heart_rate", 45m, 130m)
|
||||
=> "这次心率数值明显异常,请尽快联系医生评估;如伴有胸痛、晕厥或呼吸困难,请立即就医。",
|
||||
"glucose" when IsValueOutside(args, "glucose", 3.0m, 16.7m)
|
||||
=> "这次血糖数值属于需要尽快处理的危险范围,请立即联系医生或前往医疗机构评估。",
|
||||
"spo2"
|
||||
when args.TryGetProperty("spo2", out var spo2) &&
|
||||
spo2.TryGetDecimal(out var spo2Value) &&
|
||||
spo2Value <= 90m
|
||||
=> "这次血氧数值明显偏低,请尽快就医评估;如有呼吸困难、胸闷或意识异常,请立即拨打当地急救电话。",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static IReadOnlyList<JsonElement> GetBatchMetrics(JsonElement args) =>
|
||||
args.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Array
|
||||
? metrics.EnumerateArray().Select(metric => metric.Clone()).ToList()
|
||||
: [];
|
||||
|
||||
public static Dictionary<string, object?> CreatePreview(JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() ?? "" : "";
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
|
||||
if (metricType == null)
|
||||
return [];
|
||||
|
||||
var request = new HealthRecordUpsertRequest(
|
||||
metricType.Value,
|
||||
systolic,
|
||||
diastolic,
|
||||
value,
|
||||
unit,
|
||||
HealthRecordSource.AiEntry,
|
||||
null);
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = metricType.Value.ToString(),
|
||||
["value"] = metricType == HealthMetricType.BloodPressure
|
||||
? $"{systolic}/{diastolic}"
|
||||
: value?.ToString() ?? "",
|
||||
["unit"] = unit,
|
||||
["abnormal"] = HealthRecordRules.CheckAbnormal(request),
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
Guid userId,
|
||||
IHealthRecordService healthRecords,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args),
|
||||
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args, ct),
|
||||
"record_health_data_batch" => await ExecuteRecordHealthDataBatch(healthRecords, userId, args, ct),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
|
||||
private static async Task<object> ExecuteRecordHealthData(
|
||||
IHealthRecordService healthRecords,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
|
||||
if (metricType == null)
|
||||
return new { success = false, message = $"未知指标类型: {type}" };
|
||||
|
||||
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt)
|
||||
? dt
|
||||
var recordedAt = args.TryGetProperty("recorded_at", out var ra) &&
|
||||
AiDateTime.TryParseUserDateTime(ra.GetString(), out var parsedRecordedAt)
|
||||
? parsedRecordedAt
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var request = new HealthRecordUpsertRequest(
|
||||
@@ -47,7 +174,7 @@ public static class HealthDataAgentHandler
|
||||
HealthRecordSource.AiEntry,
|
||||
recordedAt);
|
||||
|
||||
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
|
||||
var id = await healthRecords.CreateAsync(userId, request, ct);
|
||||
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
|
||||
return new
|
||||
{
|
||||
@@ -60,6 +187,75 @@ public static class HealthDataAgentHandler
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteRecordHealthDataBatch(
|
||||
IHealthRecordService healthRecords,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var metrics = GetBatchMetrics(args);
|
||||
if (metrics.Count == 0)
|
||||
return new { success = false, message = "当前录入批次没有健康指标" };
|
||||
|
||||
var requests = new List<HealthRecordUpsertRequest>(metrics.Count);
|
||||
var previews = new List<Dictionary<string, object?>>(metrics.Count);
|
||||
foreach (var metric in metrics)
|
||||
{
|
||||
var validationError = ValidateWriteArguments(metric);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var type = metric.GetProperty("type").GetString()!;
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, metric);
|
||||
if (metricType == null)
|
||||
return new { success = false, message = $"未知指标类型: {type}" };
|
||||
|
||||
var recordedAt = metric.TryGetProperty("recorded_at", out var recordedAtValue) &&
|
||||
AiDateTime.TryParseUserDateTime(recordedAtValue.GetString(), out var parsedRecordedAt)
|
||||
? parsedRecordedAt
|
||||
: DateTime.UtcNow;
|
||||
requests.Add(new HealthRecordUpsertRequest(
|
||||
metricType.Value,
|
||||
systolic,
|
||||
diastolic,
|
||||
value,
|
||||
unit,
|
||||
HealthRecordSource.AiEntry,
|
||||
recordedAt));
|
||||
previews.Add(CreatePreview(metric));
|
||||
}
|
||||
|
||||
var ids = await healthRecords.CreateManyAsync(userId, requests, ct);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
record_ids = ids,
|
||||
items = previews,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ValidateBloodPressure(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("systolic", out var systolicValue) || !systolicValue.TryGetInt32(out var systolic) ||
|
||||
!args.TryGetProperty("diastolic", out var diastolicValue) || !diastolicValue.TryGetInt32(out var diastolic))
|
||||
return "录入血压前需要同时提供收缩压和舒张压";
|
||||
if (systolic is <= 0 or > 260 || diastolic is <= 0 or > 160 || systolic <= diastolic)
|
||||
return "血压数值明显无效,请核对收缩压和舒张压后重新提供";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateValue(JsonElement args, string property, string label, decimal minimum, decimal maximum)
|
||||
{
|
||||
if (!args.TryGetProperty(property, out var valueElement) || !valueElement.TryGetDecimal(out var value))
|
||||
return $"录入{label}前需要提供数值";
|
||||
return value < minimum || value > maximum ? $"{label}数值明显无效,请重新提供" : null;
|
||||
}
|
||||
|
||||
private static bool IsValueOutside(JsonElement args, string property, decimal minimum, decimal maximum) =>
|
||||
args.TryGetProperty(property, out var valueElement) &&
|
||||
valueElement.TryGetDecimal(out var value) &&
|
||||
(value <= minimum || value >= maximum);
|
||||
|
||||
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
|
||||
{
|
||||
return type switch
|
||||
|
||||
@@ -12,19 +12,23 @@ public static class MedicationAgentHandler
|
||||
Function = new()
|
||||
{
|
||||
Name = "manage_medication",
|
||||
Description = "用药管理(创建/查询/确认服药)",
|
||||
Description = "用药管理(创建计划、按日期查询计划与逐顿服药状态、确认某一顿服药)",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "create/query/confirm" },
|
||||
action = new { type = "string", @enum = new[] { "create", "query", "confirm" }, description = "create/query/confirm" },
|
||||
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "inactive_plans", "all_plans" }, description = "查询时必填:今日安排/指定日期安排/当前计划/即将开始/自然结束/已停用/全部计划" },
|
||||
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
|
||||
medication_id = new { type = "string", description = "药品 ID,确认服药时使用" },
|
||||
scheduled_time = new { type = "string", description = "要确认的计划服药时间 HH:mm" },
|
||||
name = new { type = "string", description = "药品名称" },
|
||||
dosage = new { type = "string", description = "剂量,如 100mg" },
|
||||
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
|
||||
frequency = new { type = "string", @enum = new[] { "Daily", "TwiceDaily", "ThreeTimesDaily", "EveryOtherDay", "Weekly", "AsNeeded" }, description = "服药频率" },
|
||||
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
|
||||
duration_days = new { type = "integer", description = "服用天数" },
|
||||
long_term = new { type = "boolean", description = "是否明确为长期服用;与 duration_days 二选一" },
|
||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
|
||||
},
|
||||
required = new[] { "action" }
|
||||
@@ -49,11 +53,11 @@ public static class MedicationAgentHandler
|
||||
|
||||
private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()?.ToLowerInvariant() : null;
|
||||
return action switch
|
||||
{
|
||||
"create" => await CreateMedication(medications, userId, args, ct),
|
||||
"query" => await QueryMedications(medications, userId, ct),
|
||||
"query" => await QueryMedications(medications, userId, args, ct),
|
||||
"confirm" => await ConfirmMedication(medications, userId, args, ct),
|
||||
_ => new { success = false, message = $"未知操作: {action}" }
|
||||
};
|
||||
@@ -61,14 +65,19 @@ public static class MedicationAgentHandler
|
||||
|
||||
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
|
||||
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
|
||||
var frequency = ReadFrequency(args);
|
||||
var times = ReadTimes(args);
|
||||
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
||||
var longTerm = args.TryGetProperty("long_term", out var longTermValue) && longTermValue.ValueKind == JsonValueKind.True;
|
||||
var startDate = ReadStartDate(args);
|
||||
// 结束日为包含式(IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
|
||||
var endDate = durationDays > 0 ? startDate.AddDays(durationDays - 1) : (DateOnly?)null;
|
||||
DateOnly? endDate = longTerm ? null : startDate.AddDays(durationDays - 1);
|
||||
|
||||
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
|
||||
name,
|
||||
@@ -91,27 +100,146 @@ public static class MedicationAgentHandler
|
||||
time = timeLabels,
|
||||
start_date = startDate.ToString("yyyy-MM-dd"),
|
||||
duration_days = durationDays,
|
||||
long_term = longTerm,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
|
||||
private static async Task<object> QueryMedications(
|
||||
IMedicationService medications,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var meds = await medications.ListActiveAsync(userId, ct);
|
||||
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) };
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "inactive_plans" or "all_plans"))
|
||||
return new
|
||||
{
|
||||
success = false,
|
||||
message = "查询用药时必须明确 scope:today、scheduled_date、current_plans、upcoming_plans、ended_plans、inactive_plans 或 all_plans",
|
||||
};
|
||||
if (scope == "scheduled_date" &&
|
||||
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
|
||||
return new { success = false, message = "查询指定日期的用药安排时必须提供有效的 date" };
|
||||
var date = scope == "scheduled_date"
|
||||
? DateOnly.Parse(args.GetProperty("date").GetString()!)
|
||||
: AiDateTime.BeijingToday;
|
||||
var isDateScope = scope is "today" or "scheduled_date";
|
||||
var overview = await medications.GetAiOverviewAsync(userId, date, ct);
|
||||
var plans = scope switch
|
||||
{
|
||||
"all_plans" => overview.ToList(),
|
||||
"current_plans" => overview.Where(plan => plan.Phase == "active").ToList(),
|
||||
"upcoming_plans" => overview
|
||||
.Where(plan => plan.Phase == "upcoming")
|
||||
.OrderBy(plan => plan.StartDate)
|
||||
.ToList(),
|
||||
"ended_plans" => overview
|
||||
.Where(plan => plan.Phase == "ended")
|
||||
.OrderByDescending(plan => plan.EndDate)
|
||||
.ToList(),
|
||||
"inactive_plans" => overview
|
||||
.Where(plan => plan.Phase == "inactive")
|
||||
.OrderByDescending(plan => plan.EndDate)
|
||||
.ToList(),
|
||||
_ => overview.Where(plan => plan.ScheduledOnDate).ToList(),
|
||||
};
|
||||
var doseStatuses = plans.SelectMany(plan => plan.Doses).ToList();
|
||||
var nextPlan = overview
|
||||
.Where(plan => plan.Phase == "upcoming" && plan.StartDate.HasValue)
|
||||
.OrderBy(plan => plan.StartDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new
|
||||
{
|
||||
query_date = date.ToString("yyyy-MM-dd"),
|
||||
scope,
|
||||
count = plans.Count,
|
||||
all_doses_completed = isDateScope
|
||||
? doseStatuses.Count > 0 && doseStatuses.All(dose => dose.Status == "taken")
|
||||
: (bool?)null,
|
||||
has_upcoming_plans = nextPlan != null,
|
||||
next_start_date = nextPlan?.StartDate?.ToString("yyyy-MM-dd"),
|
||||
days_until_next_start = nextPlan?.StartDate is DateOnly nextStart
|
||||
? nextStart.DayNumber - date.DayNumber
|
||||
: (int?)null,
|
||||
plans,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
||||
var success = await medications.ConfirmNowAsync(userId, medId, ct);
|
||||
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var medId = args.TryGetProperty("medication_id", out var mid) && mid.TryGetGuid(out var parsedMedicationId)
|
||||
? parsedMedicationId
|
||||
: Guid.Empty;
|
||||
if (!args.TryGetProperty("scheduled_time", out var timeValue) ||
|
||||
!TimeOnly.TryParse(timeValue.GetString(), out var scheduledTime))
|
||||
return new { success = false, message = "缺少要确认的计划服药时间" };
|
||||
|
||||
var success = await medications.ConfirmDoseAsync(
|
||||
userId,
|
||||
medId,
|
||||
scheduledTime,
|
||||
MedicationLogStatus.Taken,
|
||||
ct);
|
||||
return success == true
|
||||
? new { success = true, medication_id = medId, scheduled_time = scheduledTime.ToString("HH:mm") }
|
||||
: new { success = false, message = success == null ? "药品不存在" : "该时间不属于今天的服药安排,或这一顿已经记录" };
|
||||
}
|
||||
|
||||
public static string? ValidateWriteArguments(JsonElement args)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var actionValue)
|
||||
? actionValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (action == "confirm")
|
||||
{
|
||||
if (!args.TryGetProperty("medication_id", out var medicationId) || !medicationId.TryGetGuid(out _))
|
||||
return "确认服药时缺少有效的药品 ID";
|
||||
if (!args.TryGetProperty("scheduled_time", out var scheduledTime) || !TimeOnly.TryParse(scheduledTime.GetString(), out _))
|
||||
return "确认服药时缺少具体的计划服药时间";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action != "create") return null;
|
||||
if (!args.TryGetProperty("name", out var name) || string.IsNullOrWhiteSpace(name.GetString()))
|
||||
return "创建用药计划前需要确认药品名称";
|
||||
if (!args.TryGetProperty("dosage", out var dosage) || string.IsNullOrWhiteSpace(dosage.GetString()))
|
||||
return "创建用药计划前需要确认每次剂量";
|
||||
if (!args.TryGetProperty("frequency", out var frequencyValue) ||
|
||||
!Enum.TryParse<MedicationFrequency>(frequencyValue.GetString(), ignoreCase: true, out _))
|
||||
return "创建用药计划前需要确认服药频率";
|
||||
if (!args.TryGetProperty("time_of_day", out var times) || times.ValueKind != JsonValueKind.Array)
|
||||
return "创建用药计划前需要确认具体服药时间";
|
||||
var timeValues = times.EnumerateArray().ToList();
|
||||
if (timeValues.Count == 0 || timeValues.Any(value => !TimeOnly.TryParse(value.GetString(), out _)))
|
||||
return "创建用药计划前需要提供有效的具体服药时间";
|
||||
if (!args.TryGetProperty("start_date", out var startDate) || !DateOnly.TryParse(startDate.GetString(), out _))
|
||||
return "创建用药计划前需要确认开始日期";
|
||||
|
||||
var durationDays = 0;
|
||||
var hasDuration = args.TryGetProperty("duration_days", out var duration) &&
|
||||
duration.TryGetInt32(out durationDays) && durationDays > 0;
|
||||
var isLongTerm = args.TryGetProperty("long_term", out var longTerm) && longTerm.ValueKind == JsonValueKind.True;
|
||||
if (hasDuration && durationDays > 3650)
|
||||
return "服用天数不能超过3650天;长期服用请明确设置 long_term=true";
|
||||
if (hasDuration && isLongTerm)
|
||||
return "服用天数和长期服用不能同时设置";
|
||||
if (!hasDuration && !isLongTerm)
|
||||
return "创建用药计划前需要确认服用天数,或明确说明长期服用";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private static MedicationFrequency ReadFrequency(JsonElement args)
|
||||
{
|
||||
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
|
||||
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
|
||||
return Enum.TryParse<MedicationFrequency>(frequencyStr, ignoreCase: true, out var frequency) ? frequency : MedicationFrequency.Daily;
|
||||
}
|
||||
|
||||
private static DateOnly ReadStartDate(JsonElement args)
|
||||
|
||||
@@ -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 = "查询复查安排时必须明确 scope:next、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] + "…";
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.AI;
|
||||
using Health.Application.Exercises;
|
||||
using Health.Application.Diets;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
using Health.Application.Medications;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Application.Reports;
|
||||
using Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
@@ -15,6 +18,9 @@ public sealed class AiToolExecutionService(
|
||||
IHealthRecordService healthRecords,
|
||||
IExerciseService exercises,
|
||||
IMedicationService medications,
|
||||
IDietService diets,
|
||||
IReportService reports,
|
||||
IInAppNotificationService notifications,
|
||||
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
@@ -23,6 +29,9 @@ public sealed class AiToolExecutionService(
|
||||
private readonly IHealthRecordService _healthRecords = healthRecords;
|
||||
private readonly IExerciseService _exercises = exercises;
|
||||
private readonly IMedicationService _medications = medications;
|
||||
private readonly IDietService _diets = diets;
|
||||
private readonly IReportService _reports = reports;
|
||||
private readonly IInAppNotificationService _notifications = notifications;
|
||||
private readonly IAiWriteConfirmationStore _confirmations = confirmations;
|
||||
|
||||
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
|
||||
@@ -31,13 +40,17 @@ public sealed class AiToolExecutionService(
|
||||
var root = json.RootElement;
|
||||
return await (toolName switch
|
||||
{
|
||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
|
||||
"record_health_data" or "record_health_data_batch"
|
||||
=> HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords, ct),
|
||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
|
||||
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
|
||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
|
||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
|
||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, userId, _exercises, ct),
|
||||
"query_diet_records" => PatientReadAgentHandler.QueryDietAsync(_diets, userId, root, ct),
|
||||
"query_followups" => PatientReadAgentHandler.QueryFollowUpsAsync(_db, userId, root, ct),
|
||||
"query_reports" => PatientReadAgentHandler.QueryReportsAsync(_reports, userId, root, ct),
|
||||
"query_notifications" => PatientReadAgentHandler.QueryNotificationsAsync(_notifications, userId, root, ct),
|
||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.AI;
|
||||
using Health.Infrastructure.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
@@ -19,19 +20,19 @@ public sealed class AttachmentContextBuilder(
|
||||
private readonly VisionClient _vision = vision;
|
||||
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
|
||||
|
||||
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct)
|
||||
public async Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(imageUrl))
|
||||
return await BuildImageAsync(imageUrl!, ct);
|
||||
return await BuildImageAsync(userId, imageUrl!, ct);
|
||||
if (!string.IsNullOrWhiteSpace(pdfUrl))
|
||||
return await BuildPdfAsync(pdfUrl!, ct);
|
||||
return await BuildPdfAsync(userId, pdfUrl!, ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 图片:调 VLM 输出结构化 JSON ──
|
||||
private async Task<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct)
|
||||
private async Task<AttachmentContext?> BuildImageAsync(Guid userId, string imageUrl, CancellationToken ct)
|
||||
{
|
||||
var filePath = ResolveLocalPath(imageUrl);
|
||||
var filePath = UserUploadPathResolver.Resolve(userId, imageUrl);
|
||||
if (filePath == null || !File.Exists(filePath))
|
||||
{
|
||||
_logger.LogWarning("Image file not found for {Url}", imageUrl);
|
||||
@@ -105,9 +106,9 @@ public sealed class AttachmentContextBuilder(
|
||||
}
|
||||
|
||||
// ── PDF:PdfPig 抽取文本 ──
|
||||
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct)
|
||||
private Task<AttachmentContext?> BuildPdfAsync(Guid userId, string pdfUrl, CancellationToken ct)
|
||||
{
|
||||
var filePath = ResolveLocalPath(pdfUrl);
|
||||
var filePath = UserUploadPathResolver.Resolve(userId, pdfUrl);
|
||||
var fileName = Path.GetFileName(pdfUrl);
|
||||
if (filePath == null || !File.Exists(filePath))
|
||||
{
|
||||
@@ -150,18 +151,6 @@ public sealed class AttachmentContextBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveLocalPath(string url)
|
||||
{
|
||||
// url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。
|
||||
var idx = url.IndexOf("/uploads/", StringComparison.Ordinal);
|
||||
if (idx < 0) return null;
|
||||
var relative = url[(idx + "/uploads/".Length)..];
|
||||
// 去掉可能的 query string
|
||||
var q = relative.IndexOf('?');
|
||||
if (q >= 0) relative = relative[..q];
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative);
|
||||
}
|
||||
|
||||
private static string StripCodeFence(string raw)
|
||||
{
|
||||
var t = raw.Trim();
|
||||
|
||||
108
backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs
Normal file
108
backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs
Normal 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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
你是患者端统一 AI 健康助手“小脉”。你的任务是理解用户当前真正想做的事情,调用经过授权的业务工具,并用自然、温和、清楚的中文回复。
|
||||
|
||||
核心原则:
|
||||
- 个人健康数据、计划、完成状态和报告状态必须以当前业务工具返回为准,不能根据聊天历史或患者背景猜测。
|
||||
- 工具没有返回的数据不得编造;暂时没有对应能力时应如实说明。
|
||||
- 一条消息可能包含多个意图,必须分别处理,不能为了简化而遗漏其中一项。
|
||||
- 当前工具结果与历史聊天冲突时,以当前工具结果为准。
|
||||
- 普通聊天历史用于理解语境,不等同于待写入数据;只有后端明确提供的待补充草稿才允许跨消息合并录入字段。
|
||||
@@ -0,0 +1,6 @@
|
||||
医疗安全边界:
|
||||
- 你是健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以解释健康数据、报告指标和症状可能方向,但不能作确定诊断。
|
||||
- 不要求用户自行新增、停用、更换药物或调整剂量;涉及治疗变化时建议咨询医生或药师。
|
||||
- 出现剧烈胸痛、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等危险信号时,立即停止普通流程并优先建议及时就医或急诊评估。
|
||||
- 医疗分析使用“可能、建议、需要结合医生判断”等表达。
|
||||
@@ -0,0 +1,8 @@
|
||||
回复要求:
|
||||
- 语气自然、温和、专业,不使用系统报错式措辞。
|
||||
- 信息不完整时,每次只追问最关键的缺失内容;可以在一句话中列出同一批次明确缺少的字段。
|
||||
- 禁止使用粗体、斜体、删除线、HTML 和 Markdown 表格。
|
||||
- 标题只使用三级标题;列表使用短横线。
|
||||
- 数值和单位之间留空格,例如 120 mmHg、6.2 mmol/L。
|
||||
- 不输出任何以 $ 开头的占位符。
|
||||
- 不重复堆砌免责声明。
|
||||
@@ -0,0 +1,6 @@
|
||||
新饮食图片分析模块:
|
||||
- 仅用于本轮新上传的饮食图片;查询已经保存的饮食记录应进入个人数据查询模块。
|
||||
- 识别食物名称、估算份量和热量,并结合患者健康档案评价。
|
||||
- 对过敏、低盐、低脂、低糖等限制给出清楚提醒,不作绝对化医疗结论。
|
||||
- 饮食建议需要医学资料支撑时可以按需调用知识库。
|
||||
- 图片分析结果不能在用户未确认时冒充已经保存的饮食记录。
|
||||
@@ -0,0 +1,9 @@
|
||||
运动计划录入模块:
|
||||
- 用户明确创建运动计划时调用 manage_exercise(action="create")。
|
||||
- 完整计划需要:运动项目、每天运动时长、持续天数、开始日期和具体提醒时间。
|
||||
- 中文时长和周期应正确换算,例如半小时=30 分钟、一小时=60 分钟、一周=7 天、一个月=30 天。
|
||||
- 只传用户明确提供的字段;当前产品不自动补默认运动项目、天数、开始日期或提醒时间。
|
||||
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮补充时与草稿合并。
|
||||
- 用户明确开始新的运动计划时不得继承旧草稿内容。
|
||||
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
|
||||
- 单纯创建运动计划不调用医学知识库。用户先询问该运动是否适合自己时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,4 @@
|
||||
普通聊天与应用帮助模块:
|
||||
- 问候、感谢和普通闲聊自然简短回复,不调用业务工具或医学知识库。
|
||||
- 用户询问应用功能位置或使用方法时可以直接说明;已有固定 App 内链接时可自然提供一个跳转链接。
|
||||
- 不确定用户是想咨询数值还是保存记录时,只做一次简短澄清,不生成确认卡。
|
||||
@@ -0,0 +1,13 @@
|
||||
健康指标录入模块:
|
||||
- 支持血压、心率、血糖、血氧、体重;“脉搏”与“心率”是同一指标,统一使用 heart_rate。
|
||||
- 必须理解常见完整血压表达,例如 113/86、113-86、113—86,前者为收缩压,后者为舒张压。
|
||||
- 用户明确要求录入时必须调用 record_health_data_batch,而且每个录入批次只调用一次;即使只有一个指标,也必须放进 metrics 数组。信息不完整也要调用,只传用户明确提供的字段,让后端保存整个待补充批次草稿。
|
||||
- 不能自行补测量数值。测量时间未说明时可以由后端使用当前时间。
|
||||
- 一次消息可以包含多个指标,必须完整提取,不得遗漏。多指标属于同一录入批次,最终应汇总在一张确认卡中。
|
||||
- metrics 必须包含用户本轮明确要录入的全部指标;不要为每个指标分别调用工具。
|
||||
- 如果同一批次存在不完整或明显无效的指标,应保留本批次其他已识别指标,先根据后端结果追问缺失或错误部分;全部补齐后再生成整批确认卡。
|
||||
- 用户只补充一个缺失值时,必须结合后端提供的当前草稿,不能只记录最后一句。
|
||||
- 已经生成确认卡的批次立即结束;无论用户是否点击,下一条新的录入请求都属于新批次,不能带入上一张卡的数据。
|
||||
- 普通聊天历史中的指标不得自动加入当前卡;只有明确的待补充草稿可以跨消息合并。
|
||||
- 后端返回 pendingConfirmation=true 才代表确认卡真实生成。确认卡出现前不得要求用户点击卡片,点击前不得声称已经保存或录入成功。
|
||||
- 后端返回危险提醒或无效数值时,不生成成功话术,按返回内容提醒用户。
|
||||
@@ -0,0 +1,7 @@
|
||||
医疗咨询与健康建议模块:
|
||||
- 症状求助时先判断是否存在危险信号;非紧急情况下每轮只追问一个最关键问题,逐步了解部位、开始时间、程度、诱因和伴随表现。
|
||||
- 信息足够后给出可能相关方向、严重程度以及观察、门诊、尽快就医或急诊建议,不作正式诊断。
|
||||
- 解释用户个人指标或趋势时,应先调用相应个人数据查询工具获取真实数据。
|
||||
- 疾病、药品、检查指标、康复、饮食或运动建议需要医学资料支撑时,调用 search_medical_knowledge。
|
||||
- 不需要外部医学资料的简单澄清、问候或业务操作不调用知识库。
|
||||
- 用户同时要求录入或创建计划时,医疗解释不能代替对应录入工具和确认卡。
|
||||
@@ -0,0 +1,9 @@
|
||||
用药计划录入模块:
|
||||
- 用户明确创建用药计划时调用 manage_medication(action="create")。
|
||||
- 只传用户明确提供的字段,不自行新增药品、猜测剂量、频率、具体服药时间、开始日期或疗程。
|
||||
- 完整计划需要:药品名称、每次剂量、频率、具体服药时间、开始日期,以及疗程天数或明确长期服用。
|
||||
- “早饭后、晚上”等模糊时间不能擅自换算为具体时刻,应追问具体时间。
|
||||
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮只补缺失字段时与草稿合并。
|
||||
- 存在旧草稿但用户明确开始另一种药品的新计划时,应开始新批次,禁止继承旧药品的剂量、时间、频率或疗程。
|
||||
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
|
||||
- 用药计划录入本身不调用医学知识库。用户同时询问药品知识或副作用时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,7 @@
|
||||
个人数据查询模块:
|
||||
- 查询用户自己的健康记录、健康档案、用药计划、运动计划、饮食记录、复查安排、已有报告和通知时,必须调用对应只读业务工具。
|
||||
- 不能根据患者背景、聊天历史或以前的工具结果回答当前状态。
|
||||
- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“任务已经完成”。
|
||||
- 查询某一天任务与查询计划生命周期不能混用;时间意图不明确时只追问时间范围。
|
||||
- 工具没有返回数据时明确说明未查到,不得编造。
|
||||
- 个人数据查询通常不调用医学知识库;用户同时要求解释或建议时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,7 @@
|
||||
新报告分析模块:
|
||||
- 仅用于本轮新上传的医学检查报告图片或报告文件;查询以前保存的报告应进入个人数据查询模块。
|
||||
- 提取报告名称、日期、关键指标、参考范围、异常标记和原文结论。
|
||||
- 区分结构化检验指标与影像/医生描述,不把影像描述改写成确定诊断。
|
||||
- 给出患者可理解的初步解释和下一步建议,注明需要结合医生判断。
|
||||
- 报告内容需要进一步医学解释时可以按需调用知识库。
|
||||
- 用户没有明确要求时,不把报告中的指标直接写入健康记录。
|
||||
@@ -0,0 +1,7 @@
|
||||
医学知识库使用规则:
|
||||
- 知识库是医疗咨询、指标解释、药品知识、报告解释和健康建议的辅助能力,不是独立用户意图。
|
||||
- 健康指标录入、用药或运动计划创建、个人数据查询、服药确认、运动打卡、普通聊天时禁止调用。
|
||||
- 一条消息同时包含业务操作和医学咨询时,先完成必要业务工具调用;只有咨询部分确实需要资料时才检索。
|
||||
- 检索问题应保留关键症状、疾病、药品或指标名称,删除无关寒暄和操作指令。
|
||||
- 只能依据真实返回的知识片段作参考;没有结果时应谨慎回答,不得虚构检索内容或来源。
|
||||
- 知识库内容不能覆盖患者当前业务数据,也不能替代紧急安全规则。
|
||||
@@ -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 工具返回结构化结果,不得输出解释文字。
|
||||
@@ -9,7 +9,7 @@ namespace Health.Infrastructure.AI;
|
||||
public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
private readonly HttpClient _http = http;
|
||||
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat";
|
||||
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-v4-flash";
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
@@ -28,8 +28,13 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, Stream = true,
|
||||
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
Stream = true,
|
||||
MaxTokens = maxTokens,
|
||||
Temperature = temperature,
|
||||
Tools = tools,
|
||||
Thinking = new { type = "disabled" },
|
||||
};
|
||||
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||
|
||||
@@ -64,14 +69,20 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
List<ToolDefinition>? tools = null,
|
||||
int maxTokens = 2048,
|
||||
float temperature = 0.7f,
|
||||
string? toolChoice = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, Stream = false,
|
||||
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||
Model = _model,
|
||||
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 content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
@@ -117,8 +128,12 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
|
||||
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
||||
Temperature = 0.1f, VlHighResolutionImages = true,
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
MaxTokens = maxTokens,
|
||||
Stream = false,
|
||||
Temperature = 0.1f,
|
||||
VlHighResolutionImages = true,
|
||||
EnableThinking = false,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ public sealed class ChatCompletionRequest
|
||||
public float? TopP { get; set; }
|
||||
public List<ToolDefinition>? Tools { get; set; }
|
||||
public string? ToolChoice { get; set; }
|
||||
public object? Thinking { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
||||
public bool VlHighResolutionImages { get; set; }
|
||||
|
||||
185
backend/src/Health.Infrastructure/AI/ai_intent_router.cs
Normal file
185
backend/src/Health.Infrastructure/AI/ai_intent_router.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace Health.Infrastructure.AI;
|
||||
/// <summary>
|
||||
/// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。
|
||||
/// 我们后端的 DeepSeek 依旧负责对话生成与工具调用;
|
||||
/// 这里只把检索到的医学知识片段拼回 system prompt,提升回答准确度。
|
||||
/// 检索结果作为按需工具结果返回给主对话,提升回答准确度。
|
||||
/// </summary>
|
||||
public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger<FastGptKnowledgeClient> logger)
|
||||
{
|
||||
|
||||
@@ -1,199 +1,123 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
/// <summary>
|
||||
/// System Prompt 模板管理
|
||||
/// Loads small, responsibility-focused prompt resources and composes only the
|
||||
/// sections required by the current intent route.
|
||||
/// </summary>
|
||||
public sealed class PromptManager
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定 Agent 的 System Prompt
|
||||
/// </summary>
|
||||
public string GetSystemPrompt(AgentType agentType)
|
||||
{
|
||||
var prompt = agentType switch
|
||||
{
|
||||
AgentType.Default => DefaultPrompt,
|
||||
AgentType.Consultation => ConsultationPrompt,
|
||||
AgentType.Health => HealthDataPrompt,
|
||||
AgentType.Diet => DietPrompt,
|
||||
AgentType.Medication => MedicationPrompt,
|
||||
AgentType.Report => ReportPrompt,
|
||||
AgentType.Exercise => ExercisePrompt,
|
||||
AgentType.Unified => UnifiedPrompt,
|
||||
_ => DefaultPrompt
|
||||
};
|
||||
private static readonly Assembly PromptAssembly = typeof(PromptManager).Assembly;
|
||||
private static readonly IReadOnlyDictionary<string, string> PromptCache = LoadPrompts();
|
||||
|
||||
return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}";
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
private const string SmartLinkRules = """
|
||||
智能跳转链接(按需嵌入):
|
||||
- 当用户明确询问食物的 热量/卡路里 时,在回答的自然位置嵌入 Markdown 链接 [热量分析](app://diet),引导用户使用专门的饮食拍照分析功能。
|
||||
- 当用户上传医学检查报告、化验单、影像报告、出院小结等需要专业解读时,在回答末尾嵌入 [报告分析](app://report),引导上传到报告分析功能获得详细解读。
|
||||
- 当用户询问血压、血糖、血氧等需要长期追踪的设备测量场景时,可嵌入 [蓝牙设备](app://device)。
|
||||
if (normalized.Contains("medical_consultation") ||
|
||||
normalized.Contains("report_analysis") ||
|
||||
normalized.Contains("diet_analysis"))
|
||||
{
|
||||
sections.Add("rag/retrieval_rules.md");
|
||||
}
|
||||
|
||||
硬性约束:
|
||||
- 每条回复最多嵌入一个跳转链接,且必须在合适语境下自然提及,不要堆砌。
|
||||
- 仅"问热量/卡路里"才用 app://diet;"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。
|
||||
- 链接的 Markdown 文本只能用上面列出的 4 个固定文案,不要自创其他文案。
|
||||
""";
|
||||
return Compose(sections);
|
||||
}
|
||||
|
||||
private const string MedicalBoundaryRules = """
|
||||
医疗边界(必须遵守):
|
||||
- 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以解释健康数据、报告指标和症状可能方向,但不要给出确定诊断,不要说“你就是/一定是/已经确诊”。
|
||||
- 不要要求用户自行新增、停用、更换药物或调整剂量;涉及用药变化时,必须建议咨询医生或药师。
|
||||
- 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等情况,优先建议及时就医或急诊评估。
|
||||
- 回答用语使用“可能、建议、需要结合医生判断”等表达,避免绝对化结论。
|
||||
- 医疗相关分析末尾用一句自然的话提醒:以上为 AI 预分析,不能替代医生诊断和治疗建议。
|
||||
/// <summary>
|
||||
/// Compatibility entry for older clients that still address a specific agent.
|
||||
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
|
||||
/// </summary>
|
||||
public string GetSystemPrompt(AgentType agentType) => agentType switch
|
||||
{
|
||||
AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]),
|
||||
AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]),
|
||||
AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]),
|
||||
AgentType.Consultation => ComposeForIntents(["medical_consultation"]),
|
||||
AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]),
|
||||
AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]),
|
||||
AgentType.Unified => ComposeForIntents([
|
||||
"health_entry",
|
||||
"medication_entry",
|
||||
"exercise_entry",
|
||||
"personal_query",
|
||||
"medical_consultation",
|
||||
"report_analysis",
|
||||
"diet_analysis",
|
||||
"general_chat",
|
||||
]),
|
||||
_ => ComposeForIntents(["general_chat", "medical_consultation"]),
|
||||
};
|
||||
|
||||
回复格式规则(严格遵守):
|
||||
- 禁止使用 **粗体**、*斜体*、~~删除线~~ 等 Markdown 内联标记
|
||||
- 禁止使用 HTML 标签
|
||||
- 禁止使用 Markdown 表格(|--|--|)
|
||||
- 标题只使用 ### 三级标题,禁止使用 #、##、####
|
||||
- 列表使用 - 开头,每项一行
|
||||
- 数值和单位之间加空格:120 mmHg、6.2 mmol/L
|
||||
- 不要输出任何 $ 开头的占位符
|
||||
- 回复结尾不再重复免责声明,MedicalBoundaryRules 已统一处理
|
||||
""";
|
||||
private static string Compose(IEnumerable<string> sections) =>
|
||||
string.Join(
|
||||
"\n\n",
|
||||
sections
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Read)
|
||||
.Where(content => !string.IsNullOrWhiteSpace(content)));
|
||||
|
||||
private const string DefaultPrompt = """
|
||||
你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。
|
||||
private static string NormalizeIntent(string? intent) =>
|
||||
intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? "";
|
||||
|
||||
职责:
|
||||
1. 理解用户的健康需求,解析健康数据
|
||||
2. 主动查看患者近期数据,发现异常时提醒
|
||||
3. 回答健康知识问题
|
||||
4. 每次回复末尾,如有需要提醒的事项,简短温馨地提醒一句
|
||||
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;
|
||||
|
||||
private const string ConsultationPrompt = """
|
||||
你是一个患者端 AI 预问诊助手,负责帮助心脏术后患者梳理症状和就医前信息。
|
||||
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;
|
||||
}
|
||||
|
||||
规则:
|
||||
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=30,reminder_time="19:00"
|
||||
- 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字
|
||||
- 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天
|
||||
- 用户说"坚持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_data。例如“血氧98%是不是太高了”是在咨询,应解释正常范围,不生成录入确认卡片;除非用户同时明确要求记录
|
||||
- 明确要求“记录、录入、保存、记一下”时调用 record_health_data。例如“帮我记录血压116/89”必须生成待确认命令
|
||||
- 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录
|
||||
- 指标或数值缺失时只追问缺失部分,不猜测数值
|
||||
- 多个明确指标要逐项调用 record_health_data,不能遗漏
|
||||
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
||||
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||
- 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片
|
||||
- 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊
|
||||
- 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||
""";
|
||||
private static string NormalizePath(string path) =>
|
||||
path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -16,25 +16,32 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
|
||||
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空");
|
||||
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空");
|
||||
var phone = command.Phone.Trim();
|
||||
var name = command.Name.Trim();
|
||||
if (string.IsNullOrWhiteSpace(phone)) return Error(40001, "手机号不能为空");
|
||||
if (string.IsNullOrWhiteSpace(name)) return Error(40002, "姓名不能为空");
|
||||
if (await _db.Users.AnyAsync(x => x.Phone == phone, ct) ||
|
||||
await _db.Doctors.AnyAsync(x => x.Phone == phone, ct))
|
||||
return Error(40003, "该手机号已被其他账号使用");
|
||||
|
||||
var doctor = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department,
|
||||
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
Id = Guid.NewGuid(), Name = name, Title = command.Title?.Trim(), Department = command.Department?.Trim(),
|
||||
Phone = phone, ProfessionalDirection = command.ProfessionalDirection?.Trim(), IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(), Phone = phone, Role = "Doctor", Name = name,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.Doctors.Add(doctor);
|
||||
if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct))
|
||||
{
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = command.Phone, Role = "Doctor", Name = command.Name, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
_db.Users.Add(user);
|
||||
_db.DoctorProfiles.Add(new DoctorProfile
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = command.Name,
|
||||
Title = command.Title, Department = command.Department, IsActive = true,
|
||||
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = name,
|
||||
Title = doctor.Title, Department = doctor.Department, IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { doctor.Id, doctor.Name });
|
||||
}
|
||||
@@ -43,11 +50,43 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
{
|
||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null) return Error(404, "医生不存在");
|
||||
if (command.Name != null) doctor.Name = command.Name;
|
||||
if (command.Title != null) doctor.Title = command.Title;
|
||||
if (command.Department != null) doctor.Department = command.Department;
|
||||
if (command.Phone != null) doctor.Phone = command.Phone;
|
||||
if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection;
|
||||
var profile = await _db.DoctorProfiles.Include(x => x.User)
|
||||
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
|
||||
if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
|
||||
|
||||
var user = profile.User;
|
||||
if (command.Phone != null)
|
||||
{
|
||||
var phone = command.Phone.Trim();
|
||||
if (phone.Length == 0) return Error(40001, "手机号不能为空");
|
||||
if (await _db.Users.AnyAsync(x => x.Id != user.Id && x.Phone == phone, ct) ||
|
||||
await _db.Doctors.AnyAsync(x => x.Id != id && x.Phone == phone, ct))
|
||||
return Error(40003, "该手机号已被其他账号使用");
|
||||
doctor.Phone = phone;
|
||||
user.Phone = phone;
|
||||
}
|
||||
if (command.Name != null)
|
||||
{
|
||||
var name = command.Name.Trim();
|
||||
if (name.Length == 0) return Error(40002, "姓名不能为空");
|
||||
doctor.Name = name;
|
||||
user.Name = name;
|
||||
profile.Name = name;
|
||||
}
|
||||
if (command.Title != null)
|
||||
{
|
||||
doctor.Title = command.Title.Trim();
|
||||
profile.Title = doctor.Title;
|
||||
}
|
||||
if (command.Department != null)
|
||||
{
|
||||
doctor.Department = command.Department.Trim();
|
||||
profile.Department = doctor.Department;
|
||||
}
|
||||
if (command.ProfessionalDirection != null)
|
||||
doctor.ProfessionalDirection = command.ProfessionalDirection.Trim();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { doctor.Id });
|
||||
}
|
||||
@@ -56,7 +95,11 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
{
|
||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null) return Error(404, "医生不存在");
|
||||
var profile = await _db.DoctorProfiles.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
|
||||
if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
|
||||
doctor.IsActive = !doctor.IsActive;
|
||||
profile.IsActive = doctor.IsActive;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { doctor.Id, doctor.IsActive });
|
||||
}
|
||||
@@ -65,7 +108,22 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
{
|
||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null) return Error(404, "医生不存在");
|
||||
await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct);
|
||||
|
||||
if (await _db.Users.AnyAsync(x => x.Role == "User" && x.DoctorId == id, ct))
|
||||
return Error(40004, "该医生仍有绑定患者,请先停用,不能直接删除");
|
||||
if (await _db.Consultations.AnyAsync(x => x.DoctorId == id, ct))
|
||||
return Error(40005, "该医生已有问诊记录,只能停用,不能删除");
|
||||
|
||||
var profile = await _db.DoctorProfiles.Include(x => x.User)
|
||||
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
|
||||
if (profile != null)
|
||||
{
|
||||
var user = profile.User;
|
||||
var refreshTokens = await _db.RefreshTokens.Where(x => x.UserId == user.Id).ToListAsync(ct);
|
||||
_db.RefreshTokens.RemoveRange(refreshTokens);
|
||||
_db.DoctorProfiles.Remove(profile);
|
||||
_db.Users.Remove(user);
|
||||
}
|
||||
_db.Doctors.Remove(doctor);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true });
|
||||
@@ -77,7 +135,9 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search));
|
||||
query = query.Where(x =>
|
||||
(x.Name != null && x.Name.Contains(search)) ||
|
||||
(x.Phone != null && x.Phone.Contains(search)));
|
||||
var total = await query.CountAsync(ct);
|
||||
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
|
||||
|
||||
@@ -3,7 +3,12 @@ using Health.Infrastructure.Services;
|
||||
|
||||
namespace Health.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
|
||||
public sealed class AuthService(
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
SmsService sms,
|
||||
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
|
||||
) : IAuthService
|
||||
{
|
||||
private const string AdminPhone = "12345678910";
|
||||
private const string AdminSmsCode = "000000";
|
||||
@@ -11,9 +16,21 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly JwtProvider _jwt = jwt;
|
||||
private readonly SmsService _sms = sms;
|
||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
||||
|
||||
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
|
||||
{
|
||||
// 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();
|
||||
await _db.VerificationCodes.AddAsync(new VerificationCode
|
||||
{
|
||||
@@ -21,7 +38,12 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
|
||||
}, 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 });
|
||||
}
|
||||
|
||||
@@ -82,14 +104,39 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
{
|
||||
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } });
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
user = new
|
||||
{
|
||||
id = AdminId,
|
||||
phone = AdminPhone,
|
||||
role = "Admin",
|
||||
name = "管理员"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var user = await _db.Users.FindAsync([oldToken.UserId], ct);
|
||||
if (user == null) return Error(40002, "用户不存在");
|
||||
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new { userTokens.accessToken, userTokens.refreshToken, user = new { user.Role } });
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
userTokens.accessToken,
|
||||
userTokens.refreshToken,
|
||||
user = new
|
||||
{
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Role,
|
||||
user.Name,
|
||||
user.Gender,
|
||||
user.AvatarUrl,
|
||||
BirthDate = user.BirthDate?.ToString("yyyy-MM-dd")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(string refreshToken, CancellationToken ct)
|
||||
@@ -99,13 +146,68 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
||||
{
|
||||
// 1. 验证 Apple identityToken
|
||||
string appleUserId;
|
||||
try
|
||||
{
|
||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Error(40005, "Apple 登录验证失败,请重试");
|
||||
}
|
||||
|
||||
// 2. 查找已有用户
|
||||
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
||||
if (existingUser != null)
|
||||
{
|
||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
||||
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
tokens.accessToken, tokens.refreshToken,
|
||||
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
||||
var newUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = null,
|
||||
AppleUserId = appleUserId,
|
||||
Role = "User",
|
||||
Name = command.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.Users.Add(newUser);
|
||||
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
newTokens.accessToken, newTokens.refreshToken,
|
||||
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
||||
});
|
||||
}
|
||||
|
||||
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
|
||||
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
|
||||
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
|
||||
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role)
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
||||
{
|
||||
var access = _jwt.GenerateAccessToken(userId, phone, role);
|
||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
||||
var refresh = _jwt.GenerateRefreshToken();
|
||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
return (access, refresh);
|
||||
|
||||
@@ -8,8 +8,9 @@ public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository
|
||||
|
||||
public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct)
|
||||
{
|
||||
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
// 日历的日期边界按北京时间计算,数据库仍统一使用 UTC。
|
||||
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
|
||||
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
|
||||
|
||||
var medications = await _db.Medications
|
||||
.Where(m => m.UserId == userId && m.IsActive)
|
||||
|
||||
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAppleSignIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Phone",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AppleUserId",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_AppleUserId",
|
||||
table: "Users",
|
||||
column: "AppleUserId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_AppleUserId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AppleUserId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Phone",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
1528
backend/src/Health.Infrastructure/Data/Migrations/20260723054148_AddAiEntryDrafts.Designer.cs
generated
Normal file
1528
backend/src/Health.Infrastructure/Data/Migrations/20260723054148_AddAiEntryDrafts.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -819,6 +819,9 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AppleUserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -838,7 +841,6 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Role")
|
||||
@@ -853,6 +855,9 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppleUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("DoctorId");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
@@ -951,6 +956,52 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.ToTable("VerificationCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiEntryDraftRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EntryType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MissingFields")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "ConversationId", "EntryType", "Status", "ExpiresAt");
|
||||
|
||||
b.ToTable("AiEntryDrafts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
|
||||
public DbSet<AiEntryDraftRecord> AiEntryDrafts => Set<AiEntryDraftRecord>();
|
||||
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
|
||||
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
|
||||
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
|
||||
@@ -50,6 +51,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
builder.Entity<User>(e =>
|
||||
{
|
||||
e.HasIndex(u => u.Phone).IsUnique();
|
||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识
|
||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
@@ -193,6 +195,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.Property(x => x.Arguments).HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
builder.Entity<AiEntryDraftRecord>(e =>
|
||||
{
|
||||
e.ToTable("AiEntryDrafts");
|
||||
e.HasIndex(x => new { x.UserId, x.ConversationId, x.EntryType, x.Status, x.ExpiresAt });
|
||||
e.Property(x => x.EntryType).HasMaxLength(32);
|
||||
e.Property(x => x.Status).HasMaxLength(32);
|
||||
e.Property(x => x.Payload).HasColumnType("jsonb");
|
||||
e.Property(x => x.MissingFields).HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
builder.Entity<ReportAnalysisTaskRecord>(e =>
|
||||
{
|
||||
e.ToTable("ReportAnalysisTasks");
|
||||
|
||||
@@ -19,16 +19,16 @@ public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) =>
|
||||
await _db.ExercisePlans.Include(p => p.Items)
|
||||
.Where(p => p.UserId == userId)
|
||||
.OrderByDescending(p => p.StartDate)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) =>
|
||||
_db.ExercisePlans.Include(p => p.Items)
|
||||
.FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct);
|
||||
|
||||
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) =>
|
||||
_db.ExercisePlans.Include(p => p.Items)
|
||||
.Where(p => p.UserId == userId)
|
||||
.OrderByDescending(p => p.StartDate)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) =>
|
||||
_db.ExercisePlanItems.Include(i => i.Plan)
|
||||
.FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct);
|
||||
|
||||
@@ -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)..];
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="AI\Prompts\**\*.md" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -30,15 +30,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit
|
||||
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
|
||||
_db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct);
|
||||
|
||||
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
|
||||
_db.Medications.AnyAsync(m => m.Id == medicationId && m.UserId == userId, ct);
|
||||
|
||||
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
|
||||
_db.MedicationLogs.FirstOrDefaultAsync(l =>
|
||||
l.MedicationId == medicationId && l.UserId == userId
|
||||
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
|
||||
&& l.Status == MedicationLogStatus.Taken, ct);
|
||||
|
||||
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
|
||||
_db.MedicationLogs.AnyAsync(l =>
|
||||
l.MedicationId == medicationId && l.UserId == userId
|
||||
|
||||
@@ -19,8 +19,24 @@ public sealed class LocalReportFileStorage : IReportFileStorage
|
||||
return new StoredReportFile($"/uploads/reports/{fileName}", filePath);
|
||||
}
|
||||
|
||||
public string GetLocalFilePath(string fileUrl) =>
|
||||
Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/'));
|
||||
public string GetLocalFilePath(string fileUrl)
|
||||
{
|
||||
var reportsRoot = Path.GetFullPath(Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"uploads",
|
||||
"reports"));
|
||||
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||
? absolute.AbsolutePath
|
||||
: fileUrl.Split('?', 2)[0];
|
||||
var fileName = Path.GetFileName(Uri.UnescapeDataString(path));
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName))
|
||||
return Path.Combine(reportsRoot, "__invalid__");
|
||||
|
||||
var candidate = Path.GetFullPath(Path.Combine(reportsRoot, fileName));
|
||||
return candidate.StartsWith(reportsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||
? candidate
|
||||
: Path.Combine(reportsRoot, "__invalid__");
|
||||
}
|
||||
|
||||
public bool Exists(string filePath) =>
|
||||
File.Exists(filePath);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Health.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Apple IdentityToken 验证:下载 Apple 公钥、验签、检查声明
|
||||
/// </summary>
|
||||
public sealed class AppleTokenValidator
|
||||
{
|
||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
||||
private const string AppleIssuer = "https://appleid.apple.com";
|
||||
private static readonly HttpClient _http = new();
|
||||
private static List<JsonWebKey>? _cachedKeys;
|
||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
private readonly string _clientId;
|
||||
|
||||
public AppleTokenValidator(IConfiguration config)
|
||||
{
|
||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
||||
/// </summary>
|
||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
// 先解码获取 kid
|
||||
var rawToken = handler.ReadJwtToken(identityToken);
|
||||
var kid = rawToken.Header.Kid;
|
||||
|
||||
var keys = await GetKeysAsync(ct);
|
||||
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
||||
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = AppleIssuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
handler.ValidateToken(identityToken, validationParams, out _);
|
||||
// 从原始 JWT 取 sub(避免 .NET 的 ClaimType 映射把 "sub" 转成 NameIdentifier)
|
||||
var sub = rawToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(sub))
|
||||
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
||||
var jwks = new JsonWebKeySet(response);
|
||||
_cachedKeys = jwks.Keys.ToList();
|
||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
||||
return _cachedKeys;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
}
|
||||
49
backend/src/Health.Infrastructure/Services/bce_signer.cs
Normal file
49
backend/src/Health.Infrastructure/Services/bce_signer.cs
Normal 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);
|
||||
}
|
||||
64
backend/src/Health.Infrastructure/Services/bce_sms_client.cs
Normal file
64
backend/src/Health.Infrastructure/Services/bce_sms_client.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Health.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 短信验证码服务(开发阶段直接返回成功)
|
||||
/// 短信验证码服务(调用百度云 SMS v3 真实发送)
|
||||
/// </summary>
|
||||
public sealed class SmsService
|
||||
public sealed class SmsService(BceSmsClient bce, ILogger<SmsService> log)
|
||||
{
|
||||
/// <summary>
|
||||
/// 发送验证码(开发阶段不做真实发送)
|
||||
/// </summary>
|
||||
public Task<bool> SendCodeAsync(string phone, string code)
|
||||
private readonly BceSmsClient _bce = bce;
|
||||
private readonly ILogger<SmsService> _log = log;
|
||||
|
||||
public async Task<bool> SendCodeAsync(string phone, string code)
|
||||
{
|
||||
// 开发阶段:直接在控制台输出,不做真实发送
|
||||
Console.WriteLine($"[SMS DEV] 发送验证码到 {phone}: {code}");
|
||||
return Task.FromResult(true);
|
||||
try
|
||||
{
|
||||
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>
|
||||
/// 生成 6 位随机数字验证码
|
||||
/// </summary>
|
||||
public string GenerateCode()
|
||||
{
|
||||
// Next(min, max) 的 max 是 exclusive 的,所以用 1000000 保证 6 位
|
||||
return Random.Shared.Next(100000, 1000000).ToString();
|
||||
}
|
||||
public string GenerateCode() => Random.Shared.Next(100000, 1000000).ToString();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,23 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
|
||||
|
||||
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||
{
|
||||
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||
AddMetadataUrls(fileUrls, metadataJson);
|
||||
|
||||
foreach (var fileUrl in fileUrls)
|
||||
// 正式报告路径来自服务端生成的报告记录,只允许删除 reports 目录中的文件。
|
||||
foreach (var fileUrl in references.FileUrls)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var localPath = ResolveLocalPath(fileUrl);
|
||||
var localPath = ResolveReportPath(fileUrl);
|
||||
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||
}
|
||||
|
||||
// 对话元数据可能包含客户端传入的 URL,只允许解析当前账号自己的目录。
|
||||
var attachmentUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||
AddMetadataUrls(attachmentUrls, metadataJson);
|
||||
|
||||
foreach (var fileUrl in attachmentUrls)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var localPath = ResolveOwnedAttachmentPath(userId, fileUrl);
|
||||
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||
}
|
||||
|
||||
@@ -52,31 +61,65 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
|
||||
if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value);
|
||||
}
|
||||
|
||||
private string? ResolveLocalPath(string fileUrl)
|
||||
private string? ResolveReportPath(string fileUrl)
|
||||
{
|
||||
var urlPath = fileUrl;
|
||||
if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri))
|
||||
urlPath = absoluteUri.AbsolutePath;
|
||||
|
||||
var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase);
|
||||
if (uploadsIndex < 0) return null;
|
||||
|
||||
string relativePath;
|
||||
try
|
||||
{
|
||||
relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]);
|
||||
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||
? absolute.AbsolutePath
|
||||
: fileUrl.Split('?', 2)[0];
|
||||
var prefixIndex = path.IndexOf("/uploads/reports/", StringComparison.OrdinalIgnoreCase);
|
||||
if (prefixIndex < 0) return null;
|
||||
var fileName = Uri.UnescapeDataString(path[(prefixIndex + "/uploads/reports/".Length)..]);
|
||||
return ResolveInside(Path.Combine(_uploadsRoot, "reports"), fileName);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var queryIndex = relativePath.IndexOfAny(['?', '#']);
|
||||
if (queryIndex >= 0) relativePath = relativePath[..queryIndex];
|
||||
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
private string? ResolveOwnedAttachmentPath(Guid userId, string fileUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||
? absolute.AbsolutePath
|
||||
: fileUrl.Split('?', 2)[0];
|
||||
path = Uri.UnescapeDataString(path);
|
||||
var protectedPrefix = "/api/files/content/";
|
||||
var protectedIndex = path.IndexOf(protectedPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
var legacyPrefix = $"/uploads/users/{userId:N}/";
|
||||
var legacyIndex = path.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
var fileName = protectedIndex >= 0
|
||||
? path[(protectedIndex + protectedPrefix.Length)..]
|
||||
: legacyIndex >= 0
|
||||
? path[(legacyIndex + legacyPrefix.Length)..]
|
||||
: null;
|
||||
return fileName == null
|
||||
? null
|
||||
: ResolveInside(Path.Combine(_uploadsRoot, "users", userId.ToString("N")), fileName);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath));
|
||||
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
private static string? ResolveInside(string root, string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
|
||||
var fullRoot = Path.GetFullPath(root);
|
||||
var fullPath = Path.GetFullPath(Path.Combine(fullRoot, fileName));
|
||||
var rootPrefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ public static class AuthEndpoints
|
||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
@@ -28,4 +30,5 @@ public static class AuthEndpoints
|
||||
public sealed record SendSmsRequest(string Phone);
|
||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
public sealed record RefreshRequest(string RefreshToken);
|
||||
|
||||
@@ -43,6 +43,20 @@ public static class DoctorEndpoints
|
||||
return (startUtc, startUtc.AddDays(1));
|
||||
}
|
||||
|
||||
private static DateTime ParseBeijingDateTimeAsUtc(string value)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(value, out var offset) &&
|
||||
(value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.LastIndexOf('+') > 9 ||
|
||||
value.LastIndexOf('-') > 9))
|
||||
return offset.UtcDateTime;
|
||||
|
||||
var beijing = DateTime.Parse(value);
|
||||
return DateTime.SpecifyKind(
|
||||
DateTime.SpecifyKind(beijing, DateTimeKind.Unspecified).AddHours(-8),
|
||||
DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
public static void MapDoctorEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
||||
@@ -105,6 +119,7 @@ public static class DoctorEndpoints
|
||||
doctorHospital = profile.Hospital,
|
||||
doctorAvatar = profile.AvatarUrl,
|
||||
doctorOnline = profile.IsOnline,
|
||||
doctorActive = profile.IsActive,
|
||||
stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps },
|
||||
pendingConsultations,
|
||||
pendingReports = pendingReportList,
|
||||
@@ -125,7 +140,7 @@ public static class DoctorEndpoints
|
||||
{
|
||||
query = query.Where(u =>
|
||||
(u.Name != null && u.Name.Contains(search)) ||
|
||||
u.Phone.Contains(search));
|
||||
(u.Phone != null && u.Phone.Contains(search)));
|
||||
}
|
||||
var total = await query.CountAsync();
|
||||
var patients = await query.OrderByDescending(u => u.CreatedAt)
|
||||
@@ -303,7 +318,7 @@ public static class DoctorEndpoints
|
||||
query = query.Where(r => r.Status == s);
|
||||
|
||||
var reports = await query.OrderByDescending(r => r.CreatedAt)
|
||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||
.ToListAsync();
|
||||
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
||||
});
|
||||
@@ -316,7 +331,7 @@ public static class DoctorEndpoints
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId)
|
||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||
.FirstOrDefaultAsync();
|
||||
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
||||
return Results.Ok(new { code = 0, data = report, message = (string?)null });
|
||||
@@ -390,7 +405,7 @@ public static class DoctorEndpoints
|
||||
Title = json.RootElement.GetProperty("title").GetString() ?? "",
|
||||
DoctorName = profile.Name,
|
||||
Department = profile.Department,
|
||||
ScheduledAt = DateTime.Parse(json.RootElement.GetProperty("scheduledAt").GetString()!),
|
||||
ScheduledAt = ParseBeijingDateTimeAsUtc(json.RootElement.GetProperty("scheduledAt").GetString()!),
|
||||
Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null,
|
||||
Status = FollowUpStatus.Upcoming,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
@@ -414,7 +429,7 @@ public static class DoctorEndpoints
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||
if (json.RootElement.TryGetProperty("title", out var t)) followUp.Title = t.GetString() ?? followUp.Title;
|
||||
if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = DateTime.Parse(sa.GetString()!);
|
||||
if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = ParseBeijingDateTimeAsUtc(sa.GetString()!);
|
||||
if (json.RootElement.TryGetProperty("notes", out var no)) followUp.Notes = no.GetString();
|
||||
if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse<FollowUpStatus>(st.GetString(), out var fs)) followUp.Status = fs;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Health.Infrastructure.Files;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class FileEndpoints
|
||||
@@ -47,24 +49,48 @@ public static class FileEndpoints
|
||||
|
||||
var storedName = $"{fileId}{ext}";
|
||||
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
|
||||
await using var stream = new FileStream(filePath, FileMode.Create);
|
||||
await using var stream = new FileStream(filePath, FileMode.CreateNew);
|
||||
await file.CopyToAsync(stream, ct);
|
||||
results.Add(new
|
||||
{
|
||||
id = fileId,
|
||||
name = file.FileName,
|
||||
size = file.Length,
|
||||
url = $"/uploads/users/{userDirectoryName}/{storedName}",
|
||||
url = $"/api/files/content/{storedName}",
|
||||
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/content/{fileName}", (string fileName, HttpContext http) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var filePath = UserUploadPathResolver.Resolve(userId, fileName);
|
||||
if (filePath == null || !File.Exists(filePath))
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.File(
|
||||
filePath,
|
||||
ContentTypeFor(filePath),
|
||||
enableRangeProcessing: true);
|
||||
});
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
|
||||
? id
|
||||
: Guid.Empty;
|
||||
|
||||
private static string ContentTypeFor(string path) =>
|
||||
Path.GetExtension(path).ToLowerInvariant() switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
".gif" => "image/gif",
|
||||
".pdf" => "application/pdf",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,15 +42,6 @@ public static class MedicationEndpoints
|
||||
: Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" });
|
||||
});
|
||||
|
||||
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var taken = await medications.ToggleTodayTakenAsync(userId, id, ct);
|
||||
if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" });
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { taken }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
|
||||
@@ -24,6 +24,47 @@ public static class ReportEndpoints
|
||||
: Results.Ok(new { code = 0, data = report, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/{id:guid}/file", async (
|
||||
Guid id,
|
||||
HttpContext http,
|
||||
AppDbContext db,
|
||||
IReportFileStorage fileStorage,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var currentUserId = GetUserId(http);
|
||||
var report = await db.Reports
|
||||
.Include(r => r.User)
|
||||
.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
if (report == null)
|
||||
return Results.NotFound();
|
||||
|
||||
var allowed = report.UserId == currentUserId;
|
||||
if (!allowed && GetRole(http) == "Doctor")
|
||||
{
|
||||
var doctorId = await db.DoctorProfiles
|
||||
.Where(profile => profile.UserId == currentUserId)
|
||||
.Select(profile => profile.DoctorId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
allowed = doctorId != null && report.User.DoctorId == doctorId;
|
||||
}
|
||||
if (!allowed)
|
||||
return Results.NotFound();
|
||||
|
||||
var filePath = fileStorage.GetLocalFilePath(report.FileUrl);
|
||||
if (!fileStorage.Exists(filePath))
|
||||
return Results.NotFound();
|
||||
|
||||
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
".pdf" => "application/pdf",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
return Results.File(filePath, contentType, enableRangeProcessing: true);
|
||||
});
|
||||
|
||||
group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
@@ -66,4 +107,9 @@ public static class ReportEndpoints
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
|
||||
private static string GetRole(HttpContext http) =>
|
||||
http.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ??
|
||||
http.User.FindFirst("Role")?.Value ??
|
||||
"User";
|
||||
}
|
||||
|
||||
69
backend/src/Health.WebApi/Endpoints/speech_endpoints.cs
Normal file
69
backend/src/Health.WebApi/Endpoints/speech_endpoints.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,12 @@ public static class UserEndpoints
|
||||
var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
|
||||
var updated = await users.UpdateProfileAsync(
|
||||
GetUserId(http),
|
||||
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate),
|
||||
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate, req.AvatarUrl),
|
||||
ct);
|
||||
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" });
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var profile = await users.GetProfileAsync(GetUserId(http), ct);
|
||||
return Results.Ok(new { code = 0, data = profile, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
|
||||
@@ -66,7 +67,7 @@ public static class UserEndpoints
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate);
|
||||
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate, string? AvatarUrl);
|
||||
public sealed record UpdateArchiveRequest(
|
||||
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
||||
List<string>? Allergies, List<string>? DietRestrictions,
|
||||
|
||||
@@ -16,8 +16,19 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// SSE/流式请求由客户端主动取消时,响应通常已经开始。
|
||||
// 这是正常断开,不应再尝试改写状态码或错误正文。
|
||||
_logger.LogDebug("客户端已取消请求: {Path}", context.Request.Path);
|
||||
}
|
||||
catch (Health.Domain.ValidationException vex)
|
||||
{
|
||||
if (context.Response.HasStarted)
|
||||
{
|
||||
_logger.LogWarning(vex, "响应开始后发生业务校验异常: {Path}", context.Request.Path);
|
||||
return;
|
||||
}
|
||||
// 业务输入校验失败:返回 400,message 为我们自己产生的提示,可安全展示
|
||||
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
|
||||
context.Response.ContentType = "application/json";
|
||||
@@ -26,6 +37,11 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
||||
}
|
||||
catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex)
|
||||
{
|
||||
if (context.Response.HasStarted)
|
||||
{
|
||||
_logger.LogWarning(ex, "响应开始后发生请求解析异常: {Path}", context.Request.Path);
|
||||
return;
|
||||
}
|
||||
// 请求体无法解析(非法 JSON、编码错误、请求体过大等):返回其携带的状态码(通常 400),而不是 500
|
||||
_logger.LogWarning(ex, "请求解析失败: {Path}", context.Request.Path);
|
||||
context.Response.StatusCode = ex.StatusCode;
|
||||
@@ -37,6 +53,8 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
||||
{
|
||||
// 生产环境不暴露内部异常详情
|
||||
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
|
||||
if (context.Response.HasStarted)
|
||||
return;
|
||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ using Health.Application.Medications;
|
||||
using Health.Application.Maintenance;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Application.Reports;
|
||||
using Health.Application.Speech;
|
||||
using Health.Application.Users;
|
||||
using Health.Infrastructure.AI;
|
||||
using Health.Infrastructure.Admin;
|
||||
@@ -25,6 +26,7 @@ using Health.Infrastructure.Medications;
|
||||
using Health.Infrastructure.Maintenance;
|
||||
using Health.Infrastructure.Notifications;
|
||||
using Health.Infrastructure.Reports;
|
||||
using Health.Infrastructure.Speech;
|
||||
using Health.Infrastructure.Services;
|
||||
using Health.Infrastructure.Users;
|
||||
using Health.WebApi.BackgroundServices;
|
||||
@@ -34,7 +36,6 @@ using Health.WebApi.Middleware;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
// 加载 .env 文件(开发环境)
|
||||
@@ -107,9 +108,17 @@ builder.Services.AddAuthorization();
|
||||
|
||||
// ---- 业务服务 ----
|
||||
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<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
||||
builder.Services.AddSingleton<PromptManager>();
|
||||
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
||||
builder.Services.AddScoped<IAiEntryDraftStore, EfAiEntryDraftStore>();
|
||||
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
||||
builder.Services.AddScoped<IAdminService, AdminService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
@@ -150,6 +159,7 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
|
||||
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||
builder.Services.AddSingleton<IRealtimeSpeechRecognitionProxy, DashScopeSpeechRecognitionProxy>();
|
||||
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
|
||||
|
||||
@@ -205,18 +215,17 @@ app.UseMiddleware<ExceptionMiddleware>();
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseWebSockets(new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(20),
|
||||
});
|
||||
|
||||
// 默认 wwwroot 静态文件(法律文档 H5 页面等)
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
Directory.CreateDirectory(uploadsPath);
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(uploadsPath),
|
||||
RequestPath = "/uploads"
|
||||
});
|
||||
// 用户上传文件不能作为静态目录公开;统一通过带鉴权和归属校验的 API 读取。
|
||||
Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), "uploads"));
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
@@ -245,6 +254,7 @@ app.MapNotificationEndpoints();
|
||||
app.MapDoctorEndpoints();
|
||||
app.MapAdminEndpoints();
|
||||
app.MapFollowUpEndpoints();
|
||||
app.MapSpeechEndpoints();
|
||||
|
||||
// SignalR Hub
|
||||
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"JWT_AUDIENCE": "health-manager-app",
|
||||
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
||||
"DEEPSEEK_API_KEY": "sk-your-key-here",
|
||||
"DEEPSEEK_MODEL": "deepseek-chat",
|
||||
"DEEPSEEK_MODEL": "deepseek-v4-flash",
|
||||
"QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"QWEN_API_KEY": "sk-your-key-here",
|
||||
"QWEN_VISION_MODEL": "qwen-vl-max",
|
||||
|
||||
BIN
backend/src/Health.WebApi/wwwroot/app-icon.png
Normal file
BIN
backend/src/Health.WebApi/wwwroot/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -28,7 +28,7 @@
|
||||
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
|
||||
|
||||
<h2>一、我们收集的信息</h2>
|
||||
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
||||
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
||||
|
||||
<h3>1. 账号与身份信息</h3>
|
||||
<ul>
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
<h3>4. 蓝牙设备信息</h3>
|
||||
<ul>
|
||||
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主。</li>
|
||||
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前实际支持以采用标准蓝牙血压服务的血压计为主;其他设备类型将在功能正式开放后另行说明。</li>
|
||||
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。</li>
|
||||
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
|
||||
</ul>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<p>如您不同意本协议,请停止注册或使用本应用。</p>
|
||||
|
||||
<h2>一、服务内容</h2>
|
||||
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
||||
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
||||
<p>本应用当前可能包含以下服务:</p>
|
||||
<ul>
|
||||
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
|
||||
@@ -37,7 +37,6 @@
|
||||
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
|
||||
<li>AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
|
||||
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
|
||||
<li>在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。</li>
|
||||
</ul>
|
||||
|
||||
<h2>二、账号注册与使用</h2>
|
||||
@@ -56,7 +55,6 @@
|
||||
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
|
||||
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
|
||||
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
|
||||
<li>医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。</li>
|
||||
</ul>
|
||||
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ public sealed class AccountDeletionTests
|
||||
var userId = Guid.NewGuid();
|
||||
var userDirectory = Path.Combine(root, "users", userId.ToString("N"));
|
||||
var reportPath = Path.Combine(root, "reports", "owned-report.pdf");
|
||||
var chatImagePath = Path.Combine(root, "owned-chat.jpg");
|
||||
var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().ToString("N"), "keep.jpg");
|
||||
var chatFileName = $"{Guid.NewGuid()}.jpg";
|
||||
var chatImagePath = Path.Combine(userDirectory, chatFileName);
|
||||
var otherUserId = Guid.NewGuid();
|
||||
var otherUserPath = Path.Combine(root, "users", otherUserId.ToString("N"), "keep.jpg");
|
||||
var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt");
|
||||
|
||||
try
|
||||
@@ -31,7 +33,7 @@ public sealed class AccountDeletionTests
|
||||
var cleanup = new LocalAccountFileCleanup(root);
|
||||
var references = new AccountFileReferences(
|
||||
["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"],
|
||||
["{\"imageUrl\":\"/uploads/owned-chat.jpg\"}"]);
|
||||
[$"{{\"imageUrl\":\"/uploads/users/{userId:N}/{chatFileName}\"}}", $"{{\"imageUrl\":\"/uploads/users/{otherUserId:N}/keep.jpg\"}}"]);
|
||||
|
||||
await cleanup.DeleteAsync(userId, references, CancellationToken.None);
|
||||
|
||||
|
||||
191
backend/tests/Health.Tests/admin_service_tests.cs
Normal file
191
backend/tests/Health.Tests/admin_service_tests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ using Health.Application.Exercises;
|
||||
using Health.Domain;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.AI.AgentHandlers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
@@ -95,6 +97,66 @@ public sealed class ApplicationServiceTests
|
||||
Assert.Equal(1, repository.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEntryBatch_ExecutesAllMetricsWithOneSave()
|
||||
{
|
||||
var repository = new CapturingHealthRecordRepository();
|
||||
var service = new HealthRecordService(repository);
|
||||
using var arguments = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"metrics": [
|
||||
{ "type": "blood_pressure", "systolic": 113, "diastolic": 86 },
|
||||
{ "type": "heart_rate", "heart_rate": 80 },
|
||||
{ "type": "glucose", "glucose": 5.6 }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await HealthDataAgentHandler.Execute(
|
||||
"record_health_data_batch",
|
||||
arguments.RootElement,
|
||||
Guid.NewGuid(),
|
||||
service,
|
||||
CancellationToken.None);
|
||||
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||
|
||||
Assert.True(resultJson.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(3, resultJson.RootElement.GetProperty("record_ids").GetArrayLength());
|
||||
Assert.Equal(3, resultJson.RootElement.GetProperty("items").GetArrayLength());
|
||||
Assert.Equal(3, repository.Added.Count);
|
||||
Assert.Equal(1, repository.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEntryBatch_IncompleteMetricBlocksWholeBatch()
|
||||
{
|
||||
var repository = new CapturingHealthRecordRepository();
|
||||
var service = new HealthRecordService(repository);
|
||||
using var arguments = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"metrics": [
|
||||
{ "type": "blood_pressure", "systolic": 113 },
|
||||
{ "type": "heart_rate", "heart_rate": 80 }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await HealthDataAgentHandler.Execute(
|
||||
"record_health_data_batch",
|
||||
arguments.RootElement,
|
||||
Guid.NewGuid(),
|
||||
service,
|
||||
CancellationToken.None);
|
||||
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||
|
||||
Assert.False(resultJson.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("舒张压", resultJson.RootElement.GetProperty("message").GetString());
|
||||
Assert.Empty(repository.Added);
|
||||
Assert.Equal(0, repository.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
|
||||
{
|
||||
@@ -211,6 +273,24 @@ public sealed class ApplicationServiceTests
|
||||
Assert.Equal(["medication", "exercise", "followup"], events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Calendar_FollowUpUsesBeijingDateForUtcTimestamp()
|
||||
{
|
||||
var followUp = new FollowUp
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ScheduledAt = new DateTime(2026, 6, 18, 17, 0, 0, DateTimeKind.Utc),
|
||||
Title = "北京时间复查"
|
||||
};
|
||||
var service = new CalendarService(new FakeCalendarRepository(
|
||||
new CalendarDataSnapshot([], [], [followUp])));
|
||||
|
||||
var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None);
|
||||
|
||||
var target = Assert.Single(result);
|
||||
Assert.Equal("2026-06-19", target.GetType().GetProperty("date")!.GetValue(target));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates()
|
||||
{
|
||||
@@ -252,6 +332,130 @@ public sealed class ApplicationServiceTests
|
||||
Assert.Equal(2, current.Items.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiExerciseQuery_UpcomingScopeReturnsFuturePlanButTodayScopeDoesNot()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var repository = new FakeExerciseRepository();
|
||||
var plan = new ExercisePlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
StartDate = today.AddDays(2),
|
||||
EndDate = today.AddDays(4),
|
||||
ReminderTime = new TimeOnly(19, 0),
|
||||
};
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ScheduledDate = today.AddDays(2),
|
||||
ExerciseType = "散步",
|
||||
DurationMinutes = 30,
|
||||
});
|
||||
repository.SetPlan(plan);
|
||||
var service = new ExerciseService(repository);
|
||||
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
|
||||
using var todayArgs = JsonDocument.Parse("""{"action":"query","scope":"today"}""");
|
||||
|
||||
var upcoming = await ExerciseAgentHandler.Execute(
|
||||
"manage_exercise", upcomingArgs.RootElement, userId, service, CancellationToken.None);
|
||||
var todayResult = await ExerciseAgentHandler.Execute(
|
||||
"manage_exercise", todayArgs.RootElement, userId, service, CancellationToken.None);
|
||||
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
|
||||
using var todayJson = JsonDocument.Parse(JsonSerializer.Serialize(todayResult, jsonOptions));
|
||||
|
||||
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
|
||||
Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
|
||||
Assert.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]
|
||||
public async Task ExercisePlan_CheckInRejectsNonTodayItem()
|
||||
{
|
||||
@@ -360,9 +564,11 @@ public sealed class ApplicationServiceTests
|
||||
public void SetPlan(ExercisePlan plan) => Plan = plan;
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||
Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []);
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>([]);
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||
Plan != null && Plan.UserId == userId ? [Plan] : []);
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||
Plan != null && Plan.UserId == userId ? [Plan] : []);
|
||||
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan);
|
||||
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) => Task.FromResult(Plan);
|
||||
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId));
|
||||
public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; }
|
||||
public void Delete(ExercisePlan plan) { Plan = null; }
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Infrastructure.Auth;
|
||||
using Health.Infrastructure.Data;
|
||||
using Health.Infrastructure.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
@@ -30,12 +33,16 @@ public class AuthTests
|
||||
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]
|
||||
public async Task SendSms_Should_Create_VerificationCode()
|
||||
{
|
||||
// Arrange
|
||||
using var db = CreateDbContext();
|
||||
var sms = new SmsService();
|
||||
var sms = CreateSmsService();
|
||||
|
||||
// Act
|
||||
var code = sms.GenerateCode();
|
||||
@@ -118,6 +125,47 @@ public class AuthTests
|
||||
Assert.NotNull(active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_Should_Return_Stable_User_Identity()
|
||||
{
|
||||
using var db = CreateDbContext();
|
||||
var config = CreateConfig();
|
||||
var jwt = new JwtProvider(config);
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = "13800138000",
|
||||
Role = "User",
|
||||
Name = "测试用户",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
var oldRefresh = jwt.GenerateRefreshToken();
|
||||
db.Users.Add(user);
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
Token = oldRefresh,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = new AuthService(
|
||||
db,
|
||||
jwt,
|
||||
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]
|
||||
public async Task VerificationCode_Expired_Should_Fail_Login()
|
||||
{
|
||||
|
||||
111
backend/tests/Health.Tests/bce_signer_tests.cs
Normal file
111
backend/tests/Health.Tests/bce_signer_tests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
40
backend/tests/Health.Tests/file_path_security_tests.cs
Normal file
40
backend/tests/Health.Tests/file_path_security_tests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using Health.Application.Medications;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.AI.AgentHandlers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
@@ -32,21 +35,227 @@ public sealed class MedicationUpdateTests
|
||||
Assert.True(repository.Saved);
|
||||
}
|
||||
|
||||
private sealed class FakeMedicationRepository(Medication medication) : IMedicationRepository
|
||||
[Fact]
|
||||
public async Task AiOverview_DoesNotScheduleFutureMedicationForToday()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "未来用药",
|
||||
IsActive = true,
|
||||
StartDate = today.AddDays(2),
|
||||
EndDate = today.AddDays(10),
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
};
|
||||
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||
|
||||
var overview = await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None);
|
||||
|
||||
var plan = Assert.Single(overview);
|
||||
Assert.Equal("upcoming", plan.Phase);
|
||||
Assert.False(plan.ScheduledOnDate);
|
||||
Assert.Empty(plan.Doses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiOverview_ReportsEachDoseInsteadOfAnyTakenAggregate()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "两顿药",
|
||||
IsActive = true,
|
||||
StartDate = today.AddDays(-1),
|
||||
EndDate = today.AddDays(1),
|
||||
Frequency = MedicationFrequency.TwiceDaily,
|
||||
TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)],
|
||||
};
|
||||
var log = new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = medication.UserId,
|
||||
MedicationId = medication.Id,
|
||||
ScheduledTime = new TimeOnly(8, 0),
|
||||
Status = MedicationLogStatus.Taken,
|
||||
ConfirmedAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var service = new MedicationService(new FakeMedicationRepository(medication, [log]));
|
||||
|
||||
var plan = Assert.Single(await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None));
|
||||
|
||||
Assert.Equal(2, plan.Doses.Count);
|
||||
Assert.Equal("taken", plan.Doses.Single(dose => dose.ScheduledTime == "08:00").Status);
|
||||
Assert.NotEqual("taken", plan.Doses.Single(dose => dose.ScheduledTime == "20:00").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmDose_RejectsAClockTimeThatIsNotInTodaysPlan()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "阿司匹林",
|
||||
IsActive = true,
|
||||
StartDate = today,
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
};
|
||||
var repository = new FakeMedicationRepository(medication);
|
||||
var service = new MedicationService(repository);
|
||||
|
||||
var result = await service.ConfirmDoseAsync(
|
||||
medication.UserId,
|
||||
medication.Id,
|
||||
new TimeOnly(8, 15),
|
||||
MedicationLogStatus.Taken,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(repository.AddedLogs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AiMedicationCreate_RejectsMissingFieldsInsteadOfUsingDefaults()
|
||||
{
|
||||
using var arguments = JsonDocument.Parse("""{"action":"create","name":"阿司匹林"}""");
|
||||
|
||||
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
|
||||
|
||||
Assert.Equal("创建用药计划前需要确认每次剂量", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AiMedicationCreate_AcceptsCompleteFinitePlan()
|
||||
{
|
||||
using var arguments = JsonDocument.Parse("""
|
||||
{
|
||||
"action":"create",
|
||||
"name":"阿司匹林",
|
||||
"dosage":"100mg",
|
||||
"frequency":"Daily",
|
||||
"time_of_day":["08:00"],
|
||||
"start_date":"2026-07-20",
|
||||
"duration_days":30
|
||||
}
|
||||
""");
|
||||
|
||||
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
|
||||
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiMedicationQuery_UpcomingScopeReturnsTomorrowPlanButCurrentScopeDoesNot()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "明天开始的药",
|
||||
Dosage = "1片",
|
||||
IsActive = true,
|
||||
StartDate = today.AddDays(1),
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
};
|
||||
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
|
||||
using var currentArgs = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
|
||||
|
||||
var upcoming = await MedicationAgentHandler.Execute(
|
||||
"manage_medication", upcomingArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||
var current = await MedicationAgentHandler.Execute(
|
||||
"manage_medication", currentArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
|
||||
using var currentJson = JsonDocument.Parse(JsonSerializer.Serialize(current, jsonOptions));
|
||||
|
||||
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
|
||||
Assert.True(upcomingJson.RootElement.GetProperty("has_upcoming_plans").GetBoolean());
|
||||
Assert.Equal(1, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
|
||||
Assert.Equal("明天开始的药", upcomingJson.RootElement.GetProperty("plans")[0].GetProperty("name").GetString());
|
||||
Assert.Equal(0, currentJson.RootElement.GetProperty("count").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiMedicationQuery_RequiresExplicitScopeInsteadOfAssumingToday()
|
||||
{
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "测试药物",
|
||||
IsActive = true,
|
||||
StartDate = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
};
|
||||
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||
using var args = JsonDocument.Parse("""{"action":"query"}""");
|
||||
|
||||
var result = await MedicationAgentHandler.Execute(
|
||||
"manage_medication", args.RootElement, medication.UserId, service, CancellationToken.None);
|
||||
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||
|
||||
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiMedicationQuery_SeparatesInactivePlansFromNaturallyEndedPlans()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "已停用药物",
|
||||
IsActive = false,
|
||||
StartDate = today.AddDays(-10),
|
||||
EndDate = today.AddDays(10),
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
};
|
||||
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||
using var inactiveArgs = JsonDocument.Parse("""{"action":"query","scope":"inactive_plans"}""");
|
||||
using var endedArgs = JsonDocument.Parse("""{"action":"query","scope":"ended_plans"}""");
|
||||
|
||||
var inactive = await MedicationAgentHandler.Execute(
|
||||
"manage_medication", inactiveArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||
var ended = await MedicationAgentHandler.Execute(
|
||||
"manage_medication", endedArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||
using var inactiveJson = JsonDocument.Parse(JsonSerializer.Serialize(inactive));
|
||||
using var endedJson = JsonDocument.Parse(JsonSerializer.Serialize(ended));
|
||||
|
||||
Assert.Equal(1, inactiveJson.RootElement.GetProperty("count").GetInt32());
|
||||
Assert.Equal(0, endedJson.RootElement.GetProperty("count").GetInt32());
|
||||
}
|
||||
|
||||
private sealed class FakeMedicationRepository(
|
||||
Medication medication,
|
||||
IReadOnlyList<MedicationLog>? logs = null) : IMedicationRepository
|
||||
{
|
||||
public bool Saved { get; private set; }
|
||||
public List<MedicationLog> AddedLogs { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<IReadOnlyList<MedicationLog>>([]);
|
||||
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(logs ?? (IReadOnlyList<MedicationLog>)[]);
|
||||
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null);
|
||||
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId);
|
||||
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false);
|
||||
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||
public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AddLogAsync(MedicationLog log, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AddLogAsync(MedicationLog log, CancellationToken ct) { AddedLogs.Add(log); return Task.CompletedTask; }
|
||||
public void DeleteMedication(Medication value) { }
|
||||
public void DeleteLog(MedicationLog log) { }
|
||||
public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; }
|
||||
|
||||
@@ -1,18 +1,69 @@
|
||||
using Health.Infrastructure.AI;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
public sealed class PromptManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnifiedPrompt_DistinguishesQuestionsFromEntryRequests()
|
||||
{
|
||||
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
|
||||
private readonly PromptManager _prompts = new();
|
||||
|
||||
Assert.Contains("先判断用户是在咨询数值,还是希望记录数据", prompt);
|
||||
Assert.Contains("血氧98%是不是太高了", prompt);
|
||||
Assert.Contains("先回答问题,不调用 record_health_data", prompt);
|
||||
Assert.Contains("帮我记录血压116/89", prompt);
|
||||
[Fact]
|
||||
public void RouterPrompt_DistinguishesEntryConsultationAndDraftContinuation()
|
||||
{
|
||||
var prompt = _prompts.GetIntentRouterPrompt();
|
||||
|
||||
Assert.Contains("只负责识别意图,不回答用户问题", 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);
|
||||
}
|
||||
}
|
||||
|
||||
219
docs/HANDOFF-2026-07-17.md
Normal file
219
docs/HANDOFF-2026-07-17.md
Normal 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 鉴权一起处理。
|
||||
|
||||
### P1:AI SSE 鉴权和上传文件隐私
|
||||
|
||||
此前检查发现:
|
||||
|
||||
- AI SSE 接口允许从 query token 中直接读取 JWT claims,没有完整验证签名、签发者、受众和有效期。
|
||||
- 上传的医疗图片和报告通过 `/uploads` 静态公开访问。
|
||||
- 附件本地路径解析缺少完整的目录边界和文件归属校验。
|
||||
- CORS 当前允许任意来源并携带凭据。
|
||||
|
||||
这些问题应在正式上线前统一修复。
|
||||
|
||||
### P1:iOS 蓝牙流程仍需修复和真机验证
|
||||
|
||||
此前检查发现 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 提交或远程推送。
|
||||
- 没有执行数据库迁移。
|
||||
- 没有写入、更新或删除真实数据库数据。
|
||||
- 没有生成或上传发布安装包。
|
||||
- 没有启动需要额外关闭的前端或后端常驻服务。
|
||||
@@ -1,96 +0,0 @@
|
||||
# UI System First Pass Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the first pass of the app-wide UI system and migrate the highest-impact frontend surfaces to the agreed typography, color, icon, layout, button, feedback, and AI content rules.
|
||||
|
||||
**Architecture:** Centralize design decisions in token and common widget files, then migrate pages by replacing local hardcoded visuals with shared module visuals and components. Keep this pass focused on visible consistency and maintainability without adding the full accessibility-mode feature yet.
|
||||
|
||||
**Tech Stack:** Flutter, Riverpod, shadcn_ui Lucide icons, flutter_markdown.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Design Tokens And Common Widgets
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/core/app_colors.dart`
|
||||
- Modify: `health_app/lib/core/app_theme.dart`
|
||||
- Create: `health_app/lib/core/app_design_tokens.dart`
|
||||
- Create: `health_app/lib/core/app_module_visuals.dart`
|
||||
- Create: `health_app/lib/widgets/app_buttons.dart`
|
||||
- Create: `health_app/lib/widgets/app_status_badge.dart`
|
||||
- Create: `health_app/lib/widgets/app_toast.dart`
|
||||
- Create: `health_app/lib/widgets/ai_content.dart`
|
||||
|
||||
- [ ] Add typography, spacing, radius, shadow, module color, and module icon tokens.
|
||||
- [ ] Add reusable gradient-outline button and module-aware buttons.
|
||||
- [ ] Add top-center toast helper for light feedback.
|
||||
- [ ] Add shared AI Markdown renderer and plain text cleaner.
|
||||
- [ ] Run `flutter analyze`.
|
||||
|
||||
### Task 2: AI Conversation And Cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/pages/home/widgets/chat_messages_view.dart`
|
||||
- Modify: `health_app/lib/pages/home/home_page.dart`
|
||||
|
||||
- [ ] Apply shared AI content renderer to AI bubbles.
|
||||
- [ ] Normalize user bubble, AI bubble, attachments, AI generated note, and Markdown typography.
|
||||
- [ ] Update intelligent agent visuals to use shared module icons and colors, including `dumbbell` for exercise and `pill` for medication.
|
||||
- [ ] Preserve gradient-outline white-background buttons in agent welcome cards.
|
||||
- [ ] Keep today health card gradient outline and ensure empty medication windows still show a light status.
|
||||
- [ ] Run `flutter analyze lib/pages/home/widgets/chat_messages_view.dart lib/pages/home/home_page.dart`.
|
||||
|
||||
### Task 3: Sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/widgets/health_drawer.dart`
|
||||
|
||||
- [ ] Keep current structure: user header, health dashboard, common functions, conversation records.
|
||||
- [ ] Convert user header to no-frame layout with settings on the right.
|
||||
- [ ] Keep health dashboard as a light panel.
|
||||
- [ ] Convert common functions to a two-column light matrix without heavy per-item cards.
|
||||
- [ ] Keep conversation records as lightweight rows with summary left and date right.
|
||||
- [ ] Run `flutter analyze lib/widgets/health_drawer.dart`.
|
||||
|
||||
### Task 4: Core Management Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/widgets/enterprise_widgets.dart`
|
||||
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
|
||||
- Modify: `health_app/lib/pages/medication/medication_checkin_page.dart`
|
||||
- Modify: `health_app/lib/pages/remaining_pages.dart`
|
||||
- Modify: `health_app/lib/pages/report/report_pages.dart`
|
||||
- Modify: `health_app/lib/pages/report/ai_analysis_page.dart`
|
||||
- Modify: `health_app/lib/pages/notifications/notification_center_page.dart`
|
||||
|
||||
- [ ] Update summary panels to avoid repeating page titles and reduce icon emphasis.
|
||||
- [ ] Apply module colors and Lucide icons to medication, exercise, reports, and notifications.
|
||||
- [ ] Apply shared AI content renderer to report AI text to prevent visible raw Markdown markers such as `**`.
|
||||
- [ ] Normalize list title/subtitle/tag sizes and row heights.
|
||||
- [ ] Run targeted `flutter analyze` on modified files.
|
||||
|
||||
### Task 5: Profile, Health Archive, Diet, Device
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/pages/profile/profile_page.dart`
|
||||
- Modify: `health_app/lib/pages/remaining_pages.dart`
|
||||
- Modify: `health_app/lib/pages/diet/diet_capture_page.dart`
|
||||
- Modify: `health_app/lib/pages/device/device_management_page.dart`
|
||||
- Modify: `health_app/lib/widgets/ble_sync_dialog.dart`
|
||||
|
||||
- [ ] Use two-column short-field layout where appropriate in profile/health archive surfaces already in scope.
|
||||
- [ ] Apply module tokens to diet and device surfaces.
|
||||
- [ ] Improve Bluetooth/device panels with shared radius, typography, and module colors.
|
||||
- [ ] Replace obvious bottom SnackBar style feedback in touched flows with top-center toast where low-risk.
|
||||
- [ ] Run targeted `flutter analyze` on modified files.
|
||||
|
||||
### Task 6: Full Verification
|
||||
|
||||
**Files:**
|
||||
- Test: `health_app`
|
||||
|
||||
- [ ] Run `flutter analyze`.
|
||||
- [ ] If available and practical, run a smoke test or build command for the Flutter app.
|
||||
- [ ] Summarize changed files and any deferred surfaces.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Secondary Page Color Refresh Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Execute this plan inline in the current session. Do not create a branch or worktree.
|
||||
|
||||
**Goal:** Produce a restrained first-pass visual refresh for secondary pages while leaving the home page and all business behavior unchanged.
|
||||
|
||||
**Architecture:** Centralize a light neutral secondary-page canvas with a white AppBar in `GradientScaffold`, keep the existing home implementation untouched, and introduce one reusable outlined create FAB for the pages that currently expose add/upload actions. Fix swipe-delete rows at the shared widget layer so medication and exercise rows always paint an opaque white content surface over the red delete action.
|
||||
|
||||
**Tech Stack:** Flutter, Material 3, flutter_test.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Work on the existing `main` branch and preserve all unrelated dirty-worktree changes.
|
||||
- Do not change backend APIs, providers, routing, or page business logic.
|
||||
- Keep the current home page visual design unchanged.
|
||||
- Secondary pages use a `#F7F8FA` canvas with white content surfaces; module colors remain content accents only.
|
||||
- Reuse the existing `AppRadius` and `AppColors.actionOutlineGradient` tokens.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock the shared secondary-page visual behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/test/swipe_delete_tile_test.dart`
|
||||
- Create: `health_app/test/secondary_page_visuals_test.dart`
|
||||
- Modify: `health_app/lib/core/app_theme.dart`
|
||||
- Modify: `health_app/lib/widgets/common_widgets.dart`
|
||||
|
||||
- [ ] Add failing widget tests for an opaque swipe-row surface, a white `GradientScaffold`, and the shared outlined create FAB.
|
||||
- [ ] Run the targeted tests and confirm they fail for the missing visual behavior.
|
||||
- [ ] Make `GradientScaffold` and the global AppBar surface white without changing the home page.
|
||||
- [ ] Add the reusable white, purple-gradient-outline create FAB.
|
||||
- [ ] Paint swipe-row content white so red appears only in the revealed action area.
|
||||
- [ ] Run the targeted tests and confirm they pass.
|
||||
|
||||
### Task 2: Adopt the common create action on active user pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/pages/chart/trend_page.dart`
|
||||
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
|
||||
- Modify: `health_app/lib/pages/exercise/exercise_plan_page.dart`
|
||||
- Modify: `health_app/lib/pages/report/report_pages.dart`
|
||||
|
||||
- [ ] Replace page-specific solid-color FAB styling with the shared create FAB.
|
||||
- [ ] Preserve every existing callback, tooltip, route, and upload sheet.
|
||||
- [ ] Format only the touched Dart files.
|
||||
- [ ] Run the focused widget tests, the full Flutter test suite, and `flutter analyze`.
|
||||
@@ -1,47 +0,0 @@
|
||||
# 通知设置改造设计
|
||||
|
||||
## 目标
|
||||
|
||||
让通知设置展示真实的服务端状态,保存失败时明确提示并恢复原值;将五个健康指标开关收纳到底部选择弹窗中,同时修复通知总开关未真正参与后端投递判断的问题。
|
||||
|
||||
## 页面结构
|
||||
|
||||
主页面保持四个区域:通知总开关、通知类型、健康录入提醒、免打扰时段。
|
||||
|
||||
健康录入提醒区域只展示:
|
||||
|
||||
- “每日健康录入提醒”总开关。
|
||||
- “提醒指标”入口,副标题展示当前已选指标摘要。
|
||||
|
||||
点击“提醒指标”后打开底部弹窗。弹窗使用复选框选择血压、心率、血糖、血氧和体重,至少保留一项,点击“保存”后一次性提交全部指标设置。
|
||||
|
||||
## 状态与保存
|
||||
|
||||
前端使用明确的通知偏好状态对象,不再直接使用无类型的 Map。状态区分首次加载、单项保存、指标批量保存和错误信息。
|
||||
|
||||
- 首次加载成功后才展示设置内容。
|
||||
- 首次加载失败展示错误页和重试按钮,不使用默认值冒充服务端数据。
|
||||
- 普通开关修改时只锁定当前操作,后端成功后采用响应中的完整偏好更新页面。
|
||||
- 保存失败恢复原值并显示错误提示。
|
||||
- 指标弹窗先维护本地草稿,点击保存时一次提交五个字段,避免连续请求和部分成功。
|
||||
- 免打扰时间采用相同的真实保存与失败恢复规则。
|
||||
|
||||
## 后端行为
|
||||
|
||||
保留现有 `/api/notification-prefs` GET 和 PUT 接口。PUT 继续支持部分字段更新并返回保存后的完整偏好。
|
||||
|
||||
通知发送管线必须优先检查 `PushEnabled`。总开关关闭时,不生成或投递任何用户通知;健康录入、用药、随访、医生回复和异常提醒继续读取各自开关,免打扰规则继续生效。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 网络或服务错误显示后端可读信息,无法解析时使用统一提示。
|
||||
- 保存过程中禁止同一设置重复提交。
|
||||
- 指标未选择任何一项时,弹窗内提示至少选择一项,不发送请求。
|
||||
- 后端返回失败时不显示成功状态。
|
||||
|
||||
## 测试
|
||||
|
||||
- Flutter 单元测试覆盖偏好解析、指标摘要、至少选择一项和失败回滚。
|
||||
- Flutter 组件测试覆盖加载失败、指标弹窗批量选择和保存中禁用。
|
||||
- 后端测试覆盖 `PushEnabled=false` 时所有通知类型均不投递,以及各分类开关和免打扰继续生效。
|
||||
- 运行 `flutter analyze`、Flutter 全量测试和相关 .NET 测试。
|
||||
@@ -1,79 +0,0 @@
|
||||
# 健康 App 前端 UI 设计系统
|
||||
|
||||
这份规范用于后续逐页精修。首页是品牌化例外,保持当前紫色背景和已有结构;以下规则主要约束普通二级页面。
|
||||
|
||||
## 1. 页面层级
|
||||
|
||||
- 标题栏:纯白背景,深色标题和返回图标。
|
||||
- 页面画布:`#F7F8FA`,只负责建立内容边界,不作为卡片颜色。
|
||||
- 主要内容组:纯白背景,区域间保留 12–16 px 的画布间距。
|
||||
- 列表行:同一内容组内连续排列,用文字区域浅分隔线区分,不为每一行单独套卡片。
|
||||
- 重点对象:报告、设备、AI 分析和关键概览可以使用独立白色卡片。
|
||||
|
||||
## 2. 颜色角色
|
||||
|
||||
- 品牌紫:通用选中、焦点、品牌按钮描边和少量重点操作。
|
||||
- 模块色:只用于统一样式的图标、极浅图标底、图表曲线、小标签和状态识别。
|
||||
- 中性色:页面画布、分隔线、正文层级和不可用状态。
|
||||
- 语义色:绿色表示成功/正常,黄色表示提醒/等待,红色表示错误/危险,蓝色表示信息/进行中。
|
||||
- 禁止模块色用于整页背景、大面积头部、大卡片底色和普通新增按钮。
|
||||
|
||||
## 3. 间距
|
||||
|
||||
- 页面左右边距:16 px。
|
||||
- 页面顶部内容间距:12 px。
|
||||
- 内容组间距:12–16 px。
|
||||
- 列表图标与文字:12 px。
|
||||
- 列表行垂直内边距:根据内容使用 12 或 16 px,不通过缩小字体制造紧凑感。
|
||||
|
||||
## 4. 圆角与阴影
|
||||
|
||||
- 所有圆角使用 `AppRadius`,不得在页面里随意新增数值。
|
||||
- 连续内容组默认使用 16 px 圆角。
|
||||
- 输入框和小型操作使用 10–14 px 圆角。
|
||||
- 普通内容组默认无阴影、无描边。
|
||||
- 浮动操作可以使用一层轻微中性阴影;同一组件不同时使用明显边框和重阴影。
|
||||
|
||||
## 5. 字体层级
|
||||
|
||||
- 页面标题:19–20 px,粗体,颜色 `textPrimary`。
|
||||
- 区域标题:18 px,粗体。
|
||||
- 列表主标题:16–17 px,中粗体。
|
||||
- 正文和补充说明:13–15 px,保证对比度,不使用过浅小字。
|
||||
- 数值、单位、份量和热量在同一行时保持基线、字号和字重协调。
|
||||
- 选中状态不能改变文字大小、字重或位置。
|
||||
|
||||
## 6. 图标
|
||||
|
||||
- 全 App 优先使用同一套 Material 线性 `outlined/rounded` 图标,不混用大面积实心图标。
|
||||
- 普通列表图标底座统一为 40×40 px、10 px 圆角,图标统一为 22 px。
|
||||
- 不同模块可以改变图标和极浅底色,但尺寸、线条感觉、底座形状和对齐必须一致。
|
||||
- 无底座的小型操作图标统一为 20–24 px,并保证至少 44 px 点击区域。
|
||||
|
||||
## 7. 按钮
|
||||
|
||||
- 通用新增:白底、紫色渐变细描边、深紫色线性加号;所有模块共用同一组件。
|
||||
- 页面主要操作:优先使用白底紫色品牌描边;是否使用实心按钮由逐页任务重要性决定。
|
||||
- 次要操作:白底或极浅灰底、深色文字。
|
||||
- 危险操作:白底红字;只有确认删除阶段显示红色危险面。
|
||||
- 新增、上传、绑定和记录属于通用操作,不跟随模块颜色。
|
||||
|
||||
## 8. 列表与滑动操作
|
||||
|
||||
- 列表行连续排列,分隔线从文字区域开始,不穿过左侧图标。
|
||||
- 整行可点击,附属按钮不能缩小主点击区域。
|
||||
- 删除背景默认完全隐藏,向左打开删除操作后才出现。
|
||||
- 删除失败时保留真实列表数据,不提前移除前端项目。
|
||||
|
||||
## 9. 弹窗、表单和状态
|
||||
|
||||
- 弹窗、底部面板统一白色,不跟随模块色。
|
||||
- 输入框焦点继续使用品牌紫,错误提示使用语义红。
|
||||
- 空状态说明原因并提供一个明确下一步;加载、空、失败和未开放必须能被用户区分。
|
||||
- 暂未启用的问诊和随访显示明确准备状态,不伪装成可用功能。
|
||||
|
||||
## 10. 逐页精修原则
|
||||
|
||||
- 每次只精修一个真实页面或一组完全同构页面。
|
||||
- 先看真机截图,再确定该页的信息层级、内容组、操作和模块色用量。
|
||||
- 不因建立设计系统而批量重排所有页面;公共组件只提供一致基础,页面布局仍按实际任务设计。
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +7,13 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// 加载正式签名配置
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.datalumina.YYA"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -20,21 +29,30 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.datalumina.YYA"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// 正式签名配置
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
storeFile = keystoreProperties.getProperty("storeFile")?.let { path ->
|
||||
file(path)
|
||||
}
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
|
||||
<!-- BLE 蓝牙血压计 -->
|
||||
<!-- BLE 蓝牙健康设备(当前实现血压计同步) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
12
health_app/appinfo
Normal file
12
health_app/appinfo
Normal file
@@ -0,0 +1,12 @@
|
||||
IOS:
|
||||
SHA1:
|
||||
B1:17:1D:FD:7E:CA:CE:61:65:C2:2D:8B:A0:7C:57:5A:EB:EF:20:5E
|
||||
SHA256:
|
||||
D0:1E:AF:24:40:06:42:1B:FA:86:5E:3D:90:ED:FA:D7:3F:2D:E2:99:52:60:58:F4:85:4F:18:95:1E:B9:CC:96
|
||||
MD5:
|
||||
15:3B:89:ED:6B:C2:1B:6A:F9:6D:E0:71:44:54:CB:0F
|
||||
|
||||
Android:
|
||||
SHA1: 48:48:22:D2:DA:B5:0D:4B:3F:D6:CA:8C:77:B1:36:B5:B5:8F:47:D1
|
||||
SHA256: 0F:10:E7:B5:D0:8A:1E:4B:A6:67:EB:D4:D4:7F:00:C1:08:7E:EB:46:72:51:73:B2:AC:30:DB:69:18:DD:15:A2
|
||||
MD5:D9:75:8F:17:66:17:3F:83:49:1A:82:34:59:9A:08:25
|
||||
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 |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user