From aa44d6f0f0529615b28b746003d0c5597d833155 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Sat, 20 Jun 2026 22:06:31 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=B8=85=E7=90=86=20AI=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=AD=BB=E4=BB=A3=E7=A0=81=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除未使用的 OpenAiCompatibleClient,DTO 抽离到 ai_contracts.cs - 新增 AiHttpResponseGuard 统一 LLM/VLM 的 HTTP 错误处理: 错误响应带响应体(截断 2000 字符)、空响应与无效 JSON 显式抛 InvalidDataException - 空 catch 补全精确异常类型(IOException/UnauthorizedAccessException/JsonException) - 修正 Program.cs 数据库初始化注释 - 新增 AI 客户端错误处理单元测试 --- .../Health.Infrastructure/AI/ai_clients.cs | 58 +++-- .../Health.Infrastructure/AI/ai_contracts.cs | 81 ++++++ .../AI/open_ai_compatible_client.cs | 237 ------------------ .../Diets/DietImageAnalysisCoordinator.cs | 3 +- .../Reports/ReportAnalysisService.cs | 2 +- .../diet_image_analysis_worker.cs | 3 +- .../Endpoints/ai_chat_endpoints.cs | 2 +- backend/src/Health.WebApi/Program.cs | 2 +- .../Health.Tests/ai_client_error_tests.cs | 50 ++++ 9 files changed, 182 insertions(+), 256 deletions(-) create mode 100644 backend/src/Health.Infrastructure/AI/ai_contracts.cs delete mode 100644 backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs create mode 100644 backend/tests/Health.Tests/ai_client_error_tests.cs diff --git a/backend/src/Health.Infrastructure/AI/ai_clients.cs b/backend/src/Health.Infrastructure/AI/ai_clients.cs index 0acabc9..72a808a 100644 --- a/backend/src/Health.Infrastructure/AI/ai_clients.cs +++ b/backend/src/Health.Infrastructure/AI/ai_clients.cs @@ -40,7 +40,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config) httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); 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 reader = new StreamReader(stream); @@ -76,11 +76,9 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config) var json = JsonSerializer.Serialize(request, _jsonOptions); var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await _http.PostAsync("chat/completions", content, ct); - response.EnsureSuccessStatusCode(); - - var body = await response.Content.ReadAsStringAsync(ct); - return JsonSerializer.Deserialize(body, _jsonOptions)!; + using var response = await _http.PostAsync("chat/completions", content, ct); + var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "DeepSeek", ct); + return AiHttpResponseGuard.DeserializeRequired(body, _jsonOptions, "DeepSeek"); } } @@ -125,13 +123,45 @@ public sealed class VisionClient(HttpClient http, IConfiguration config) var json = JsonSerializer.Serialize(request, _jsonOptions); var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await _http.PostAsync("chat/completions", content, ct); - if (!response.IsSuccessStatusCode) - { - 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(body, _jsonOptions)!; + using var response = await _http.PostAsync("chat/completions", content, ct); + var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "VLM", ct); + return AiHttpResponseGuard.DeserializeRequired(body, _jsonOptions, "VLM"); } } + +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 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(string body, JsonSerializerOptions options, string provider) + { + try + { + return JsonSerializer.Deserialize(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] + "..."; +} diff --git a/backend/src/Health.Infrastructure/AI/ai_contracts.cs b/backend/src/Health.Infrastructure/AI/ai_contracts.cs new file mode 100644 index 0000000..15de093 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/ai_contracts.cs @@ -0,0 +1,81 @@ +namespace Health.Infrastructure.AI; + +public sealed class ChatCompletionRequest +{ + public string Model { get; set; } = string.Empty; + public List 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? 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? 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 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? ToolCalls { get; set; } +} + +public sealed class ResponseDelta +{ + public string? Content { get; set; } + public string? Role { get; set; } +} diff --git a/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs b/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs deleted file mode 100644 index 2f80299..0000000 --- a/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs +++ /dev/null @@ -1,237 +0,0 @@ -using System.Net.Http.Headers; - -namespace Health.Infrastructure.AI; - -/// -/// OpenAI 兼容协议 HTTP 客户端,统一调用 DeepSeek / 千问 VL -/// -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; - } - - /// - /// 流式 Chat Completions(SSE) - /// - public async IAsyncEnumerable ChatStreamAsync( - List messages, - List? 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; - } - } - - /// - /// 非流式 Chat Completions - /// - public async Task ChatAsync( - List messages, - List? 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(body, _jsonOptions)!; - } - - /// - /// Vision 图片理解(非流式) - /// - public async Task VisionAsync( - string systemPrompt, - List imageUrls, - string? userText = null, - int maxTokens = 2048) - { - var messages = new List(); - - if (!string.IsNullOrEmpty(systemPrompt)) - messages.Add(new ChatMessage { Role = "system", Content = systemPrompt }); - - // 构建多模态消息内容 - var contentParts = new List(); - 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(body, _jsonOptions)!; - } -} - -#region 请求/响应模型 - -public sealed class ChatCompletionRequest -{ - public string Model { get; set; } = string.Empty; - public List 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? 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? 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 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? ToolCalls { get; set; } -} - -public sealed class ResponseDelta -{ - public string? Content { get; set; } - public string? Role { get; set; } -} - -#endregion diff --git a/backend/src/Health.Infrastructure/Diets/DietImageAnalysisCoordinator.cs b/backend/src/Health.Infrastructure/Diets/DietImageAnalysisCoordinator.cs index 287777a..a0f330a 100644 --- a/backend/src/Health.Infrastructure/Diets/DietImageAnalysisCoordinator.cs +++ b/backend/src/Health.Infrastructure/Diets/DietImageAnalysisCoordinator.cs @@ -50,7 +50,8 @@ public sealed class DietImageAnalysisCoordinator(IDietImageAnalysisQueue queue) foreach (var path in paths) { try { if (File.Exists(path)) File.Delete(path); } - catch { } + catch (IOException) { } + catch (UnauthorizedAccessException) { } } } throw; diff --git a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs index 3c01154..6b4f049 100644 --- a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs +++ b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs @@ -160,6 +160,6 @@ public sealed class ReportAnalysisService( content = content.Trim(); try { return JsonDocument.Parse(content).RootElement; } - catch { return null; } + catch (JsonException) { return null; } } } diff --git a/backend/src/Health.WebApi/BackgroundServices/diet_image_analysis_worker.cs b/backend/src/Health.WebApi/BackgroundServices/diet_image_analysis_worker.cs index fcdd035..39ed24c 100644 --- a/backend/src/Health.WebApi/BackgroundServices/diet_image_analysis_worker.cs +++ b/backend/src/Health.WebApi/BackgroundServices/diet_image_analysis_worker.cs @@ -67,7 +67,8 @@ public sealed class DietImageAnalysisWorker( foreach (var path in paths) { try { if (File.Exists(path)) File.Delete(path); } - catch { } + catch (IOException) { } + catch (UnauthorizedAccessException) { } } } } diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 8e852c4..cbe5082 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -521,7 +521,7 @@ public static class AiChatEndpoints var json = JsonSerializer.Serialize(toolResult, JsonOpts); resultDict = JsonSerializer.Deserialize>(json, JsonOpts); } - catch { resultDict = new Dictionary(); } + catch (JsonException) { resultDict = new Dictionary(); } } var isPendingConfirmation = resultDict != null diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index 87a498e..2284bf9 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -202,7 +202,7 @@ app.UseStaticFiles(new StaticFileOptions if (app.Environment.IsDevelopment()) app.MapOpenApi(); -// ---- 初始化数据库(开发环境:每次重建)---- +// ---- 初始化数据库:创建空库,并用版本化补丁更新已有本地结构 ---- using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); diff --git a/backend/tests/Health.Tests/ai_client_error_tests.cs b/backend/tests/Health.Tests/ai_client_error_tests.cs new file mode 100644 index 0000000..a38e6ca --- /dev/null +++ b/backend/tests/Health.Tests/ai_client_error_tests.cs @@ -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(() => 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(() => 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 { ["DEEPSEEK_MODEL"] = "test-model" }).Build(); + return new DeepSeekClient(http, config); + } + + private sealed class StubHandler(HttpStatusCode statusCode, string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(body), + RequestMessage = request, + }); + } +}