refactor: 清理 AI 客户端死代码并统一错误处理
- 删除未使用的 OpenAiCompatibleClient,DTO 抽离到 ai_contracts.cs - 新增 AiHttpResponseGuard 统一 LLM/VLM 的 HTTP 错误处理: 错误响应带响应体(截断 2000 字符)、空响应与无效 JSON 显式抛 InvalidDataException - 空 catch 补全精确异常类型(IOException/UnauthorizedAccessException/JsonException) - 修正 Program.cs 数据库初始化注释 - 新增 AI 客户端错误处理单元测试
This commit is contained in:
@@ -40,7 +40,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
|||||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||||
|
|
||||||
using var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct);
|
using var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
response.EnsureSuccessStatusCode();
|
await AiHttpResponseGuard.ThrowIfFailedAsync(response, "DeepSeek", ct);
|
||||||
|
|
||||||
using var stream = await response.Content.ReadAsStreamAsync(ct);
|
using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||||
using var reader = new StreamReader(stream);
|
using var reader = new StreamReader(stream);
|
||||||
@@ -76,11 +76,9 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
|||||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
var response = await _http.PostAsync("chat/completions", content, ct);
|
using var response = await _http.PostAsync("chat/completions", content, ct);
|
||||||
response.EnsureSuccessStatusCode();
|
var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "DeepSeek", ct);
|
||||||
|
return AiHttpResponseGuard.DeserializeRequired<ChatCompletionResponse>(body, _jsonOptions, "DeepSeek");
|
||||||
var body = await response.Content.ReadAsStringAsync(ct);
|
|
||||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,13 +123,45 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
|
|||||||
|
|
||||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
var response = await _http.PostAsync("chat/completions", content, ct);
|
using var response = await _http.PostAsync("chat/completions", content, ct);
|
||||||
if (!response.IsSuccessStatusCode)
|
var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "VLM", ct);
|
||||||
{
|
return AiHttpResponseGuard.DeserializeRequired<ChatCompletionResponse>(body, _jsonOptions, "VLM");
|
||||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
|
||||||
throw new HttpRequestException($"VLM API {response.StatusCode}: {errorBody}");
|
|
||||||
}
|
|
||||||
var body = await response.Content.ReadAsStringAsync(ct);
|
|
||||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static class AiHttpResponseGuard
|
||||||
|
{
|
||||||
|
private const int MaxErrorBodyLength = 2000;
|
||||||
|
|
||||||
|
public static async Task ThrowIfFailedAsync(HttpResponseMessage response, string provider, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (response.IsSuccessStatusCode) return;
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
throw new HttpRequestException($"{provider} API {(int)response.StatusCode} ({response.StatusCode}): {Truncate(body)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> ReadSuccessBodyAsync(HttpResponseMessage response, string provider, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await ThrowIfFailedAsync(response, provider, ct);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
throw new InvalidDataException($"{provider} API返回了空响应");
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T DeserializeRequired<T>(string body, JsonSerializerOptions options, string provider)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T>(body, options)
|
||||||
|
?? throw new InvalidDataException($"{provider} API响应内容为空");
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"{provider} API响应格式无效", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string value) =>
|
||||||
|
value.Length <= MaxErrorBodyLength ? value : value[..MaxErrorBodyLength] + "...";
|
||||||
|
}
|
||||||
|
|||||||
81
backend/src/Health.Infrastructure/AI/ai_contracts.cs
Normal file
81
backend/src/Health.Infrastructure/AI/ai_contracts.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
namespace Health.Infrastructure.AI;
|
||||||
|
|
||||||
|
public sealed class ChatCompletionRequest
|
||||||
|
{
|
||||||
|
public string Model { get; set; } = string.Empty;
|
||||||
|
public List<ChatMessage> Messages { get; set; } = [];
|
||||||
|
public bool Stream { get; set; }
|
||||||
|
public int MaxTokens { get; set; } = 2048;
|
||||||
|
public float Temperature { get; set; } = 0.7f;
|
||||||
|
public float? TopP { get; set; }
|
||||||
|
public List<ToolDefinition>? Tools { get; set; }
|
||||||
|
public string? ToolChoice { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
||||||
|
public bool VlHighResolutionImages { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ChatMessage
|
||||||
|
{
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public object? Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? ToolCallId { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public List<ToolCall>? ToolCalls { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ToolDefinition
|
||||||
|
{
|
||||||
|
public string Type { get; set; } = "function";
|
||||||
|
public ToolFunction Function { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ToolFunction
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public object Parameters { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ToolCall
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Type { get; set; } = "function";
|
||||||
|
public ToolCallFunction Function { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ToolCallFunction
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Arguments { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ChatCompletionResponse
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public List<Choice> Choices { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Choice
|
||||||
|
{
|
||||||
|
public int Index { get; set; }
|
||||||
|
public ResponseMessage? Message { get; set; }
|
||||||
|
public ResponseDelta? Delta { get; set; }
|
||||||
|
public string? FinishReason { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ResponseMessage
|
||||||
|
{
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public string? Content { get; set; }
|
||||||
|
public List<ToolCall>? ToolCalls { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ResponseDelta
|
||||||
|
{
|
||||||
|
public string? Content { get; set; }
|
||||||
|
public string? Role { get; set; }
|
||||||
|
}
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
using System.Net.Http.Headers;
|
|
||||||
|
|
||||||
namespace Health.Infrastructure.AI;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// OpenAI 兼容协议 HTTP 客户端,统一调用 DeepSeek / 千问 VL
|
|
||||||
/// </summary>
|
|
||||||
public sealed class OpenAiCompatibleClient(string baseUrl, string apiKey, string model)
|
|
||||||
{
|
|
||||||
private readonly HttpClient _http = CreateHttpClient(baseUrl, apiKey);
|
|
||||||
private readonly string _model = model;
|
|
||||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
||||||
PropertyNameCaseInsensitive = true
|
|
||||||
};
|
|
||||||
|
|
||||||
private static HttpClient CreateHttpClient(string baseUrl, string apiKey)
|
|
||||||
{
|
|
||||||
var client = new HttpClient
|
|
||||||
{
|
|
||||||
BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"),
|
|
||||||
Timeout = TimeSpan.FromSeconds(60)
|
|
||||||
};
|
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
|
||||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 流式 Chat Completions(SSE)
|
|
||||||
/// </summary>
|
|
||||||
public async IAsyncEnumerable<string> ChatStreamAsync(
|
|
||||||
List<ChatMessage> messages,
|
|
||||||
List<ToolDefinition>? tools = null,
|
|
||||||
int maxTokens = 2048,
|
|
||||||
float temperature = 0.7f)
|
|
||||||
{
|
|
||||||
var request = new ChatCompletionRequest
|
|
||||||
{
|
|
||||||
Model = _model,
|
|
||||||
Messages = messages,
|
|
||||||
Stream = true,
|
|
||||||
MaxTokens = maxTokens,
|
|
||||||
Temperature = temperature,
|
|
||||||
Tools = tools,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tools?.Count > 0)
|
|
||||||
request.ToolChoice = "auto";
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
||||||
|
|
||||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
|
|
||||||
{
|
|
||||||
Content = content
|
|
||||||
};
|
|
||||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
|
||||||
|
|
||||||
var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
using var stream = await response.Content.ReadAsStreamAsync();
|
|
||||||
using var reader = new StreamReader(stream);
|
|
||||||
|
|
||||||
string? line;
|
|
||||||
while ((line = await reader.ReadLineAsync()) != null)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
||||||
if (!line.StartsWith("data: ")) continue;
|
|
||||||
|
|
||||||
var data = line["data: ".Length..];
|
|
||||||
if (data == "[DONE]") break;
|
|
||||||
|
|
||||||
yield return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 非流式 Chat Completions
|
|
||||||
/// </summary>
|
|
||||||
public async Task<ChatCompletionResponse> ChatAsync(
|
|
||||||
List<ChatMessage> messages,
|
|
||||||
List<ToolDefinition>? tools = null,
|
|
||||||
int maxTokens = 2048,
|
|
||||||
float temperature = 0.7f)
|
|
||||||
{
|
|
||||||
var request = new ChatCompletionRequest
|
|
||||||
{
|
|
||||||
Model = _model,
|
|
||||||
Messages = messages,
|
|
||||||
Stream = false,
|
|
||||||
MaxTokens = maxTokens,
|
|
||||||
Temperature = temperature,
|
|
||||||
Tools = tools,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tools?.Count > 0)
|
|
||||||
request.ToolChoice = "auto";
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
||||||
|
|
||||||
var response = await _http.PostAsync("chat/completions", content);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var body = await response.Content.ReadAsStringAsync();
|
|
||||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Vision 图片理解(非流式)
|
|
||||||
/// </summary>
|
|
||||||
public async Task<ChatCompletionResponse> VisionAsync(
|
|
||||||
string systemPrompt,
|
|
||||||
List<string> imageUrls,
|
|
||||||
string? userText = null,
|
|
||||||
int maxTokens = 2048)
|
|
||||||
{
|
|
||||||
var messages = new List<ChatMessage>();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(systemPrompt))
|
|
||||||
messages.Add(new ChatMessage { Role = "system", Content = systemPrompt });
|
|
||||||
|
|
||||||
// 构建多模态消息内容
|
|
||||||
var contentParts = new List<object>();
|
|
||||||
foreach (var url in imageUrls)
|
|
||||||
contentParts.Add(new { type = "image_url", image_url = new { url } });
|
|
||||||
if (!string.IsNullOrEmpty(userText))
|
|
||||||
contentParts.Add(new { type = "text", text = userText });
|
|
||||||
|
|
||||||
var userMessage = new ChatMessage
|
|
||||||
{
|
|
||||||
Role = "user",
|
|
||||||
Content = contentParts // 数组格式,让 JSON 序列化时保持为数组而非字符串
|
|
||||||
};
|
|
||||||
|
|
||||||
messages.Add(userMessage);
|
|
||||||
|
|
||||||
var request = new ChatCompletionRequest
|
|
||||||
{
|
|
||||||
Model = _model,
|
|
||||||
Messages = messages,
|
|
||||||
MaxTokens = maxTokens,
|
|
||||||
Stream = false,
|
|
||||||
};
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
||||||
var response = await _http.PostAsync("chat/completions", content);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
var body = await response.Content.ReadAsStringAsync();
|
|
||||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#region 请求/响应模型
|
|
||||||
|
|
||||||
public sealed class ChatCompletionRequest
|
|
||||||
{
|
|
||||||
public string Model { get; set; } = string.Empty;
|
|
||||||
public List<ChatMessage> Messages { get; set; } = [];
|
|
||||||
public bool Stream { get; set; }
|
|
||||||
public int MaxTokens { get; set; } = 2048;
|
|
||||||
public float Temperature { get; set; } = 0.7f;
|
|
||||||
public float? TopP { get; set; }
|
|
||||||
public List<ToolDefinition>? Tools { get; set; }
|
|
||||||
public string? ToolChoice { get; set; }
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
|
||||||
public bool VlHighResolutionImages { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ChatMessage
|
|
||||||
{
|
|
||||||
public string Role { get; set; } = string.Empty;
|
|
||||||
public object? Content { get; set; } = string.Empty;
|
|
||||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
|
||||||
public string? ToolCallId { get; set; }
|
|
||||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
|
||||||
public List<ToolCall>? ToolCalls { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ToolDefinition
|
|
||||||
{
|
|
||||||
public string Type { get; set; } = "function";
|
|
||||||
public ToolFunction Function { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ToolFunction
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
public object Parameters { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ToolCall
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
public string Type { get; set; } = "function";
|
|
||||||
public ToolCallFunction Function { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ToolCallFunction
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string Arguments { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ChatCompletionResponse
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
public List<Choice> Choices { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class Choice
|
|
||||||
{
|
|
||||||
public int Index { get; set; }
|
|
||||||
public ResponseMessage? Message { get; set; }
|
|
||||||
public ResponseDelta? Delta { get; set; }
|
|
||||||
public string? FinishReason { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ResponseMessage
|
|
||||||
{
|
|
||||||
public string Role { get; set; } = string.Empty;
|
|
||||||
public string? Content { get; set; }
|
|
||||||
public List<ToolCall>? ToolCalls { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ResponseDelta
|
|
||||||
{
|
|
||||||
public string? Content { get; set; }
|
|
||||||
public string? Role { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
@@ -50,7 +50,8 @@ public sealed class DietImageAnalysisCoordinator(IDietImageAnalysisQueue queue)
|
|||||||
foreach (var path in paths)
|
foreach (var path in paths)
|
||||||
{
|
{
|
||||||
try { if (File.Exists(path)) File.Delete(path); }
|
try { if (File.Exists(path)) File.Delete(path); }
|
||||||
catch { }
|
catch (IOException) { }
|
||||||
|
catch (UnauthorizedAccessException) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw;
|
throw;
|
||||||
|
|||||||
@@ -160,6 +160,6 @@ public sealed class ReportAnalysisService(
|
|||||||
|
|
||||||
content = content.Trim();
|
content = content.Trim();
|
||||||
try { return JsonDocument.Parse(content).RootElement; }
|
try { return JsonDocument.Parse(content).RootElement; }
|
||||||
catch { return null; }
|
catch (JsonException) { return null; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ public sealed class DietImageAnalysisWorker(
|
|||||||
foreach (var path in paths)
|
foreach (var path in paths)
|
||||||
{
|
{
|
||||||
try { if (File.Exists(path)) File.Delete(path); }
|
try { if (File.Exists(path)) File.Delete(path); }
|
||||||
catch { }
|
catch (IOException) { }
|
||||||
|
catch (UnauthorizedAccessException) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ public static class AiChatEndpoints
|
|||||||
var json = JsonSerializer.Serialize(toolResult, JsonOpts);
|
var json = JsonSerializer.Serialize(toolResult, JsonOpts);
|
||||||
resultDict = JsonSerializer.Deserialize<Dictionary<string, object>>(json, JsonOpts);
|
resultDict = JsonSerializer.Deserialize<Dictionary<string, object>>(json, JsonOpts);
|
||||||
}
|
}
|
||||||
catch { resultDict = new Dictionary<string, object>(); }
|
catch (JsonException) { resultDict = new Dictionary<string, object>(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
var isPendingConfirmation = resultDict != null
|
var isPendingConfirmation = resultDict != null
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ app.UseStaticFiles(new StaticFileOptions
|
|||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|
||||||
// ---- 初始化数据库(开发环境:每次重建)----
|
// ---- 初始化数据库:创建空库,并用版本化补丁更新已有本地结构 ----
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|||||||
50
backend/tests/Health.Tests/ai_client_error_tests.cs
Normal file
50
backend/tests/Health.Tests/ai_client_error_tests.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Health.Infrastructure.AI;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class AiClientErrorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task DeepSeek_ErrorResponse_IncludesStatusAndBody()
|
||||||
|
{
|
||||||
|
var client = CreateClient(HttpStatusCode.TooManyRequests, "{\"error\":\"rate limit\"}");
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<HttpRequestException>(() => client.ChatAsync([]));
|
||||||
|
|
||||||
|
Assert.Contains("429", error.Message);
|
||||||
|
Assert.Contains("rate limit", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeepSeek_NullJsonResponse_ThrowsClearDataError()
|
||||||
|
{
|
||||||
|
var client = CreateClient(HttpStatusCode.OK, "null");
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidDataException>(() => client.ChatAsync([]));
|
||||||
|
|
||||||
|
Assert.Contains("响应内容为空", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeepSeekClient CreateClient(HttpStatusCode statusCode, string body)
|
||||||
|
{
|
||||||
|
var http = new HttpClient(new StubHandler(statusCode, body))
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://example.test/"),
|
||||||
|
};
|
||||||
|
var config = new ConfigurationBuilder().AddInMemoryCollection(
|
||||||
|
new Dictionary<string, string?> { ["DEEPSEEK_MODEL"] = "test-model" }).Build();
|
||||||
|
return new DeepSeekClient(http, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHandler(HttpStatusCode statusCode, string body) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(new HttpResponseMessage(statusCode)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body),
|
||||||
|
RequestMessage = request,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user