feat: 引入 EF Core Migrations + 后台任务/校验修复 + 前端四态
后端 - 改用 EF Core Migrations(InitialCreate),Program.cs 用 MigrateAsync + AUTO_MIGRATE 开关 + 显式 MigrationsAssembly;移除 EnsureCreated、删除手写 DatabaseSchemaMigrator - 修复后台任务永久卡 Processing:三个队列对超时且达上限的任务原子标记 Failed - 修复报告重试失效:基础设施异常向上抛激活 RetryAsync,停机取消不计失败 - 修复 AI 用药天数 off-by-one(结束日为包含式) - AI 报告日志不再记录 VLM 健康正文 - 新增统一输入校验(ValidationException + 中间件映射 400):血压/心率/血糖/血氧/体重范围、药名/日期/服药时间去重、饮食热量与评分、运动时长上限 - 删除健康数据 AI 录入的影子直写路径,统一走 Service 校验 前端 - 新增 AppErrorState / AppFutureView,用药列表、服药打卡、运动计划、随访列表改为 加载/失败/空/数据 四态,失败可重试 瘦身 - 删除过时的 DataSeeder / DevDataSeeder 及调用 - 删除依赖实时服务的 ai_agent_tests 集成测试
This commit is contained in:
@@ -1,287 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.AI;
|
||||
using Health.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// AI 智能体集成测试 — 模拟真实用户对话,验证 Tool Calling 与数据库写入
|
||||
/// 运行前需确保后端已启动: dotnet run --project src/Health.WebApi
|
||||
/// </summary>
|
||||
public class AiAgentTests
|
||||
{
|
||||
private static readonly HttpClient Http = new()
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:5000"),
|
||||
Timeout = TimeSpan.FromSeconds(120)
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
// ==================== PromptManager 单元测试 ====================
|
||||
|
||||
[Fact]
|
||||
public void PromptManager_Default_Should_Contain_HeartKeywords()
|
||||
{
|
||||
var pm = new PromptManager();
|
||||
var prompt = pm.GetSystemPrompt(AgentType.Default);
|
||||
Assert.Contains("心脏", prompt);
|
||||
Assert.Contains("健康", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptManager_Consultation_Should_Contain_TriageRules()
|
||||
{
|
||||
var pm = new PromptManager();
|
||||
var prompt = pm.GetSystemPrompt(AgentType.Consultation);
|
||||
Assert.Contains("剧烈胸痛", prompt);
|
||||
Assert.Contains("呼吸困难", prompt);
|
||||
Assert.Contains("160/100", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptManager_Health_Should_Contain_NormalRanges()
|
||||
{
|
||||
var pm = new PromptManager();
|
||||
var prompt = pm.GetSystemPrompt(AgentType.Health);
|
||||
Assert.Contains("139", prompt); // 收缩压上界
|
||||
Assert.Contains("89", prompt); // 舒张压下界
|
||||
Assert.Contains("100", prompt); // 心率上界
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptManager_Diet_Should_Contain_VlmKeywords()
|
||||
{
|
||||
var pm = new PromptManager();
|
||||
var prompt = pm.GetSystemPrompt(AgentType.Diet);
|
||||
Assert.Contains("VLM", prompt);
|
||||
Assert.Contains("能不能吃", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptManager_Medication_Should_Contain_ParseRules()
|
||||
{
|
||||
var pm = new PromptManager();
|
||||
var prompt = pm.GetSystemPrompt(AgentType.Medication);
|
||||
Assert.Contains("药名", prompt);
|
||||
Assert.Contains("剂量", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptManager_Report_Should_Contain_Disclaimer()
|
||||
{
|
||||
var pm = new PromptManager();
|
||||
var prompt = pm.GetSystemPrompt(AgentType.Report);
|
||||
Assert.Contains("AI预解读", prompt);
|
||||
Assert.Contains("医生确认", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptManager_Exercise_Should_Contain_RehabKeywords()
|
||||
{
|
||||
var pm = new PromptManager();
|
||||
var prompt = pm.GetSystemPrompt(AgentType.Exercise);
|
||||
Assert.Contains("心脏康复", prompt);
|
||||
Assert.Contains("循序渐进", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptManager_All_Agents_Should_Return_NonEmpty()
|
||||
{
|
||||
var pm = new PromptManager();
|
||||
foreach (AgentType agent in Enum.GetValues<AgentType>())
|
||||
{
|
||||
var prompt = pm.GetSystemPrompt(agent);
|
||||
Assert.False(string.IsNullOrWhiteSpace(prompt), $"{agent} 的 SystemPrompt 为空");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DeepSeekClient 连通性测试 ====================
|
||||
|
||||
[Fact]
|
||||
public void DeepSeekClient_ApiKey_Should_Be_Configured()
|
||||
{
|
||||
// 仅验证 API Key 已配置(实际调用通过集成测试验证)
|
||||
CreateDeepSeekClient();
|
||||
var config = new ConfigurationBuilder().AddEnvironmentVariables().Build();
|
||||
var apiKey = config["DEEPSEEK_API_KEY"] ?? "";
|
||||
Assert.False(string.IsNullOrEmpty(apiKey) || apiKey.StartsWith("sk-your-key"),
|
||||
"DEEPSEEK_API_KEY 未配置或为占位符,请在 backend/.env 中设置真实 Key");
|
||||
}
|
||||
|
||||
// ==================== AI 对话 + Tool Calling 集成测试 ====================
|
||||
|
||||
[Fact]
|
||||
public async Task HealthAgent_RecordBloodPressure_Should_SaveToDb()
|
||||
{
|
||||
// 先登录获取 token
|
||||
var token = await LoginAsync("13800000001");
|
||||
|
||||
// 发送对话消息触发 Tool Calling
|
||||
var events = await SendChatMessage(token, "health", "我刚刚测了血压,138/86");
|
||||
var toolResults = events.Where(e => e.Action == "tool_result").ToList();
|
||||
|
||||
Assert.NotEmpty(toolResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthAgent_RecordHeartRate_Should_SaveToDb()
|
||||
{
|
||||
var token = await LoginAsync("13800000001");
|
||||
var events = await SendChatMessage(token, "health", "心率72");
|
||||
var toolResults = events.Where(e => e.Action == "tool_result").ToList();
|
||||
|
||||
Assert.NotEmpty(toolResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MedicationAgent_Query_Should_Return_Medications()
|
||||
{
|
||||
var token = await LoginAsync("13800000001");
|
||||
var events = await SendChatMessage(token, "medication", "我现在在吃什么药?");
|
||||
|
||||
var answers = events.Where(e => e.Action == "answer")
|
||||
.Select(e => e.Data?.ToString() ?? "");
|
||||
|
||||
var fullResponse = string.Join("", answers);
|
||||
Assert.NotEmpty(fullResponse);
|
||||
// 应该提到阿司匹林或阿托伐他汀
|
||||
Assert.True(fullResponse.Contains("阿司匹林") || fullResponse.Contains("阿托伐他汀") ||
|
||||
fullResponse.Contains("Aspirin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsultationAgent_SymptomCheck_Should_AskFollowUp()
|
||||
{
|
||||
var token = await LoginAsync("13800000001");
|
||||
var events = await SendChatMessage(token, "consultation", "最近胸口有点不舒服");
|
||||
|
||||
var answers = events.Where(e => e.Action == "answer")
|
||||
.Select(e => e.Data?.ToString() ?? "");
|
||||
|
||||
var fullResponse = string.Join("", answers);
|
||||
Assert.NotEmpty(fullResponse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultAgent_GeneralQuestion_Should_Respond()
|
||||
{
|
||||
var token = await LoginAsync("13800000001");
|
||||
var events = await SendChatMessage(token, "default", "你好,介绍一下你自己");
|
||||
|
||||
var answers = events.Where(e => e.Action == "answer")
|
||||
.Select(e => e.Data?.ToString() ?? "");
|
||||
|
||||
var fullResponse = string.Join("", answers);
|
||||
Assert.NotEmpty(fullResponse);
|
||||
Assert.True(fullResponse.Length > 10, "默认 Agent 应返回有效回复");
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
/// <summary>
|
||||
/// 发送验证码 + 登录,返回 accessToken
|
||||
/// </summary>
|
||||
private static async Task<string> LoginAsync(string phone)
|
||||
{
|
||||
// 发送验证码
|
||||
var smsPayload = JsonSerializer.Serialize(new { phone }, JsonOpts);
|
||||
var smsResp = await Http.PostAsync("/api/auth/send-sms",
|
||||
new StringContent(smsPayload, Encoding.UTF8, "application/json"));
|
||||
var smsJson = JsonDocument.Parse(await smsResp.Content.ReadAsStringAsync());
|
||||
var devCode = smsJson.RootElement.GetProperty("data").GetProperty("devCode").GetString()!;
|
||||
|
||||
// 登录
|
||||
var loginPayload = JsonSerializer.Serialize(new { phone, smsCode = devCode }, JsonOpts);
|
||||
var loginResp = await Http.PostAsync("/api/auth/login",
|
||||
new StringContent(loginPayload, Encoding.UTF8, "application/json"));
|
||||
var loginJson = JsonDocument.Parse(await loginResp.Content.ReadAsStringAsync());
|
||||
|
||||
return loginJson.RootElement.GetProperty("data").GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向指定 Agent 发送消息,返回所有 SSE 事件
|
||||
/// </summary>
|
||||
private static async Task<List<SseEvent>> SendChatMessage(string token, string agentType, string message)
|
||||
{
|
||||
var url = $"/api/ai/{agentType}/chat?message={Uri.EscapeDataString(message)}&token={Uri.EscapeDataString(token)}";
|
||||
|
||||
Http.DefaultRequestHeaders.Authorization = null; // token 走 query string
|
||||
var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var events = new List<SseEvent>();
|
||||
using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
if (!line.StartsWith("data: ")) continue;
|
||||
|
||||
var data = line["data: ".Length..];
|
||||
if (data == "[DONE]") break;
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<SseEvent>(data, JsonOpts);
|
||||
if (parsed != null) events.Add(parsed);
|
||||
}
|
||||
catch { /* 跳过无法解析的 chunk */ }
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建 DeepSeekClient(读取 .env 配置)
|
||||
/// </summary>
|
||||
private static DeepSeekClient CreateDeepSeekClient()
|
||||
{
|
||||
// 从测试输出目录向上 5 级找到 backend/.env
|
||||
// bin/Debug/net10.0 → Health.Tests → tests → backend
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var envPath = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", ".env"));
|
||||
|
||||
if (File.Exists(envPath))
|
||||
{
|
||||
foreach (var envLine in File.ReadAllLines(envPath))
|
||||
{
|
||||
var trimmed = envLine.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue;
|
||||
var eqIdx = trimmed.IndexOf('=');
|
||||
if (eqIdx <= 0) continue;
|
||||
var key = trimmed[..eqIdx].Trim();
|
||||
var value = trimmed[(eqIdx + 1)..].Trim();
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
var config = new ConfigurationBuilder().AddEnvironmentVariables().Build();
|
||||
var apiKey = config["DEEPSEEK_API_KEY"] ?? "";
|
||||
var baseUrl = (config["DEEPSEEK_BASE_URL"] ?? "https://api.deepseek.com/v1").TrimEnd('/') + "/";
|
||||
var httpClient = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(120) };
|
||||
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
return new DeepSeekClient(httpClient, config);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>SSE 事件模型</summary>
|
||||
public class SseEvent
|
||||
{
|
||||
public string? Action { get; set; }
|
||||
public object? Data { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user