From 60a2b1dfcd5a152bb1a2b8917f3e28f6e25e6403 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Thu, 16 Jul 2026 11:07:46 +0800 Subject: [PATCH] feat: add Qwen JSON chat client --- pom.xml | 7 + .../ai/DashScopeQwenChatClient.java | 120 ++++++++++++++++++ .../linkCommerce/ai/QwenChatClient.java | 6 + .../config/ai/QwenProperties.java | 46 +++++++ src/main/resources/application.yml | 7 + .../ai/DashScopeQwenChatClientTest.java | 94 ++++++++++++++ 6 files changed, 280 insertions(+) create mode 100644 src/main/java/com/sztzjy/linkCommerce/ai/DashScopeQwenChatClient.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/ai/QwenChatClient.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/config/ai/QwenProperties.java create mode 100644 src/test/java/com/sztzjy/linkCommerce/ai/DashScopeQwenChatClientTest.java diff --git a/pom.xml b/pom.xml index c25cfae..2f3e74c 100644 --- a/pom.xml +++ b/pom.xml @@ -322,6 +322,13 @@ + + com.squareup.okhttp3 + mockwebserver + 4.9.3 + test + + diff --git a/src/main/java/com/sztzjy/linkCommerce/ai/DashScopeQwenChatClient.java b/src/main/java/com/sztzjy/linkCommerce/ai/DashScopeQwenChatClient.java new file mode 100644 index 0000000..b90c9ce --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/ai/DashScopeQwenChatClient.java @@ -0,0 +1,120 @@ +package com.sztzjy.linkCommerce.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sztzjy.linkCommerce.config.ai.QwenProperties; +import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +@Component +public class DashScopeQwenChatClient implements QwenChatClient { + + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private final QwenProperties properties; + private final ObjectMapper objectMapper; + + public DashScopeQwenChatClient(QwenProperties properties) { + this(properties, new ObjectMapper()); + } + + DashScopeQwenChatClient(QwenProperties properties, ObjectMapper objectMapper) { + this.properties = properties; + this.objectMapper = objectMapper; + } + + @Override + public String completeJson(String systemPrompt, String userPrompt) { + validateConfiguration(); + Request request = new Request.Builder() + .url(endpoint()) + .header("Authorization", "Bearer " + properties.getApiKey().trim()) + .header("Content-Type", "application/json") + .post(RequestBody.create(requestJson(systemPrompt, userPrompt), JSON)) + .build(); + + try (Response response = httpClient().newCall(request).execute()) { + if (!response.isSuccessful()) { + throw unavailable("Qwen request failed with HTTP " + response.code()); + } + return contentFrom(response); + } catch (ServiceException e) { + throw e; + } catch (IOException | RuntimeException e) { + throw unavailable("Qwen request could not be completed"); + } + } + + private void validateConfiguration() { + if (isBlank(properties.getApiKey())) { + throw unavailable("Qwen API key is not configured"); + } + if (isBlank(properties.getBaseUrl()) || isBlank(properties.getModel())) { + throw unavailable("Qwen client configuration is incomplete"); + } + } + + private String endpoint() { + return properties.getBaseUrl().trim().replaceAll("/+$", "") + "/chat/completions"; + } + + private OkHttpClient httpClient() { + long timeout = properties.getTimeoutMillis() > 0 ? properties.getTimeoutMillis() : 60_000L; + return new OkHttpClient.Builder() + .connectTimeout(timeout, TimeUnit.MILLISECONDS) + .readTimeout(timeout, TimeUnit.MILLISECONDS) + .writeTimeout(timeout, TimeUnit.MILLISECONDS) + .callTimeout(timeout, TimeUnit.MILLISECONDS) + .build(); + } + + private String requestJson(String systemPrompt, String userPrompt) { + ObjectNode request = objectMapper.createObjectNode(); + request.put("model", properties.getModel().trim()); + request.put("temperature", 0.2D); + request.putObject("response_format").put("type", "json_object"); + ArrayNode messages = request.putArray("messages"); + messages.addObject().put("role", "system").put("content", systemPrompt); + messages.addObject().put("role", "user").put("content", userPrompt); + try { + return objectMapper.writeValueAsString(request); + } catch (IOException e) { + throw unavailable("Qwen request could not be prepared"); + } + } + + private String contentFrom(Response response) throws IOException { + if (response.body() == null) { + throw unavailable("Qwen returned an invalid response"); + } + JsonNode root; + try { + root = objectMapper.readTree(response.body().string()); + } catch (IOException | RuntimeException e) { + throw unavailable("Qwen returned an invalid response"); + } + JsonNode content = root.path("choices").path(0).path("message").path("content"); + if (!content.isTextual() || isBlank(content.asText())) { + throw unavailable("Qwen returned an invalid response"); + } + return content.asText(); + } + + private ServiceException unavailable(String message) { + return new ServiceException(HttpStatus.BAD_GATEWAY, message); + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/ai/QwenChatClient.java b/src/main/java/com/sztzjy/linkCommerce/ai/QwenChatClient.java new file mode 100644 index 0000000..730863d --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/ai/QwenChatClient.java @@ -0,0 +1,6 @@ +package com.sztzjy.linkCommerce.ai; + +public interface QwenChatClient { + + String completeJson(String systemPrompt, String userPrompt); +} diff --git a/src/main/java/com/sztzjy/linkCommerce/config/ai/QwenProperties.java b/src/main/java/com/sztzjy/linkCommerce/config/ai/QwenProperties.java new file mode 100644 index 0000000..e71d13a --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/config/ai/QwenProperties.java @@ -0,0 +1,46 @@ +package com.sztzjy.linkCommerce.config.ai; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "ai.qwen") +public class QwenProperties { + + private String baseUrl; + private String apiKey; + private String model; + private long timeoutMillis; + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public long getTimeoutMillis() { + return timeoutMillis; + } + + public void setTimeoutMillis(long timeoutMillis) { + this.timeoutMillis = timeoutMillis; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f07232d..a67da22 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -100,6 +100,13 @@ swagger: contactAddress: www.sztzjy.com version: @project.version@ +ai: + qwen: + base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 + api-key: ${DASHSCOPE_API_KEY:} + model: qwen-plus + timeout-millis: 60000 + ssl: # 支持https enabled: true diff --git a/src/test/java/com/sztzjy/linkCommerce/ai/DashScopeQwenChatClientTest.java b/src/test/java/com/sztzjy/linkCommerce/ai/DashScopeQwenChatClientTest.java new file mode 100644 index 0000000..d06970b --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/ai/DashScopeQwenChatClientTest.java @@ -0,0 +1,94 @@ +package com.sztzjy.linkCommerce.ai; + +import com.sztzjy.linkCommerce.config.ai.QwenProperties; +import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DashScopeQwenChatClientTest { + + private final MockWebServer server = new MockWebServer(); + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + @Test + void sendsCompatibleJsonChatRequestAndReturnsMessageContent() throws Exception { + server.start(); + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"choices\":[{\"message\":{\"content\":\"{\\\"score\\\":86}\"}}]}")); + + String content = client("test-key").completeJson("system instructions", "student answer"); + + RecordedRequest request = server.takeRequest(); + assertEquals("POST", request.getMethod()); + assertEquals("/compatible-mode/v1/chat/completions", request.getPath()); + assertEquals("Bearer test-key", request.getHeader("Authorization")); + assertTrue(request.getBody().readUtf8().contains("\"response_format\":{\"type\":\"json_object\"}")); + assertEquals("{\"score\":86}", content); + } + + @Test + void rejectsBlankApiKeyWithoutIncludingPromptsInMessage() { + ServiceException exception = assertThrows(ServiceException.class, + () -> client(" ").completeJson("confidential system prompt", "confidential user prompt")); + + assertEquals(HttpStatus.BAD_GATEWAY, exception.getCode()); + assertFalse(exception.getMessage().contains("confidential")); + } + + @Test + void rejectsNonSuccessResponseWithoutLeakingPromptOrKey() throws Exception { + server.start(); + server.enqueue(new MockResponse().setResponseCode(401).setBody("provider details")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> client("secret-key").completeJson("private system prompt", "private user prompt")); + + assertEquals(HttpStatus.BAD_GATEWAY, exception.getCode()); + assertFalse(exception.getMessage().contains("private")); + assertFalse(exception.getMessage().contains("secret-key")); + } + + @Test + void rejectsResponseWithoutMessageContent() throws Exception { + server.start(); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"choices\":[{\"message\":{}}]}")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> client("test-key").completeJson("system prompt", "user prompt")); + + assertEquals(HttpStatus.BAD_GATEWAY, exception.getCode()); + } + + @Test + void rejectsMalformedResponse() throws Exception { + server.start(); + server.enqueue(new MockResponse().setResponseCode(200).setBody("not-json")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> client("test-key").completeJson("system prompt", "user prompt")); + + assertEquals(HttpStatus.BAD_GATEWAY, exception.getCode()); + } + + private DashScopeQwenChatClient client(String apiKey) { + QwenProperties properties = new QwenProperties(); + properties.setBaseUrl(server.url("/compatible-mode/v1").toString().replaceAll("/$", "")); + properties.setApiKey(apiKey); + properties.setModel("qwen-test"); + properties.setTimeoutMillis(3_000L); + return new DashScopeQwenChatClient(properties); + } +}