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);
+ }
+}